diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d1633e25..5cfebfbee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,25 @@ here.** ### Added +- **A model explorer site, built by `docsSiteFile()`.** The docs site now shows what the + software does and how the model is designed: a main page with count tiles and the feature and + quality cards with their roll-ups, a model map with slices (package, requirement subtree, + prompt) and colour-by (kind, API, claims, capability, status), a page per object, projection, + report, prompt and requirement with a one-hop mini-map, the traceability matrix, a trace from a + requirement to the files generated from it, the API index and a gaps page. Every page is drawn + from `explorer.json`, which the generator builds in the same run from the loaded model, the + requirement gate's ledger, the files the run wrote and the routes it mounts; it runs after the + other generators wherever it is listed. Declared status is a filled chip with its glyph, gate + verdicts are outlines, roll-ups are bars, and no colour reports a test result. Pages are marked + for Pagefind, work at phone width, depend on the model alone (so `meta verify --codegen` holds + them) and are link-checked. The graphs load Cytoscape.js from `assetBase`. `meta eject + docs-site` now copies the explorer's `explorer-*.mustache` templates and `explorer.css` / + `explorer.js` beside the classic theme. `buildExplorer` takes an optional `routes` input, and + nodes carry their declaration `order`. Each `requirements.rows` entry in `meta verify + --format json` now carries `node`: the entity's resolution key on a + `WARN_REQUIREMENT_OBJECT_UNCLAIMED` row, `""` on every other row, and each finding in a + `requirementLedger` row carries its `message`. See `docs/features/explorer-site.md`. + - **Generated record models enforce the four cross-field validators in every port.** `validator.comparison`, `validator.requiredWhen`, `validator.presentIff` and `validator.atLeastOne` used to render only as DB `CHECK` constraints, so a store with no `CHECK` @@ -183,6 +202,14 @@ here.** ### Changed +- **`docsSiteFile()` builds the model explorer by default.** A project that lists + `docsSiteFile()` gets a different site under `site/` on its next `meta gen`, with new page + paths (object pages move under `model/`); `meta gen` removes the old pages it wrote, as it + does any untouched generated file it no longer emits, and `meta verify --codegen` reports + drift until it has run. Pass `docsSiteFile({ site: "classic" })` to keep the earlier + site byte for byte. A project that ejected the classic theme gets a warning and the packaged + explorer theme; run `meta eject docs-site` again to copy the explorer's files. + - **Python and Java report a missing provider's errors in the same order as the other ports.** When a file holds both an unknown subtype and unknown attributes, Python used to drop the whole file at the unknown subtype and never report the attributes; it now reports diff --git a/agent-context/skills/metaobjects-codegen/references/typescript-docs.md b/agent-context/skills/metaobjects-codegen/references/typescript-docs.md index 477959e31..ff88a88e8 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript-docs.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript-docs.md @@ -23,7 +23,7 @@ generate, docs included). Each factory is an ordinary entry in `generators`, fro | `requirementsFile()` | `requirements` | the declared `requirement.*` ledger, each entry headed by its dotted path and `title`. Writes nothing for a model with no `requirement.*` node. | | `agentDocsFile()` | `agent-docs` | `agent/schema.md`, `agent/ui.md` (only when the run also lists a client-tier generator, OR the config sets `includeUiTier: true` — the escape hatch for a docs-only config whose UI is generated by a separate config; skipping it with a UI surface in the model and `includeUiTier` unset prints an advisory naming `includeUiTier`; `includeUiTier: false` says there is no UI anywhere and skips it without one) and `agent/requirements.md` — the three `agent/` pages the always-on pointer reads before touching a tier. | | `metamodelDocsFile()` | `metamodel-docs` | the built-in vocabulary reference, under `metamodel/` (was `meta docs --metamodel`). | -| `docsSiteFile()` | `docs-site` | an HTML site under `site/` (was `meta docs --site`). Option: `prompts`. `meta eject docs-site` copies its templates and assets to `codegen/docs-site/` (was `meta docs --scaffold-site`). | +| `docsSiteFile()` | `docs-site` | an HTML site under `site/` (was `meta docs --site`): the model explorer by default (what the software does, the model map, requirement pages, matrix, trace, API, gaps), drawn from an `explorer.json` built in the same run; `site: "classic"` writes the earlier site. Options: `site`, `prompts`. `meta eject docs-site` copies its templates and assets to `codegen/docs-site/` (was `meta docs --scaffold-site`). | ```ts import { defineConfig } from "@metaobjectsdev/cli"; diff --git a/docs/README.md b/docs/README.md index b71020904..542ee95aa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,6 +48,7 @@ docs/ │ ├── contract-emitters.md # the contract-schema and openapi generators: JSON Schema and OpenAPI for the REST contract (FR-022) │ ├── cube-export.md # the cube-model generator: reporting vocabulary as Cube data-model files (FR-044) │ ├── explorer-data.md # explorer.json and its inputs: export --effective, the verify ledger rows, manifest provenance +│ ├── explorer-site.md # the model explorer site docsSiteFile() builds from explorer.json │ └── own-your-codegen.md # scaffold-and-own generator ownership (ADR-0034) └── ports/ # one file per language/framework port ├── typescript.md @@ -80,6 +81,7 @@ this tree is documentation, not the source of truth. | Publish the API as JSON Schema or OpenAPI, and check a change against the last release | [`features/contract-emitters.md`](features/contract-emitters.md), [`recipes/check-api-against-last-release.md`](recipes/check-api-against-last-release.md) | | Export the reporting vocabulary to Cube (`cube-model`: cubes, joins, rollups; the `cube` lane that checks it against a real Cube) | [`features/cube-export.md`](features/cube-export.md) | | Build the data a model explorer reads (`explorer.json` from `meta export --effective`, `meta verify --format json` and the generated-file manifest) | [`features/explorer-data.md`](features/explorer-data.md) | +| Build the model explorer site: what the software does, the model map, requirement pages, the matrix, trace and gaps (`docsSiteFile()`) | [`features/explorer-site.md`](features/explorer-site.md) | | Wire prompt construction (FR-004) | [`features/templates-and-payloads.md`](features/templates-and-payloads.md) | | Share a metadata shape across multiple instances (abstracts, `extends:`) | [`features/abstracts-and-inheritance.md`](features/abstracts-and-inheritance.md) | | Add a custom metamodel subtype or attribute to a downstream project | [`features/extending-with-providers.md`](features/extending-with-providers.md) + [`recipes/extending-metaobjects-with-providers.md`](recipes/extending-metaobjects-with-providers.md) | diff --git a/docs/features/cli.md b/docs/features/cli.md index 0e6d1de71..f69c34380 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -324,7 +324,7 @@ documentation never lands in the code's output directory by default. | `requirements` | `requirementsFile()` | The declared ledger, as `requirements.md` + `requirements.toon`; nothing for a model with no `requirement.*` node. | | `agent-docs` | `agentDocsFile()` | Three pages an agent reads BEFORE touching a tier (below), under `agent/`. | | `metamodel-docs` | `metamodelDocsFile()` | The reference of the BUILT-IN metamodel (every registered type, subtype and attribute), not your model, under `metamodel/`. Its rendered form is published at . | -| `docs-site` | `docsSiteFile()` | The HTML site under `site/`; `meta eject docs-site` copies its templates and assets to `codegen/docs-site/` for you to change. | +| `docs-site` | `docsSiteFile()` | The HTML site under `site/`: the model explorer by default ([explorer-site.md](explorer-site.md)), the earlier site with `site: "classic"`; `meta eject docs-site` copies its templates and assets to `codegen/docs-site/` for you to change. | ```ts targets: { docs: { outDir: "./docs/generated" } }, diff --git a/docs/features/explorer-data.md b/docs/features/explorer-data.md index fd55f77fa..5502ac514 100644 --- a/docs/features/explorer-data.md +++ b/docs/features/explorer-data.md @@ -1,17 +1,19 @@ -# Explorer data: `explorer.json` and the three inputs it reads +# Explorer data: `explorer.json` and the inputs it reads A model explorer shows one project so a reader can see what it does and how it was designed: the objects and how they connect, the prompts and what they send and get back, the requirement tree and whether each claim holds, and which generated files each node produced. All of that -is read from one data file, `explorer.json`. This page documents that file and the three -inputs it is built from. +is read from one data file, `explorer.json`. This page documents that file and the inputs it is +built from. -This is the data layer only. No screen reads it yet. +This page is the data layer. The site that reads it, built by `docsSiteFile()`, is +[explorer-site.md](explorer-site.md); that generator builds the file during `meta gen`, so +you only build it by hand to feed a tool of your own. ## The inputs are port-neutral The builder (`buildExplorer` in `@metaobjectsdev/docs-site`) never loads metadata itself. It -reads three documents: +reads three documents, and a fourth optional input, the REST routes (see [`api`](#api)): | Input | Produced by | What it contributes | | --- | --- | --- | @@ -74,12 +76,17 @@ does not have to infer "checked and clean" from a path that no diagnostic mentio | `digest` | The same SHA-256 over the claim that each generated requirement test carries ([requirements.md](requirements.md)). | | `implementedBy` | Each link as written (`ref`), whether it resolves, and the node it resolves to (`target`): the object's resolution key, followed by `.` for a member. The gate's own resolver produces these values. | | `supersededBy` | The same, for `@supersededBy`; its `target` is the replacement's `qualifiedPath`. | -| `diagnostics` | Every finding reported against this requirement, as `{ code, severity, source }`. `source` is `gate` when the finding can fail the build and `lint` when it cannot. | +| `diagnostics` | Every finding reported against this requirement, as `{ code, severity, source, message }`. `source` is `gate` when the finding can fail the build and `lint` when it cannot. Two packages may declare a requirement at the same `path`, so read a requirement's findings here rather than matching `requirements.rows` by path. | | `verdict` | From gate findings only: `error`, `warn`, or `clean`. | A finding that is not about a single requirement, such as an entity nobody claims, appears -in `requirements.rows` and in no ledger row. The structured payload is TypeScript-only, as -the rest of `--format json` is. +in `requirements.rows` and in no ledger row. Each `requirements.rows` entry is `{ code, path, +node, severity, source, message }`. `path` is the requirement's dotted path, or `""` when the +subject is not a requirement. `node` names that other subject: a +`WARN_REQUIREMENT_OBJECT_UNCLAIMED` row carries the entity's resolution key there (for example +`"node": "acme::shop::AuditEntry"`, the entity's id in `explorer.json`), and every other row +carries `""`. Read the subject from `path` and `node`, never from `message`, whose wording may +change. The structured payload is TypeScript-only, as the rest of `--format json` is. ### Node provenance in `.hashes.json` @@ -117,6 +124,7 @@ interface ExplorerJson { edges: ExplorerEdge[]; ledger?: ExplorerLedger; // absent without a verify payload with requirements generated?: ExplorerGenerated[]; // absent without a manifest + api?: ExplorerRoute[]; // absent without a routes input slices: ExplorerSlice[]; } ``` @@ -134,6 +142,7 @@ each node nested inside one (field, identity, source, view, index, nested requir | `id` | A top-level node's id is `::`. A nested node's id is its parent's id followed by `.`, for example `acme::shop::Order.total`. A nested requirement's id equals its ledger row's `qualifiedPath`. A nested node with no name gets `#` in place of `.`. When siblings of different types share a name, the first one in the effective JSON keeps `.`, because that is the node a dotted `@implementedBy` reference such as `Order.id` resolves to. Each later one adds its type, for example `acme::shop::AuditEntry.id[identity.primary]`. | | `type`, `subType`, `name`, `package` | As in the model. `package` is the package of the top-level ancestor. | | `parent` | The id of the node it is nested in. A top-level node has no `parent`. | +| `order` | Its position among its siblings in the effective tree, from 0: the declaration order, which the id sort does not keep. | | `extends`, `abstract`, `isArray` | As in the effective JSON. | | `inheritedFrom` | Set on a member that came from a super. It holds the id of the object the member is declared on, meaning the topmost ancestor that has a member with the same type, name and body. A member redeclared with a different body belongs to the node itself. | | `attrs` | Every `@` attribute, without the `@`, with keys sorted. | @@ -171,6 +180,11 @@ finding, including those about something other than a single requirement. The manifest entries that carry `sourceNode`, grouped by node: `{ node, files: [{ path, generatedBy }] }`, sorted by node and then by path. A plain entry has no node and is left out. +### `api` + +One row per REST route: `{ node, method, path }`, with the project's `apiPrefix` in the path, +sorted by node, then path, then method. Absent when no routes were passed. + ### `slices` Slices are named node sets that a view can open on. They are all derived from the model, so @@ -186,7 +200,7 @@ never in metadata, and are not built yet. ### Determinism -The same three inputs always produce the same bytes. Nodes are sorted by id. Edges are sorted +The same inputs always produce the same bytes. Nodes are sorted by id. Edges are sorted by kind, then `from`, then `to`, then `via`, with duplicates removed. Slices are sorted by id, and ledger rows stay in the verify payload's order. `serializeExplorer` writes JSON indented two spaces, with one trailing newline. That makes the file safe to commit and diff. @@ -205,14 +219,17 @@ writeFileSync("explorer.json", serializeExplorer(buildExplorer({ }))); ``` -A `verify` or `manifest` that is missing or malformed leaves its section out. A `model` that is +`routes` (optional) is a list of `{ node, method, path }`, one per REST route, and becomes the +`api` section; `docsSiteFile()` fills it from the routes the run mounts. The manifest records +files, not the routes inside them, so a hand build that wants routes passes them here. + +A `verify`, `manifest` or `routes` that is missing or malformed leaves its section out. A `model` that is not canonical metadata JSON makes the builder throw. ## Not built yet - **`usage`**: which hand-written modules import a generated module. The design marks it optional, and nothing builds it yet. -- **Routes per node** in `generated`: the manifest records files, not the routes inside them. - **Provenance from the JVM, Python and C# generators.** Their manifests have no `sourceNode` yet, so for those ports `generated` is empty. diff --git a/docs/features/explorer-site.md b/docs/features/explorer-site.md new file mode 100644 index 000000000..c221b8645 --- /dev/null +++ b/docs/features/explorer-site.md @@ -0,0 +1,181 @@ +# The model explorer site + +`docsSiteFile()` builds a static site that shows one project: what the software does, how the +model is put together, and whether each requirement claim holds. Every page is drawn from one +data file, [`explorer.json`](explorer-data.md), which the generator builds during the same +`meta gen` run. A page never works a model fact out for itself. + +The explorer is the default site. The earlier site (package navigation, a page per object, the +Mermaid ER diagrams) is still there as `docsSiteFile({ site: "classic" })`. + +## Building it + +List the generator with a `docs` target in `metaobjects.config.ts`: + +```ts +import { defineConfig } from "@metaobjectsdev/cli"; +import { docsSiteFile } from "@metaobjectsdev/codegen-ts/generators"; +import { entityFile } from "./codegen/generators/entity"; +import { routesFile } from "./codegen/generators/routes"; + +export default defineConfig({ + apiPrefix: "/api", + targets: { docs: { outDir: "./docs/generated" } }, + generators: [entityFile(), routesFile(), docsSiteFile()], +}); +``` + +`meta gen` writes the site to `docs/generated/site/`. The generator runs after every other +generator in the run, wherever it appears in the list, because it reads what they did: + +| `explorer.json` input | Where `meta gen` gets it | +|---|---| +| The effective model | The model this run loaded (the same bytes as `meta export --effective`) | +| The requirement ledger | The requirement gate, run with its defaults (the rows `meta verify --format json` prints) | +| Generated files per node | The files this run wrote, with the node each came from. Files under a target whose `outDir` lies outside the project are left out, under `meta gen` and `verify --codegen` alike | +| Routes per node | The REST routes this run's routes generator mounts; none if the run has no routes generator | + +Open `site/index.html` in a browser, or serve the directory. The pages use relative links, so +the site works from any path and from `file://`. + +### Options + +| Option | Default | What it does | +|---|---|---| +| `site` | `"explorer"` | `"classic"` builds the earlier site instead | +| `subDir` | `"site"` | The directory under the `docs` target | +| `title` | the project directory's name | The name in the header and the page titles | +| `prompts` | none | An extra directory of prompt `.mustache` sources, so the prompt pages can show the text | +| `stamp` | none | Footer text. With none, the pages depend on the model alone | +| `assetBase` | `https://cdn.jsdelivr.net/npm` | Where the graph library loads from (see "Offline use") | +| `target` | `"docs"` | The output target | + +### Search + +Every page has a search box over a built-in index of objects, fields, prompts and +requirements; press `/` to jump to it. For full-text search, run +[Pagefind](https://pagefind.app/) over the output after `meta gen`: + +```bash +npx pagefind --site docs/generated/site +``` + +The page content is marked `data-pagefind-body`, and each page carries a `section` and `kind` +filter. When the site is served over HTTP and `pagefind/pagefind.js` exists, the search box +uses it; otherwise it keeps the built-in index. Pagefind takes its base URL from where +`pagefind/pagefind.js` loads, so result links work when the site is served under a sub-path. +Pagefind's output is not generated by `meta gen`, so leave `pagefind/` out of the drift check +(or out of the repository). + +### Offline use + +The map, the mini-maps and the trace draw with [Cytoscape.js](https://js.cytoscape.org/), +loaded from `${assetBase}/cytoscape@3.30.2/dist/cytoscape.min.js`. Only those three kinds of +page load it. Opened without network access, every page still renders and the graphs show a +notice in their place. To serve the file yourself, put +`cytoscape@3.30.2/dist/cytoscape.min.js` under a path of your own and pass that path: + +```ts +docsSiteFile({ assetBase: "/vendor/npm" }) +``` + +## The screens + +| Screen | Page | What it shows | +|---|---|---| +| What it does | `index.html` | Six count tiles, the features (`requirement.functional`) as cards by level with their roll-up, and the qualities (`requirement.architectural`). A folded summary slot stays empty: this build makes no LLM call | +| Model map | `map.html` | Every top-level node and the edges between them. Open a slice (a package, a requirement subtree, a prompt), colour by kind, API, claims, capability or status, and hide or show value objects and nodes outside the slice. The URL keeps the choice (`?slice=&colour=&focus=`) | +| Object | `model//.html` | Why it exists (the requirements that claim it), its fields, inherited ones marked, relationships, indexes, a one-hop mini-map, its routes and the files generated from it. A projection shows its read model and each field's origin; a report shows where its rows come from, its dimensions and its measures | +| Prompt | `model//.html` | The payload and reply types, the template text with each variable marked, and which payload fields the text never uses | +| Features, qualities | `requirements/features.html`, `requirements/qualities.html` | The requirement tree, one card per entry | +| Requirement | `requirements//.html` | Its status, what it claims, the entries below it with their roll-up, what the gate found, its authoring advisories apart, and links to its slice of the map and its trace | +| Matrix | `matrix.html` | Requirements against stored entities and prompts. An empty column is a node nothing claims | +| Trace | `trace.html?start=` | One requirement down to the model nodes it claims and the files generated from them | +| API | `api.html` | Every route this run mounts, by node | +| Gaps | `gaps.html` | The gate's findings and coverage warnings, the authoring lint's advisories (listed apart, never as a verdict), undecided gaps as the gate counts them (a roll-up parent of outstanding work is left out), and the checks this explorer adds: planned entries with live work below, prompts nothing claims, fields named like a reference with no `identity.reference`, payload fields the text never uses | + +An object named `index` gets `index.node.html`, so it never takes its package's index page, +and a model with no package puts its pages under `model/-root/`. + +Every page works at phone width: the navigation scrolls, the tiles stack in two columns, and +wide tables scroll inside their card. + +## How status is drawn + +The colours keep four things apart, so none can be read as another: + +- **Declared status** is a filled chip with its glyph: ● live, ◐ partial, ○ planned, + ⊘ retired. It is what the author wrote in `@status`. +- **Gate verdicts** are outlines: a red ring or badge for an error or an unclaimed entity, an + amber one for a warning. They come from gate findings only. An authoring-lint advisory cannot + fail a build, so it is never coloured: it appears as a neutral line under "Authoring + advisories" on the requirement and Gaps pages. +- **Roll-ups** are stacked bars of the statuses below an entry. A parent's status is written + by hand and never rolled up, so the bar and the chip can disagree, and the Gaps page lists + where they do. +- **Test results are never coloured.** The site reads no test run, and nothing on it claims + one passed. + +## Own the theme + +```bash +meta eject docs-site +``` + +copies the templates of both sites to `codegen/docs-site/templates/` (the explorer's are the +`explorer-*.mustache` files) and their CSS and JS to `codegen/docs-site/assets/` +(`explorer.css`, `explorer.js`). Only files that are not there yet are written (`--force` +overwrites). `docsSiteFile()` renders with your copies, and a file you did not copy falls back +to the packaged one. A project that ejected the theme before the explorer existed has only the +classic files; `meta gen` warns about it, and running `meta eject docs-site` again adds the +explorer's files without touching the ones you own. + +## Drift and determinism + +The site prints no date and no build time, its graph layouts start from fixed positions, and +the same model writes the same bytes. `meta verify --codegen` therefore holds it to the model +like any other generated output. Every build also checks its own links and fails on a dangling +one. + +## As a library + +```ts +import { generateExplorerSite, readPromptTexts, type ExplorerJson } from "@metaobjectsdev/docs-site"; + +const explorer: ExplorerJson = JSON.parse(readFileSync("explorer.json", "utf8")); +generateExplorerSite({ + explorer, + outDir: "site", + title: "Shop", + texts: readPromptTexts(explorer, ["prompts"]), +}); +``` + +It takes `templatesDir`, `assetsDir`, `assetBase` and `stamp` as well, and throws on a +dangling link. Build `explorer.json` itself with `buildExplorer` +([explorer-data.md](explorer-data.md)). + +## Not built yet + +- A 3D view of the map, an LLM-written summary, a test-result overlay, a live view served by + the running application, and publishing the site. +- "Hand-written code that imports it": the site reads no import graph, so the object pages say + it was not scanned. +- Generated-file provenance from the JVM, Python and C# generators (see + [explorer-data.md](explorer-data.md)), so for those ports the generated-file lists are empty. + +## Verified by + +- `server/typescript/packages/docs-site/test/explorer-site.test.ts`: the shop fixture + (`fixtures/explorer-conformance/shop/`) and the fitness model + (`fixtures/persistence-conformance/canonical/meta.fitness.json`). Every screen is written, + links resolve, two builds match byte for byte, status chips and verdict outlines are + distinct, and projection and report pages show their read model. +- `server/typescript/packages/codegen-ts/test/docs-site-explorer.test.ts`: the generator sees + the files and routes of generators listed after it, and the ledger is asked for once. +- `server/typescript/packages/cli/test/explorer-site-ledger.test.ts`: the requirement views read + the gate's per-node ledger, so two packages sharing a path keep their own findings, an + authoring advisory is never a verdict, and the undecided card matches the gate. +- `server/typescript/packages/cli/test/gen-docs-site.test.ts` and + `gen-docs-generators.test.ts`: `meta gen` writes the site, a second run is identical, + `meta verify --codegen` is clean, and an ejected `explorer.css` wins. diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 0371bd297..b74b1cbb1 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -299,10 +299,12 @@ wins over the packaged one. ### The docs site: `meta eject docs-site` copies the theme -`docsSiteFile()` writes the HTML site (package navigation, search, a page per object and -package, the ER diagrams) under `site/` in the `docs` target. It loads the model from your -declared source directories, with your config's providers. To change how it looks, copy the -templates and assets into your repo: +`docsSiteFile()` writes the HTML site under `site/` in the `docs` target. By default that is +the model explorer ([explorer-site.md](explorer-site.md)), drawn from the model this run +loaded; `docsSiteFile({ site: "classic" })` writes the earlier site (package navigation, +search, a page per object and package, the ER diagrams), which loads the model from your +declared source directories with your config's providers. To change how either looks, copy +the templates and assets into your repo: ```bash meta eject docs-site @@ -314,10 +316,11 @@ That writes the Mustache templates to `codegen/docs-site/templates/` and the CSS packaged one. The site prints no date, so its pages depend on the model alone and `meta verify --codegen` can check them; `docsSiteFile({ stamp })` adds footer text. -The page chrome loads daisyUI, Tailwind's browser build, Mermaid and svg-pan-zoom from -jsDelivr, so a site opened offline renders unstyled and its diagrams do not work. -`docsSiteFile({ assetBase: "/vendor/npm" })` replaces that base; serving the five files from -it is on you. The files and how to fetch them are in the `@metaobjectsdev/docs-site` README, +The explorer loads one file from jsDelivr, Cytoscape.js, for its graphs. The classic page +chrome loads daisyUI, Tailwind's browser build, Mermaid and svg-pan-zoom from jsDelivr, so a +classic site opened offline renders unstyled and its diagrams do not work. +`docsSiteFile({ assetBase: "/vendor/npm" })` replaces that base for either site; serving the +files from it is on you. The files and how to fetch them are in the `@metaobjectsdev/docs-site` README, "Offline use". ### The ER diagram comes from `docsFile()` diff --git a/docs/features/requirements.md b/docs/features/requirements.md index c7b7db9d3..8f072f87f 100644 --- a/docs/features/requirements.md +++ b/docs/features/requirements.md @@ -369,7 +369,7 @@ the summary at `info`, warnings as warnings and errors as errors. | `WARN_REQUIREMENT_DISPOSITION_NOT_APPLICABLE` | warn | `@disposition` on a status other than `planned` or `partial`. | | `WARN_REQUIREMENT_NOTHING_IMPLEMENTS` | warn | A `live` or `partial` functional requirement where neither it nor anything nested under it names a node. An error under [the strict switch](#requiring-implementers). | | `WARN_REQUIREMENT_DEFERRED_UNTRACKED` | warn | `@disposition: deferred` with no `@trackedBy`. | -| `WARN_REQUIREMENT_OBJECT_UNCLAIMED` | warn | Coverage is measured and no requirement claims a concrete entity. The line has no requirement path, because its subject is the entity. | +| `WARN_REQUIREMENT_OBJECT_UNCLAIMED` | warn | Coverage is measured and no requirement claims a concrete entity. The line has no requirement path, because its subject is the entity. Its `--format json` row names the entity in `node`. | A diagnostic prints as ` []: `. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md index 477959e31..ff88a88e8 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md @@ -23,7 +23,7 @@ generate, docs included). Each factory is an ordinary entry in `generators`, fro | `requirementsFile()` | `requirements` | the declared `requirement.*` ledger, each entry headed by its dotted path and `title`. Writes nothing for a model with no `requirement.*` node. | | `agentDocsFile()` | `agent-docs` | `agent/schema.md`, `agent/ui.md` (only when the run also lists a client-tier generator, OR the config sets `includeUiTier: true` — the escape hatch for a docs-only config whose UI is generated by a separate config; skipping it with a UI surface in the model and `includeUiTier` unset prints an advisory naming `includeUiTier`; `includeUiTier: false` says there is no UI anywhere and skips it without one) and `agent/requirements.md` — the three `agent/` pages the always-on pointer reads before touching a tier. | | `metamodelDocsFile()` | `metamodel-docs` | the built-in vocabulary reference, under `metamodel/` (was `meta docs --metamodel`). | -| `docsSiteFile()` | `docs-site` | an HTML site under `site/` (was `meta docs --site`). Option: `prompts`. `meta eject docs-site` copies its templates and assets to `codegen/docs-site/` (was `meta docs --scaffold-site`). | +| `docsSiteFile()` | `docs-site` | an HTML site under `site/` (was `meta docs --site`): the model explorer by default (what the software does, the model map, requirement pages, matrix, trace, API, gaps), drawn from an `explorer.json` built in the same run; `site: "classic"` writes the earlier site. Options: `site`, `prompts`. `meta eject docs-site` copies its templates and assets to `codegen/docs-site/` (was `meta docs --scaffold-site`). | ```ts import { defineConfig } from "@metaobjectsdev/cli"; diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md index 477959e31..ff88a88e8 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md @@ -23,7 +23,7 @@ generate, docs included). Each factory is an ordinary entry in `generators`, fro | `requirementsFile()` | `requirements` | the declared `requirement.*` ledger, each entry headed by its dotted path and `title`. Writes nothing for a model with no `requirement.*` node. | | `agentDocsFile()` | `agent-docs` | `agent/schema.md`, `agent/ui.md` (only when the run also lists a client-tier generator, OR the config sets `includeUiTier: true` — the escape hatch for a docs-only config whose UI is generated by a separate config; skipping it with a UI surface in the model and `includeUiTier` unset prints an advisory naming `includeUiTier`; `includeUiTier: false` says there is no UI anywhere and skips it without one) and `agent/requirements.md` — the three `agent/` pages the always-on pointer reads before touching a tier. | | `metamodelDocsFile()` | `metamodel-docs` | the built-in vocabulary reference, under `metamodel/` (was `meta docs --metamodel`). | -| `docsSiteFile()` | `docs-site` | an HTML site under `site/` (was `meta docs --site`). Option: `prompts`. `meta eject docs-site` copies its templates and assets to `codegen/docs-site/` (was `meta docs --scaffold-site`). | +| `docsSiteFile()` | `docs-site` | an HTML site under `site/` (was `meta docs --site`): the model explorer by default (what the software does, the model map, requirement pages, matrix, trace, API, gaps), drawn from an `explorer.json` built in the same run; `site: "classic"` writes the earlier site. Options: `site`, `prompts`. `meta eject docs-site` copies its templates and assets to `codegen/docs-site/` (was `meta docs --scaffold-site`). | ```ts import { defineConfig } from "@metaobjectsdev/cli"; diff --git a/fixtures/explorer-conformance/shop/expected.json b/fixtures/explorer-conformance/shop/expected.json index d28845d4f..fc4b44dda 100644 --- a/fixtures/explorer-conformance/shop/expected.json +++ b/fixtures/explorer-conformance/shop/expected.json @@ -7,6 +7,7 @@ "subType": "value", "name": "Address", "package": "acme::shop", + "order": 1, "attrs": {} }, { @@ -15,6 +16,7 @@ "subType": "string", "name": "city", "package": "acme::shop", + "order": 1, "parent": "acme::shop::Address", "attrs": {} }, @@ -24,6 +26,7 @@ "subType": "string", "name": "street", "package": "acme::shop", + "order": 0, "parent": "acme::shop::Address", "attrs": {} }, @@ -33,6 +36,7 @@ "subType": "architectural", "name": "Addressable", "package": "acme::shop", + "order": 8, "attrs": { "counterexample": "A row nobody can point at.", "implementedBy": [ @@ -48,6 +52,7 @@ "subType": "entity", "name": "AuditEntry", "package": "acme::shop", + "order": 4, "attrs": {} }, { @@ -56,6 +61,7 @@ "subType": "rdb", "name": "db", "package": "acme::shop", + "order": 3, "parent": "acme::shop::AuditEntry", "attrs": { "table": "audit_entries" @@ -67,6 +73,7 @@ "subType": "uuid", "name": "id", "package": "acme::shop", + "order": 0, "parent": "acme::shop::AuditEntry", "attrs": {} }, @@ -76,6 +83,7 @@ "subType": "primary", "name": "id", "package": "acme::shop", + "order": 1, "parent": "acme::shop::AuditEntry", "attrs": { "fields": [ @@ -89,6 +97,7 @@ "subType": "string", "name": "message", "package": "acme::shop", + "order": 2, "parent": "acme::shop::AuditEntry", "attrs": {} }, @@ -98,6 +107,7 @@ "subType": "entity", "name": "BaseEntity", "package": "acme::shop", + "order": 0, "abstract": true, "attrs": {} }, @@ -107,6 +117,7 @@ "subType": "timestamp", "name": "createdAt", "package": "acme::shop", + "order": 1, "parent": "acme::shop::BaseEntity", "attrs": {} }, @@ -116,6 +127,7 @@ "subType": "uuid", "name": "id", "package": "acme::shop", + "order": 0, "parent": "acme::shop::BaseEntity", "attrs": {} }, @@ -125,6 +137,7 @@ "subType": "primary", "name": "pk", "package": "acme::shop", + "order": 2, "parent": "acme::shop::BaseEntity", "attrs": { "fields": [ @@ -138,6 +151,7 @@ "subType": "entity", "name": "Customer", "package": "acme::shop", + "order": 2, "extends": "acme::shop::BaseEntity", "attrs": {} }, @@ -147,6 +161,7 @@ "subType": "timestamp", "name": "createdAt", "package": "acme::shop", + "order": 1, "parent": "acme::shop::Customer", "attrs": {}, "inheritedFrom": "acme::shop::BaseEntity" @@ -157,6 +172,7 @@ "subType": "rdb", "name": "db", "package": "acme::shop", + "order": 6, "parent": "acme::shop::Customer", "attrs": { "table": "customers" @@ -168,6 +184,7 @@ "subType": "string", "name": "email", "package": "acme::shop", + "order": 4, "parent": "acme::shop::Customer", "attrs": {} }, @@ -177,6 +194,7 @@ "subType": "uuid", "name": "id", "package": "acme::shop", + "order": 0, "parent": "acme::shop::Customer", "attrs": {}, "inheritedFrom": "acme::shop::BaseEntity" @@ -187,6 +205,7 @@ "subType": "string", "name": "name", "package": "acme::shop", + "order": 3, "parent": "acme::shop::Customer", "attrs": { "required": true @@ -198,6 +217,7 @@ "subType": "primary", "name": "pk", "package": "acme::shop", + "order": 2, "parent": "acme::shop::Customer", "attrs": { "fields": [ @@ -212,6 +232,7 @@ "subType": "object", "name": "shippingAddress", "package": "acme::shop", + "order": 5, "parent": "acme::shop::Customer", "attrs": { "objectRef": "acme::shop::Address" @@ -223,6 +244,7 @@ "subType": "entity", "name": "Order", "package": "acme::shop", + "order": 3, "extends": "acme::shop::BaseEntity", "attrs": {} }, @@ -232,6 +254,7 @@ "subType": "timestamp", "name": "createdAt", "package": "acme::shop", + "order": 1, "parent": "acme::shop::Order", "attrs": {}, "inheritedFrom": "acme::shop::BaseEntity" @@ -242,6 +265,7 @@ "subType": "uuid", "name": "customerId", "package": "acme::shop", + "order": 3, "parent": "acme::shop::Order", "attrs": {} }, @@ -251,6 +275,7 @@ "subType": "reference", "name": "customerRef", "package": "acme::shop", + "order": 7, "parent": "acme::shop::Order", "attrs": { "fields": [ @@ -265,6 +290,7 @@ "subType": "rdb", "name": "db", "package": "acme::shop", + "order": 9, "parent": "acme::shop::Order", "attrs": { "table": "orders" @@ -276,6 +302,7 @@ "subType": "uuid", "name": "id", "package": "acme::shop", + "order": 0, "parent": "acme::shop::Order", "attrs": {}, "inheritedFrom": "acme::shop::BaseEntity" @@ -286,6 +313,7 @@ "subType": "lookup", "name": "orderByCustomerId", "package": "acme::shop", + "order": 8, "parent": "acme::shop::Order", "attrs": { "fields": [ @@ -299,6 +327,7 @@ "subType": "primary", "name": "pk", "package": "acme::shop", + "order": 2, "parent": "acme::shop::Order", "attrs": { "fields": [ @@ -313,6 +342,7 @@ "subType": "string", "name": "reference", "package": "acme::shop", + "order": 4, "parent": "acme::shop::Order", "attrs": {} }, @@ -322,6 +352,7 @@ "subType": "enum", "name": "state", "package": "acme::shop", + "order": 6, "parent": "acme::shop::Order", "attrs": { "values": [ @@ -337,6 +368,7 @@ "subType": "decimal", "name": "total", "package": "acme::shop", + "order": 5, "parent": "acme::shop::Order", "attrs": {} }, @@ -346,6 +378,7 @@ "subType": "value", "name": "OrderNote", "package": "acme::shop", + "order": 6, "attrs": {} }, { @@ -354,6 +387,7 @@ "subType": "string", "name": "text", "package": "acme::shop", + "order": 0, "parent": "acme::shop::OrderNote", "attrs": { "required": true @@ -365,6 +399,7 @@ "subType": "prompt", "name": "OrderNotePrompt", "package": "acme::shop", + "order": 7, "attrs": { "format": "text", "payloadRef": "acme::shop::OrderNoteRequest", @@ -379,6 +414,7 @@ "subType": "value", "name": "OrderNoteRequest", "package": "acme::shop", + "order": 5, "attrs": {} }, { @@ -387,6 +423,7 @@ "subType": "string", "name": "reference", "package": "acme::shop", + "order": 0, "parent": "acme::shop::OrderNoteRequest", "attrs": { "required": true @@ -398,6 +435,7 @@ "subType": "functional", "name": "Shop", "package": "acme::shop", + "order": 9, "attrs": { "counterexample": "A customer who cannot place an order.", "level": 1, @@ -411,6 +449,7 @@ "subType": "functional", "name": "Checkout", "package": "acme::shop", + "order": 0, "parent": "acme::shop::Shop", "attrs": { "counterexample": "An order taken outside checkout.", @@ -429,6 +468,7 @@ "subType": "functional", "name": "Orders", "package": "acme::shop", + "order": 0, "parent": "acme::shop::Shop.Checkout", "attrs": { "counterexample": "A placed order that is lost.", @@ -443,6 +483,7 @@ "subType": "functional", "name": "Noted", "package": "acme::shop", + "order": 1, "parent": "acme::shop::Shop.Checkout.Orders", "attrs": { "counterexample": "An order with no note.", @@ -460,6 +501,7 @@ "subType": "functional", "name": "Recorded", "package": "acme::shop", + "order": 0, "parent": "acme::shop::Shop.Checkout.Orders", "attrs": { "counterexample": "A placed order has no row.", @@ -478,6 +520,7 @@ "subType": "functional", "name": "Quotable", "package": "acme::shop", + "order": 0, "parent": "acme::shop::Shop.Checkout.Orders.Recorded", "attrs": { "counterexample": "An order nobody can quote back.", @@ -495,6 +538,7 @@ "subType": "functional", "name": "Refunded", "package": "acme::shop", + "order": 2, "parent": "acme::shop::Shop.Checkout.Orders", "attrs": { "counterexample": "A paid order that cannot be refunded.", @@ -512,6 +556,7 @@ "subType": "functional", "name": "Shipping", "package": "acme::shop", + "order": 1, "parent": "acme::shop::Shop.Checkout", "attrs": { "counterexample": "A paid order that never ships.", @@ -792,7 +837,8 @@ { "code": "WARN_REQUIREMENT_NOTHING_IMPLEMENTS", "severity": "warn", - "source": "gate" + "source": "gate", + "message": "is 'live' but neither it nor anything nested under it names an implementing node. A functional requirement's check is existence — a subtree that claims nothing is a capability nobody built." } ], "verdict": "warn" @@ -802,6 +848,7 @@ { "code": "WARN_REQUIREMENT_NOTHING_IMPLEMENTS", "path": "Shop.Checkout.Shipping", + "node": "", "severity": "warn", "source": "gate", "message": "is 'live' but neither it nor anything nested under it names an implementing node. A functional requirement's check is existence — a subtree that claims nothing is a capability nobody built." @@ -809,6 +856,7 @@ { "code": "WARN_REQUIREMENT_OBJECT_UNCLAIMED", "path": "", + "node": "acme::shop::AuditEntry", "severity": "warn", "source": "gate", "message": "no requirement claims 'acme::shop::AuditEntry'. Add it to an L4 requirement's 'implementedBy'." diff --git a/fixtures/explorer-conformance/shop/verify.json b/fixtures/explorer-conformance/shop/verify.json index 54702a591..220450bdc 100644 --- a/fixtures/explorer-conformance/shop/verify.json +++ b/fixtures/explorer-conformance/shop/verify.json @@ -46,6 +46,7 @@ { "code": "WARN_REQUIREMENT_NOTHING_IMPLEMENTS", "path": "Shop.Checkout.Shipping", + "node": "", "severity": "warn", "source": "gate", "message": "is 'live' but neither it nor anything nested under it names an implementing node. A functional requirement's check is existence — a subtree that claims nothing is a capability nobody built." @@ -53,6 +54,7 @@ { "code": "WARN_REQUIREMENT_OBJECT_UNCLAIMED", "path": "", + "node": "acme::shop::AuditEntry", "severity": "warn", "source": "gate", "message": "no requirement claims 'acme::shop::AuditEntry'. Add it to an L4 requirement's 'implementedBy'." @@ -246,7 +248,8 @@ { "code": "WARN_REQUIREMENT_NOTHING_IMPLEMENTS", "severity": "warn", - "source": "gate" + "source": "gate", + "message": "is 'live' but neither it nor anything nested under it names an implementing node. A functional requirement's check is existence — a subtree that claims nothing is a capability nobody built." } ], "verdict": "warn" diff --git a/server/typescript/packages/cli/src/commands/gen.ts b/server/typescript/packages/cli/src/commands/gen.ts index bdba49c35..6f96d480f 100644 --- a/server/typescript/packages/cli/src/commands/gen.ts +++ b/server/typescript/packages/cli/src/commands/gen.ts @@ -34,6 +34,7 @@ import { describeError } from "../lib/error-text.js"; import { findRuntimeBoundaryCrossings, runtimeBoundaryWarnings } from "../lib/runtime-boundary-advisory.js"; import { hasLegacyDocsBlock, legacyDocsConfigMessage, NO_CONFIG_MESSAGE } from "../lib/docs-removed.js"; import { buildAgentSchemaInput } from "../lib/agent-schema.js"; +import { requirementPayload } from "../lib/requirement-payload.js"; import { existsSync } from "node:fs"; /** @@ -263,6 +264,8 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat // that is what tells the post-selection audit "this project opted into none", // which is a different statement from a programmatic caller that never said. libraries: genCollection.libraries, + // The requirement ledger the docs site's model explorer shows, built only if it asks. + requirementLedger: () => requirementPayload(metadata, genCollection), ...(cliConfig.entities.length > 0 ? { entityFilter: cliConfig.entities } : {}), }); } catch (err) { diff --git a/server/typescript/packages/cli/src/commands/verify.ts b/server/typescript/packages/cli/src/commands/verify.ts index 4eac6c743..7a0c181fd 100644 --- a/server/typescript/packages/cli/src/commands/verify.ts +++ b/server/typescript/packages/cli/src/commands/verify.ts @@ -12,6 +12,7 @@ import { join, relative, resolve as resolvePath } from "node:path"; import { parseVerifyArgs, type MigrateFlags } from "../lib/args.js"; import { log } from "../lib/log.js"; import { requirementLedgerRows, type RequirementLedgerRow } from "../lib/requirement-ledger.js"; +import { requirementCountsOf, requirementDiagnosticRow, requirementPayload, type RequirementCounts, type RequirementDiagnosticRow } from "../lib/requirement-payload.js"; import { emitStructured, type OutputFormat } from "../lib/format.js"; import { antiPatternRows, missingBaseUrlRows, removedPropRows, ranSection, skippedSection, warnCapped, @@ -408,7 +409,7 @@ export async function verifyCommand( // can carry them IN FULL. They were previously formatted straight to stderr and // existed nowhere else, which is why 96% of a 239-finding report was unreachable // by any flag, env var or format. - let requirementSection: AdvisorySection = + let requirementSection: AdvisorySection = skippedSection("the requirement pass did not run"); let antiPatternSection: AdvisorySection = skippedSection("the advisory anti-pattern pass did not run"); @@ -807,22 +808,7 @@ export async function verifyCommand( // reason the text line carries it: the claimed/total ratio is only ever taken // over what actually loaded, so a reader needs the denominator's provenance to // notice a spine that covers half an estate. - requirementCounts = { - total: s.total, - functional: s.functional, - architectural: s.architectural, - byStatus: order - .filter((k) => (s.byStatus[k] ?? 0) > 0) - .map((k) => ({ status: k, count: s.byStatus[k] ?? 0 })), - undecided: s.undecided, - // Absent when coverage was not measured (FR-043 §5.4), never zeroed: a reader - // cannot tell `0/0 claimed` from "not measured", and the two mean opposite - // things. - ...(s.entitiesTotal === undefined - ? {} - : { entitiesClaimed: s.entitiesClaimed ?? 0, entitiesTotal: s.entitiesTotal }), - metadataFiles: collection.files.length, - }; + requirementCounts = requirementCountsOf(s, collection.files.length); } const errors = diags.filter((d) => d.severity === "error"); @@ -868,9 +854,9 @@ export async function verifyCommand( // Everything this pass found, uncapped, for the structured payload — gate // diagnostics and lint findings in one list, each row saying which it is, so a // reader can tell what can fail a build from what never can. - requirementSection = ranSection([ - ...diags.map((d) => toDiagnosticRow(d, "gate")), - ...lint.map((d) => toDiagnosticRow(d, "lint")), + requirementSection = ranSection([ + ...diags.map((d) => requirementDiagnosticRow(d, "gate")), + ...lint.map((d) => requirementDiagnosticRow(d, "lint")), ]); // The same findings per ENTRY, beside what each entry declares — so a reader learns an // entry was checked and found clean from a row saying so, not from its path's absence. @@ -1704,6 +1690,8 @@ export async function verifyCommand( return 2; } const config = forgeConfig; + // The explorer's requirement ledger, built only if the docs site asks for it. + const requirementLedger = () => requirementPayload(codegenRoot, genCollection); let result; try { result = await computeCodegenDrift(config, codegenRoot, genConfigDir, genCollection.inScope, genCollection.ownFiles, { @@ -1713,6 +1701,7 @@ export async function verifyCommand( config.dialect, (m) => log.warn(`verify --codegen: [agent-docs] ${m}`), ), + requirementLedger, }); } catch (err) { log.error(`verify --codegen: regeneration failed: ${describeError(err)}`); @@ -1735,6 +1724,7 @@ export async function verifyCommand( genCollection.ownFiles, { sourceRoots: sourceRootsOf(genCollection), agentSchema: () => buildAgentSchemaInput(codegenRoot, config.dialect, () => {}), + requirementLedger, }); return probe.error === undefined && probe.clean; } finally { @@ -1942,20 +1932,6 @@ function toSchemaDriftRow(c: Change): SchemaDriftRow { // structured output (--format toon|json) // --------------------------------------------------------------------------- -/** The ledger counts `meta verify` prints on every run, machine-readable. */ -interface RequirementCounts { - total: number; - functional: number; - architectural: number; - byStatus: { status: string; count: number }[]; - undecided: number; - /** Absent when coverage was not measured — FR-043 §5.4. */ - entitiesClaimed?: number; - entitiesTotal?: number; - /** How many metadata files the two entity counts were taken over. */ - metadataFiles: number; -} - /** One gate's verdict. `ran: false` is NOT a pass — it is "not selected". */ interface VerifyGateRow { gate: string; @@ -2090,7 +2066,7 @@ function toDiagnosticRow(d: Diagnostic, source: "gate" | "lint"): AdvisoryDiagno function buildVerifyPayload(input: { gates: VerifyGateRow[]; exitCode: number; - requirements: AdvisorySection; + requirements: AdvisorySection; requirementCounts: RequirementCounts | undefined; requirementLedger?: RequirementLedgerRow[] | undefined; antiPatterns: AdvisorySection; diff --git a/server/typescript/packages/cli/src/lib/advisory.ts b/server/typescript/packages/cli/src/lib/advisory.ts index bc8397d4d..bb97eb871 100644 --- a/server/typescript/packages/cli/src/lib/advisory.ts +++ b/server/typescript/packages/cli/src/lib/advisory.ts @@ -76,7 +76,7 @@ export interface AdvisoryFindingRow { export interface AdvisoryDiagnosticRow { code: string; /** The dotted child-name path, or "" for a diagnostic whose subject is not a - * requirement (object coverage names the entity in its message instead). */ + * requirement (a requirement row names an unclaimed entity in `node` instead). */ path: string; severity: string; /** "gate" — can fail the build; "lint" — advisory authoring warning, never can. */ diff --git a/server/typescript/packages/cli/src/lib/codegen-drift.ts b/server/typescript/packages/cli/src/lib/codegen-drift.ts index 334e02f51..259d53825 100644 --- a/server/typescript/packages/cli/src/lib/codegen-drift.ts +++ b/server/typescript/packages/cli/src/lib/codegen-drift.ts @@ -187,7 +187,7 @@ export async function computeCodegenDrift( projectRoot: string, scope?: (fqn: string) => boolean, sourceFiles?: readonly string[], - run?: Pick, + run?: Pick, ): Promise { const root = isAbsolute(projectRoot) ? projectRoot : resolve(projectRoot); @@ -218,16 +218,19 @@ export async function computeCodegenDrift( // relative import paths in generated content come out identical. const tempRoot = mkdtempSync(join(tmpdir(), "meta-verify-codegen-")); try { + const untrackedOutDirs: string[] = []; const tempFor = (committed: string): string => { const rel = relative(root, committed); // If a committed outDir lives outside the project root, fall back to a // flattened, collision-safe slot under the temp root. (Relative-import // fidelity for out-of-tree targets isn't guaranteed, but the diff is // still meaningful per-file.) - const safeRel = rel.startsWith("..") || isAbsolute(rel) - ? committed.replace(/[^A-Za-z0-9]+/g, "_") - : rel; - return join(tempRoot, safeRel); + if (rel.startsWith("..") || isAbsolute(rel)) { + const slot = join(tempRoot, committed.replace(/[^A-Za-z0-9]+/g, "_")); + untrackedOutDirs.push(slot); + return slot; + } + return join(tempRoot, rel); }; // Remap the config so runGen writes into the temp mirror instead of the @@ -274,10 +277,12 @@ export async function computeCodegenDrift( // owned generator may read anything. `projectRoot` stays the temp tree, which the // owned route and entity generators compute their adapter import against. inputRoot: root, + untrackedOutDirs, ...(scope !== undefined ? { scope } : {}), ...(sourceFiles !== undefined ? { sourceFiles } : {}), ...(run?.sourceRoots !== undefined ? { sourceRoots: run.sourceRoots } : {}), ...(run?.agentSchema !== undefined ? { agentSchema: run.agentSchema } : {}), + ...(run?.requirementLedger !== undefined ? { requirementLedger: run.requirementLedger } : {}), }); // Where gen discards hand edits instead of keeping them (handEdits: "overwrite"). diff --git a/server/typescript/packages/cli/src/lib/requirement-check.ts b/server/typescript/packages/cli/src/lib/requirement-check.ts index afcff955b..afcae8c98 100644 --- a/server/typescript/packages/cli/src/lib/requirement-check.ts +++ b/server/typescript/packages/cli/src/lib/requirement-check.ts @@ -58,8 +58,11 @@ export interface Diagnostic { * path. Two branches of a ledger may reuse a NAME, so a bare name does not * locate the node, and a diagnostic you cannot locate is one you cannot act on. * Absent on a diagnostic whose subject is not a requirement (object coverage - * names the entity in its message instead). */ + * names the entity in `node` instead). */ path?: string; + /** The entity a diagnostic is about when its subject is not a requirement (object + * coverage): its fully-qualified name, `::`. */ + node?: string; message: string; } @@ -659,7 +662,7 @@ export function checkRequirements(root: MetaData, scan: RequirementScan = scanRe const key = ent.resolutionKey(); if (!claimedObjects.has(key)) { out.push({ - severity: OBJECT_COVERAGE_SEVERITY, code: WARN_REQUIREMENT_OBJECT_UNCLAIMED, + severity: OBJECT_COVERAGE_SEVERITY, code: WARN_REQUIREMENT_OBJECT_UNCLAIMED, node: key, message: `no requirement claims '${key}'. Add it to an L${REQUIREMENT_LINK_FLOOR_LEVEL} requirement's 'implementedBy'.`, }); } diff --git a/server/typescript/packages/cli/src/lib/requirement-ledger.ts b/server/typescript/packages/cli/src/lib/requirement-ledger.ts index 9a1896f9f..f9b916af7 100644 --- a/server/typescript/packages/cli/src/lib/requirement-ledger.ts +++ b/server/typescript/packages/cli/src/lib/requirement-ledger.ts @@ -43,6 +43,7 @@ export interface RequirementDiagnosticRef { severity: string; /** "gate" can fail the build; "lint" never can. */ source: "gate" | "lint"; + message: string; } /** One `requirement.*` node, as this run of `meta verify` judged it. */ @@ -99,7 +100,7 @@ export function requirementLedgerRows( const byNode = new Map(); const byPath = new Map(); const note = (d: Diagnostic, source: "gate" | "lint"): void => { - const ref = { code: d.code, severity: d.severity, source }; + const ref = { code: d.code, severity: d.severity, source, message: d.message }; const requirement = diagnosticRequirement.get(d); if (requirement !== undefined) { byNode.set(requirement, [...(byNode.get(requirement) ?? []), ref]); diff --git a/server/typescript/packages/cli/src/lib/requirement-payload.ts b/server/typescript/packages/cli/src/lib/requirement-payload.ts new file mode 100644 index 000000000..f410ff7ce --- /dev/null +++ b/server/typescript/packages/cli/src/lib/requirement-payload.ts @@ -0,0 +1,107 @@ +// The requirement ledger as `meta verify --format json` states it, for the docs site's model +// explorer (`docsSiteFile()`), which runs inside `meta gen` and `meta verify --codegen`. +// +// codegen-ts cannot run the requirement gate (it lives here, in the CLI), so the two commands +// compute this and hand it to the run as `RunGenOpts.requirementLedger`. It is the same scan, +// gate, lint and ledger rows `meta verify` uses, with the gate's defaults: no +// `--require-implementers`, the authoring lint on. Fixed defaults rather than the caller's +// flags, so the generated site depends on the model alone and `verify --codegen` can hold it. + +import type { MetaData } from "@metaobjectsdev/metadata"; +import { REQUIREMENT_STATUSES } from "@metaobjectsdev/metadata"; +import type { AdvisoryDiagnosticRow } from "./advisory.js"; +import { + checkRequirements, + scanRequirements, + summariseRequirements, + type Diagnostic, + type RequirementSummary, +} from "./requirement-check.js"; +import { lintRequirements } from "./requirement-lint.js"; +import { requirementLedgerRows, type RequirementLedgerRow } from "./requirement-ledger.js"; + +/** The ledger counts `meta verify` prints on every run, machine-readable. */ +export interface RequirementCounts { + total: number; + functional: number; + architectural: number; + byStatus: { status: string; count: number }[]; + undecided: number; + /** Absent when coverage was not measured — FR-043 §5.4. */ + entitiesClaimed?: number; + entitiesTotal?: number; + /** How many metadata files the two entity counts were taken over. */ + metadataFiles: number; +} + +/** The counts as the structured payload carries them. */ +export function requirementCountsOf(s: RequirementSummary, metadataFiles: number): RequirementCounts { + const order = [...REQUIREMENT_STATUSES]; + return { + total: s.total, + functional: s.functional, + architectural: s.architectural, + byStatus: order + .filter((k) => (s.byStatus[k] ?? 0) > 0) + .map((k) => ({ status: k, count: s.byStatus[k] ?? 0 })), + undecided: s.undecided, + // Absent when coverage was not measured (FR-043 §5.4), never zeroed: a reader cannot tell + // `0/0 claimed` from "not measured", and the two mean opposite things. + ...(s.entitiesTotal === undefined + ? {} + : { entitiesClaimed: s.entitiesClaimed ?? 0, entitiesTotal: s.entitiesTotal }), + metadataFiles, + }; +} + +/** One requirement finding as the structured payload carries it. */ +export interface RequirementDiagnosticRow extends AdvisoryDiagnosticRow { + /** The fully-qualified name of the entity the finding is about when its subject is not a + * requirement (`WARN_REQUIREMENT_OBJECT_UNCLAIMED`), or "". */ + node: string; +} + +/** One finding as the structured payload carries it. */ +export function requirementDiagnosticRow(d: Diagnostic, source: "gate" | "lint"): RequirementDiagnosticRow { + return { + code: d.code, + // "" rather than omitted: a tabular encoder (TOON) needs every row to carry the same keys. + path: d.path ?? "", + node: d.node ?? "", + severity: d.severity, + source, + message: d.message, + }; +} + +/** The part of `meta verify --format json` the explorer builder reads. */ +export interface RequirementPayload { + requirementLedger: RequirementLedgerRow[]; + requirementCounts: RequirementCounts; + requirements: { rows: RequirementDiagnosticRow[] }; +} + +/** + * The requirement ledger of a loaded model, or undefined when it declares no requirement. + * `coverable` and `metadataFiles` are the collection's (`inCoverage`, `files.length`). + */ +export function requirementPayload( + root: MetaData, + collection: { inCoverage: (fqn: string) => boolean; files: readonly string[] }, +): RequirementPayload | undefined { + const scan = scanRequirements(root, { coverable: collection.inCoverage, requireImplementers: false }); + const s = summariseRequirements(root, scan); + if (s === undefined) return undefined; + const diags = [...checkRequirements(root, scan)]; + const lint = lintRequirements(root, scan.addressed); + return { + requirementLedger: requirementLedgerRows(root, scan, diags, lint), + requirementCounts: requirementCountsOf(s, collection.files.length), + requirements: { + rows: [ + ...diags.map((d) => requirementDiagnosticRow(d, "gate")), + ...lint.map((d) => requirementDiagnosticRow(d, "lint")), + ], + }, + }; +} diff --git a/server/typescript/packages/cli/test/explorer-fixture.test.ts b/server/typescript/packages/cli/test/explorer-fixture.test.ts index d01388949..08debae40 100644 --- a/server/typescript/packages/cli/test/explorer-fixture.test.ts +++ b/server/typescript/packages/cli/test/explorer-fixture.test.ts @@ -59,7 +59,7 @@ interface LedgerRow { verdict: string; digest: string; implementedBy: { ref: string; resolves: boolean; target?: string }[]; - diagnostics: { code: string; severity: string; source: string }[]; + diagnostics: { code: string; severity: string; source: string; message: string }[]; } describe("explorer fixture — the committed inputs are what the CLI writes today", () => { @@ -136,7 +136,12 @@ describe("meta verify --format json — requirementLedger rows", () => { const shipping = r.get("Shop.Checkout.Shipping"); expect(shipping?.verdict).toBe("warn"); expect(shipping?.diagnostics).toEqual([ - { code: "WARN_REQUIREMENT_NOTHING_IMPLEMENTS", severity: "warn", source: "gate" }, + { + code: "WARN_REQUIREMENT_NOTHING_IMPLEMENTS", + severity: "warn", + source: "gate", + message: expect.stringContaining("is 'live' but neither it nor anything nested under it names an implementing node"), + }, ]); expect(r.get("Shop.Checkout.Orders.Recorded")?.verdict).toBe("clean"); }); diff --git a/server/typescript/packages/cli/test/explorer-site-ledger.test.ts b/server/typescript/packages/cli/test/explorer-site-ledger.test.ts new file mode 100644 index 000000000..4bc3e27e4 --- /dev/null +++ b/server/typescript/packages/cli/test/explorer-site-ledger.test.ts @@ -0,0 +1,144 @@ +// The explorer site's requirement views, driven by the ledger the gate itself computes +// (`requirementPayload`) over small real models. The oracle is the gate: each assertion +// names a fact `meta verify` states, and checks the page says the same. +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { canonicalSerializeEffective } from "@metaobjectsdev/metadata"; +import { loadMemory } from "@metaobjectsdev/sdk"; +import { buildExplorer, generateExplorerSite } from "@metaobjectsdev/docs-site"; +import { requirementPayload } from "../src/lib/requirement-payload.js"; +import { ERR_REQUIREMENT_DANGLING_REF, WARN_REQUIREMENT_NOTHING_IMPLEMENTS } from "../src/lib/requirement-check.js"; +import { WARN_REQUIREMENT_TITLE_IS_AN_ID } from "../src/lib/requirement-lint.js"; + +const dirs: string[] = []; +afterAll(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); +}); + +/** Loads `files`, runs the gate, builds the explorer site, and returns a page reader plus the + * gate's own counts. */ +async function site(files: Record): Promise<{ page: (p: string) => string; undecided: unknown }> { + const dir = mkdtempSync(join(tmpdir(), "explorer-ledger-")); + dirs.push(dir); + mkdirSync(join(dir, "metaobjects")); + for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, "metaobjects", name), body); + const root = await loadMemory(dir, { strict: true }); + const verify = requirementPayload(root, { inCoverage: () => true, files: Object.keys(files) }); + if (verify === undefined) throw new Error("the model declares requirements"); + const explorer = buildExplorer({ model: JSON.parse(canonicalSerializeEffective(root)) as unknown, verify }); + const out = join(dir, "site"); + generateExplorerSite({ explorer, outDir: out, title: "Ledger" }); + return { page: (p) => readFileSync(join(out, p), "utf8"), undecided: verify.requirementCounts.undecided }; +} + +/** The body of the Gaps card titled `title`. */ +function gapsCard(gaps: string, title: string): string { + const card = gaps.split('
').find((c) => c.startsWith(`

${title} `)); + if (card === undefined) throw new Error(`no "${title}" card`); + return card; +} + +/** The row of a requirement page's proof table that reports `what`. */ +function proofRow(html: string, what: string): string { + const row = html.split("").find((r) => r.startsWith(`${what}`)); + if (row === undefined) throw new Error(`no "${what}" proof row`); + return row; +} + +/** The `class` attribute values on a page that mark a gate verdict (`v-*`). */ +function verdictClasses(html: string): string[] { + return [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) => (m[1] ?? "").split(" ")).filter((c) => c.startsWith("v-")); +} + +const billing = (name: string, claim: string): string => ` +metadata: + package: acme::${name} + children: + - object.entity: + name: Invoice + children: + - source.rdb: { table: ${name}_invoices } + - field.uuid: { name: id } + - identity.primary: { name: pk, fields: [id] } + - requirement.functional: + name: Billing + level: 1 + status: live + statement: "Invoices are issued" + counterexample: "A sale with no invoice" + children: + - requirement.functional: + name: Invoicing + level: 4 + status: live + statement: "Each invoice is stored" + counterexample: "An invoice that cannot be found again" + implementedBy: ["${claim}"] +`; + +const checkout = ` +metadata: + package: acme::shop + children: + - requirement.functional: + name: Checkout + level: 1 + status: partial + statement: "Customers can check out" + counterexample: "A cart that cannot be paid" + children: + - requirement.functional: + name: Refunds + level: 2 + status: planned + statement: "Orders can be refunded" + counterexample: "A refund nobody can issue" + - requirement.functional: + name: Notes + level: 1 + status: planned + title: "REQ-12" + statement: "Orders carry notes" + counterexample: "A note that is lost" +`; + +describe("explorer requirement views follow the gate's ledger", () => { + test("two packages' requirements at one path each show only their own findings", async () => { + const s = await site({ "meta.a.yaml": billing("a", "acme::a::Invoice"), "meta.b.yaml": billing("b", "acme::b::Receipt") }); + const a = s.page("requirements/acme/a/Billing.Invoicing.html"); + const b = s.page("requirements/acme/b/Billing.Invoicing.html"); + expect(proofRow(a, "Gate findings")).not.toContain(ERR_REQUIREMENT_DANGLING_REF); + expect(verdictClasses(a)).toEqual([]); + expect(proofRow(b, "Gate findings")).toContain(ERR_REQUIREMENT_DANGLING_REF); + const gate = gapsCard(s.page("gaps.html"), "Gate findings"); + const dangling = gate.split("").find((r) => r.includes(ERR_REQUIREMENT_DANGLING_REF)); + expect(dangling).toContain('href="requirements/acme/b/Billing.Invoicing.html"'); + expect(gate).not.toContain('href="requirements/acme/a/Billing.Invoicing.html"'); + }); + + test("an authoring advisory is listed as one and never drawn as a gate verdict", async () => { + const s = await site({ "meta.shop.yaml": checkout }); + const notes = s.page("requirements/acme/shop/Notes.html"); + expect(verdictClasses(notes)).toEqual([]); + expect(proofRow(notes, "Authoring advisories")).toContain(WARN_REQUIREMENT_TITLE_IS_AN_ID); + expect(proofRow(notes, "Gate findings")).not.toContain(WARN_REQUIREMENT_TITLE_IS_AN_ID); + const gaps = s.page("gaps.html"); + expect(gapsCard(gaps, "Gate findings")).not.toContain(WARN_REQUIREMENT_TITLE_IS_AN_ID); + expect(gapsCard(gaps, "Gate findings")).toContain(WARN_REQUIREMENT_NOTHING_IMPLEMENTS); + const lint = gapsCard(gaps, "Authoring advisories"); + expect(lint).toContain(WARN_REQUIREMENT_TITLE_IS_AN_ID); + expect(verdictClasses(lint)).toEqual([]); + }); + + test("the undecided card matches the gate: a partial parent over a planned child is not owed", async () => { + const s = await site({ "meta.shop.yaml": checkout }); + expect(s.undecided).toBe(2); + const card = gapsCard(s.page("gaps.html"), "Undecided gaps"); + expect(card).toContain('2'); + expect(card).toContain('href="requirements/acme/shop/Checkout.Refunds.html"'); + expect(card).toContain('href="requirements/acme/shop/Notes.html"'); + expect(card).not.toContain('href="requirements/acme/shop/Checkout.html"'); + }); +}); diff --git a/server/typescript/packages/cli/test/gen-docs-generators.test.ts b/server/typescript/packages/cli/test/gen-docs-generators.test.ts index 2e775d2b7..0305f0a94 100644 --- a/server/typescript/packages/cli/test/gen-docs-generators.test.ts +++ b/server/typescript/packages/cli/test/gen-docs-generators.test.ts @@ -244,22 +244,39 @@ describe("meta gen writes the documentation the list declares", () => { }); test("the docs site lands under site/, and an ejected theme is what it renders with", async () => { - const root = project({ config: base(`[docsSiteFile()]`) }); + const root = project({ config: base(`["entity", docsSiteFile()]`) }); expect(await run(["gen", "--cwd", root])).toBe(0); const site = join(root, "docs", "generated", "site"); - expect(existsSync(join(site, "index.html"))).toBe(true); + // The explorer is the default site: its main page, the data every page reads, a page per object. + const index = readFileSync(join(site, "index.html"), "utf8"); + expect(index).toContain('content="metaobjects docs-site explorer"'); // No date in the footer: the pages depend on the model alone. - expect(readFileSync(join(site, "index.html"), "utf8")).toContain("generated · metadata-docs"); + expect(index).toContain("by the metaobjects docs-site generator. Static"); + expect(existsSync(join(site, "model", "app", "Talk.html"))).toBe(true); + // It runs after the entity generator, so the files that run wrote are traced to their node. + const explorer = JSON.parse(readFileSync(join(site, "explorer.json"), "utf8")) as { generated?: { node: string; files: { path: string }[] }[] }; + expect(explorer.generated?.find((g) => g.node === "app::Talk")?.files.map((f) => f.path)).toEqual( + expect.arrayContaining([expect.stringContaining("Talk.ts")]), + ); expect(await run(["eject", "docs-site", "--cwd", root])).toBe(0); - const css = join(root, "codegen", "docs-site", "assets", "site.css"); + const css = join(root, "codegen", "docs-site", "assets", "explorer.css"); expect(existsSync(css)).toBe(true); writeFileSync(css, "/* owned theme */\n", "utf8"); // A re-run keeps the edit. expect(await run(["eject", "docs-site", "--cwd", root])).toBe(0); expect(readFileSync(css, "utf8")).toBe("/* owned theme */\n"); expect(await run(["gen", "--cwd", root])).toBe(0); - expect(readFileSync(join(site, "assets", "site.css"), "utf8")).toBe("/* owned theme */\n"); + expect(readFileSync(join(site, "assets", "explorer.css"), "utf8")).toBe("/* owned theme */\n"); + }); + + test("site: \"classic\" builds the site that shipped before the explorer", async () => { + const root = project({ config: base(`[docsSiteFile({ site: "classic" })]`) }); + expect(await run(["gen", "--cwd", root])).toBe(0); + const site = join(root, "docs", "generated", "site"); + expect(readFileSync(join(site, "index.html"), "utf8")).toContain("generated · metadata-docs"); + expect(existsSync(join(site, "assets", "site.css"))).toBe(true); + expect(existsSync(join(site, "explorer.json"))).toBe(false); }); }); diff --git a/server/typescript/packages/cli/test/gen-docs-site.test.ts b/server/typescript/packages/cli/test/gen-docs-site.test.ts index ee722f1a0..5f1efe438 100644 --- a/server/typescript/packages/cli/test/gen-docs-site.test.ts +++ b/server/typescript/packages/cli/test/gen-docs-site.test.ts @@ -1,7 +1,8 @@ // The docs site as a generator (`docsSiteFile()`, ADR-0034 Amendment 9; `meta docs --site` -// until 1.1.0). The site loads the model with a loader of its own, over whole source -// directories, so what `meta gen` hands it is what these cases hold to: the project's own -// providers, every declared source directory, and output that depends on the model alone. +// until 1.1.0). The default site, the model explorer, reads the model `meta gen` loaded; the +// classic site loads it again with a loader of its own, over whole source directories. Either +// way these cases hold it to the project's own providers, every declared source directory, and +// output that depends on the model alone. import { describe, test, expect, afterEach } from "bun:test"; import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; @@ -102,13 +103,24 @@ describe("meta gen with docsSiteFile()", () => { test("threads the config's providers into the site's own loader, so a custom subtype renders", async () => { const root = await project(CUSTOM_META, config("providers: [geoProvider],", GEO_PROVIDER)); expect(await run(["gen", "--cwd", root])).toBe(0); - expect(existsSync(join(root, "docs-out", "site", "acme", "geo", "Place.html"))).toBe(true); + const page = join(root, "docs-out", "site", "model", "acme", "geo", "Place.html"); + expect(existsSync(page)).toBe(true); + expect(await readFile(page, "utf8")).toContain("geopoint"); }); - test("docsSiteFile({ assetBase }) points the page chrome at that base instead of jsDelivr", async () => { + test("docsSiteFile({ assetBase }) points the explorer's graph library at that base instead of jsDelivr", async () => { const cfg = config("").replace("docsSiteFile()", `docsSiteFile({ assetBase: "/vendor/npm" })`); const root = await project(META, cfg); expect(await run(["gen", "--cwd", root])).toBe(0); + const html = await readFile(join(root, "docs-out", "site", "map.html"), "utf8"); + expect(html).not.toContain("cdn.jsdelivr.net"); + expect(html).toContain(''); + }); + + test("docsSiteFile({ site: \"classic\", assetBase }) points the classic chrome at that base", async () => { + const cfg = config("").replace("docsSiteFile()", `docsSiteFile({ site: "classic", assetBase: "/vendor/npm" })`); + const root = await project(META, cfg); + expect(await run(["gen", "--cwd", root])).toBe(0); const html = await readFile(join(root, "docs-out", "site", "index.html"), "utf8"); expect(html).not.toContain("cdn.jsdelivr.net"); expect(html).toContain(''); @@ -132,8 +144,8 @@ describe("meta gen with docsSiteFile()", () => { expect(await run(["gen", "--cwd", root])).toBe(0); const site = join(root, "docs-out", "site"); for (const [pkg, model] of [["site", "Welcome"], ["shared", "SharedThing"]] as const) { - expect(existsSync(join(site, "acme", pkg, `${model}.html`))).toBe(true); - expect(await readFile(join(site, "acme", pkg, "index.html"), "utf8")).toContain(model); + expect(existsSync(join(site, "model", "acme", pkg, `${model}.html`))).toBe(true); + expect(await readFile(join(site, "model", "acme", pkg, "index.html"), "utf8")).toContain(model); } }); diff --git a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts index 029432456..25d13f73b 100644 --- a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts +++ b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts @@ -503,7 +503,9 @@ export default defineConfig({ docsFile({ target: "out" }), requirementsFile({ target: "out" }), requirementsFile({ target: "out--requirements" }), - docsSiteFile({ target: "out--site" }), + // The classic site, whose pages this diff is stated against; the explorer's report + // pages are covered in docs-site's explorer-site.test.ts. + docsSiteFile({ target: "out--site", site: "classic" }), ], }); `, diff --git a/server/typescript/packages/cli/test/unit/requirement-ledger.test.ts b/server/typescript/packages/cli/test/unit/requirement-ledger.test.ts index a135dce8f..9e83e568d 100644 --- a/server/typescript/packages/cli/test/unit/requirement-ledger.test.ts +++ b/server/typescript/packages/cli/test/unit/requirement-ledger.test.ts @@ -8,7 +8,11 @@ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadMemory } from "@metaobjectsdev/sdk"; -import { checkRequirements, scanRequirements } from "../../src/lib/requirement-check.js"; +import { + checkRequirements, + scanRequirements, + WARN_REQUIREMENT_OBJECT_UNCLAIMED, +} from "../../src/lib/requirement-check.js"; import { lintRequirements } from "../../src/lib/requirement-lint.js"; import { requirementLedgerRows } from "../../src/lib/requirement-ledger.js"; @@ -50,7 +54,10 @@ describe("requirementLedgerRows", () => { const gate = checkRequirements(root, scan); expect(gate.length).toBeGreaterThan(0); for (const d of [...gate, ...lintRequirements(root, scan.addressed)]) { - expect(Object.keys(d).filter((k) => !["code", "message", "path", "severity"].includes(k))).toEqual([]); + // Only the object-coverage row names a structured `node` (the unclaimed entity). + const allowed = ["code", "message", "path", "severity"]; + if (d.code === WARN_REQUIREMENT_OBJECT_UNCLAIMED) allowed.push("node"); + expect(Object.keys(d).filter((k) => !allowed.includes(k))).toEqual([]); } const rows = requirementLedgerRows(root, scan, gate, lintRequirements(root, scan.addressed)); const verdicts = rows.map((r) => [r.qualifiedPath, r.verdict, r.diagnostics.map((d) => d.code)]); diff --git a/server/typescript/packages/codegen-ts/src/generator.ts b/server/typescript/packages/codegen-ts/src/generator.ts index d6caa1339..1cf0c7754 100644 --- a/server/typescript/packages/codegen-ts/src/generator.ts +++ b/server/typescript/packages/codegen-ts/src/generator.ts @@ -127,9 +127,35 @@ export interface GenContext { * call, where a docs generator's own options decide. See docs-run.ts. */ docs?: DocsRunFacts; + /** + * The files this run generated, for a generator marked `readsRunOutput` (the docs site's + * model explorer, which lists what was generated from each node). The runner runs such a + * generator after every other one, so the list is complete. Paths are project-relative, + * as `.gen-state/.hashes.json` keys them. A run narrowed with `--generators` adds the + * files the committed manifest records for the generators it did not run, so a narrowed + * run lists what a full run lists. Absent for a generator without the marker. + */ + runOutput?: () => readonly RunOutputFile[]; + /** + * The requirement ledger as `meta verify --format json` states it (`requirementLedger`, + * `requirementCounts`, `requirements.rows`), computed by the caller that owns the + * requirement gate (`meta gen`, `meta verify --codegen`), which codegen-ts cannot import. + * Called at most once per run, and only when a generator asks. Absent when the caller + * supplied none; returns undefined when the model declares no requirement. + */ + requirementLedger?: () => unknown; warn: (msg: string) => void; } +/** One file of the run, as `GenContext.runOutput` lists it. */ +export interface RunOutputFile { + /** Project-relative path, `/`-separated. */ + path: string; + generatedBy: string; + /** The node the file was rendered from (`EmittedFile.sourceNode`), when it was. */ + sourceNode?: string; +} + export interface Generator { /** kebab-case identifier; surfaces in diagnostics + drift logs. */ name: string; @@ -190,6 +216,10 @@ export interface Generator { * pages in the same target link to them (`ctx.docs.apiSurfaces`). Like `emitsModelDocs`, * a cross-link fact only. */ emitsApiDocs?: { readonly subDir: string }; + /** Marks a generator that reads the files the rest of the run generated + * (`ctx.runOutput`). The runner runs every marked generator after every unmarked one, + * in declared order, so the list it reads is complete. */ + readsRunOutput?: boolean; /** §A6 — marks the generator that emits the Names artifact. The runner * aggregates this across the suite into ResolvedGenConfig.includeNames, which the * entity generator reads to decide whether it may reference those constants. diff --git a/server/typescript/packages/codegen-ts/src/generators/docs-site-file.ts b/server/typescript/packages/codegen-ts/src/generators/docs-site-file.ts index 213ce576b..271cbc0a9 100644 --- a/server/typescript/packages/codegen-ts/src/generators/docs-site-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/docs-site-file.ts @@ -7,24 +7,53 @@ // docs-site` copies both into `codegen/docs-site/`, and this generator reads them from there // when they exist (Amendment 8, ruling 3, as Amendment 9 restates it). // -// The site loads the model with its own loader, from whole source DIRECTORIES (one page group -// each), so it reads the collection's source roots off the context rather than the loaded -// root every other generator walks. +// Two sites, chosen by `site`: +// +// explorer (the default) the model explorer: what the software does (the requirement +// tree), the model map, a page per object, prompt, requirement and route, the +// coverage matrix, the trace and the gaps. Every page reads one document, +// `explorer.json`, which this generator builds from the effective model, the +// requirement ledger the caller computed (`ctx.requirementLedger`), the files this +// run wrote (`ctx.runOutput`) and the routes the run mounts. It runs after every +// other generator, so the files it lists are this run's. +// classic the site before the explorer: a page per package and per object with ER +// diagrams. It loads the model with its own loader, from whole source DIRECTORIES +// (one page group each), so it reads the collection's source roots off the context. import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; import { existsSync } from "node:fs"; -import { generateSite } from "@metaobjectsdev/docs-site"; -import type { EmittedFile, Generator, GeneratorFactory } from "../generator.js"; +import { canonicalSerializeEffective } from "@metaobjectsdev/metadata"; +import { + buildExplorer, + generateExplorerSite, + generateSite, + readPromptTexts, + type ExplorerRoute, +} from "@metaobjectsdev/docs-site"; +import type { EmittedFile, GenContext, Generator, GeneratorFactory } from "../generator.js"; import { DOCS_TARGET_NAME } from "../docs-run.js"; +import { buildApiModelForRun } from "./api-model.js"; /** The site lands here, under the target. */ const DEFAULT_SITE_DIR = "site"; /** Where `meta eject docs-site` writes the site's templates and assets, under the project. */ export const OWNED_DOCS_SITE_DIR = "codegen/docs-site"; +/** The two sites `docsSiteFile()` builds. */ +export const DOCS_SITE_EXPLORER = "explorer"; +export const DOCS_SITE_CLASSIC = "classic"; +export type DocsSiteKind = typeof DOCS_SITE_EXPLORER | typeof DOCS_SITE_CLASSIC; + +/** The file every explorer theme template set carries, so an owned set can be told apart from + * an owned classic one. */ +const EXPLORER_MARKER_TEMPLATE = "explorer-head.mustache"; + export interface DocsSiteFileOpts { + /** Which site: `explorer` (default), the model explorer, or `classic`, the per-package site + * with ER diagrams that shipped before it. */ + site?: DocsSiteKind; /** A directory holding prompt `.mustache` sources outside `metaobjects/` and `templates/`, * relative to the project, so the site can show prompt TEXT. */ prompts?: string; @@ -36,10 +65,11 @@ export interface DocsSiteFileOpts { * depend on the model alone and `meta verify --codegen` can hold them to it. A date here * makes every day's run differ from the last. */ stamp?: string; - /** Where the pages load their third-party CSS and JS from (daisyUI, Tailwind's browser build, - * Mermaid, svg-pan-zoom). Default `https://cdn.jsdelivr.net/npm`, which needs network access - * to render; set it to your own mirror, or to a path you serve the five files from. Give an - * absolute URL or a root-relative path. See `@metaobjectsdev/docs-site` for the files. */ + /** Where the pages load their third-party CSS and JS from: the explorer loads Cytoscape for + * its graphs, the classic site daisyUI, Tailwind's browser build, Mermaid and svg-pan-zoom. + * Default `https://cdn.jsdelivr.net/npm`, which needs network access to draw them; set it to + * your own mirror, or to a path you serve the files from. Give an absolute URL or a + * root-relative path. See `@metaobjectsdev/docs-site` for the files. */ assetBase?: string; /** Named output target. Default `"docs"`, which `targets` must declare. */ target?: string; @@ -48,11 +78,15 @@ export interface DocsSiteFileOpts { export const docsSiteFile = function docsSiteFile(opts?: DocsSiteFileOpts): Generator { const dir = (opts?.subDir ?? DEFAULT_SITE_DIR).replace(/\/$/, ""); const prefix = dir === "" ? "" : `${dir}/`; + const kind = opts?.site ?? DOCS_SITE_EXPLORER; return { name: "docs-site", target: opts?.target ?? DOCS_TARGET_NAME, + // The explorer lists the files the run generated, so it runs after the rest. + ...(kind === DOCS_SITE_EXPLORER && { readsRunOutput: true }), async generate(ctx) { const project = ctx.inputRoot ?? ctx.projectRoot ?? process.cwd(); + if (kind === DOCS_SITE_EXPLORER) return explorerSite(ctx, project, prefix, opts); if (ctx.sourceRoots === undefined || ctx.sourceRoots.length === 0) { throw new Error( "docs-site: the run named no metadata source directories (ctx.sourceRoots), which the " + @@ -60,54 +94,124 @@ export const docsSiteFile = function docsSiteFile(opts?: DocsSiteFileOpts): Gene "`sourceRoots`.", ); } - // Prompt `.mustache` sources are searched beside the model: the project's `templates/` - // and the `prompts` directory when given. Deduplicated by path: one directory named twice - // would be loaded twice. - const sourceDirs = [...ctx.sourceRoots]; - const seen = new Set(sourceDirs.map((d) => resolve(d))); - const prompts = opts?.prompts === undefined ? undefined : resolve(project, opts.prompts); - if (prompts !== undefined && !existsSync(prompts)) { - ctx.warn(`docs-site: the prompts directory does not exist: ${prompts}`); - } - for (const d of [join(project, "templates"), ...(prompts !== undefined ? [prompts] : [])]) { - const abs = resolve(d); - if (existsSync(abs) && !seen.has(abs)) { - sourceDirs.push(abs); - seen.add(abs); - } - } - const ownedTemplates = join(project, OWNED_DOCS_SITE_DIR, "templates"); - const ownedAssets = join(project, OWNED_DOCS_SITE_DIR, "assets"); - // The builder writes to a directory (its link check reads the pages back), so the site - // is built in a scratch directory and returned as files for the runner to write. - const scratch = mkdtempSync(join(tmpdir(), "meta-docs-site-")); - try { + const sourceDirs = promptSourceDirs(ctx, project, opts, ctx.sourceRoots); + const owned = ownedSiteDirs(project); + return buildInScratch(prefix, async (outDir) => { const result = await generateSite({ sourceDirs, - outDir: scratch, + outDir, title: opts?.title ?? (basename(project) || "Metadata"), stamp: opts?.stamp ?? "", commit: "", core: { n: 15 }, ...(opts?.assetBase !== undefined ? { assetBase: opts.assetBase } : {}), ...(ctx.providers !== undefined ? { extraProviders: ctx.providers } : {}), - ...(existsSync(ownedTemplates) ? { templatesDir: ownedTemplates } : {}), - ...(existsSync(ownedAssets) ? { assetsDir: ownedAssets } : {}), + ...owned, }); - const written = [ - ...result.pages, - "assets/site.css", - "assets/site.js", - "assets/search-index.json", - ]; - const files: EmittedFile[] = written.map((path) => ({ - path: `${prefix}${path}`, - content: readFileSync(join(scratch, path), "utf8"), - })); - return files; - } finally { - rmSync(scratch, { recursive: true, force: true }); - } + return [...result.pages, "assets/site.css", "assets/site.js", "assets/search-index.json"]; + }); }, }; } as GeneratorFactory; + +/** Prompt `.mustache` sources are searched beside the model: the source roots, the project's + * `templates/` and the `prompts` directory when given. Deduplicated by path: one directory + * named twice would be loaded twice. */ +function promptSourceDirs(ctx: GenContext, project: string, opts: DocsSiteFileOpts | undefined, roots: readonly string[]): string[] { + const dirs = [...roots]; + const seen = new Set(dirs.map((d) => resolve(d))); + const prompts = opts?.prompts === undefined ? undefined : resolve(project, opts.prompts); + if (prompts !== undefined && !existsSync(prompts)) { + ctx.warn(`docs-site: the prompts directory does not exist: ${prompts}`); + } + for (const d of [join(project, "templates"), ...(prompts !== undefined ? [prompts] : [])]) { + const abs = resolve(d); + if (existsSync(abs) && !seen.has(abs)) { + dirs.push(abs); + seen.add(abs); + } + } + return dirs; +} + +/** The owned site directories `meta eject docs-site` wrote, only those that exist. */ +function ownedSiteDirs(project: string): { templatesDir?: string; assetsDir?: string } { + const templatesDir = join(project, OWNED_DOCS_SITE_DIR, "templates"); + const assetsDir = join(project, OWNED_DOCS_SITE_DIR, "assets"); + return { + ...(existsSync(templatesDir) && { templatesDir }), + ...(existsSync(assetsDir) && { assetsDir }), + }; +} + +/** The builder writes to a directory (its link check reads the pages back), so a site is built + * in a scratch directory and returned as files for the runner to write. `build` returns the + * page paths it wrote, relative to that directory. */ +async function buildInScratch(prefix: string, build: (outDir: string) => string[] | Promise): Promise { + const scratch = mkdtempSync(join(tmpdir(), "meta-docs-site-")); + try { + const paths = await build(scratch); + return paths.map((path) => ({ path: `${prefix}${path}`, content: readFileSync(join(scratch, path), "utf8") })); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +/** The routes the run mounts, as the api reference states them (`METHOD /path` per symbol). */ +function runRoutes(ctx: GenContext): ExplorerRoute[] { + const routes: ExplorerRoute[] = []; + for (const unit of buildApiModelForRun(ctx).units) { + const node = unit.package !== undefined && unit.package !== "" ? `${unit.package}::${unit.node}` : unit.node; + for (const sym of unit.symbols) { + if (sym.kind !== "rest") continue; + const space = sym.name.indexOf(" "); + if (space < 0) continue; + routes.push({ node, method: sym.name.slice(0, space), path: sym.name.slice(space + 1) }); + } + } + return routes; +} + +async function explorerSite(ctx: GenContext, project: string, prefix: string, opts: DocsSiteFileOpts | undefined): Promise { + // The manifest's shape (path → generatedBy, sourceNode), from the files this run wrote: a + // fresh clone builds the same site as the machine that last generated. + const manifest: Record = {}; + for (const f of ctx.runOutput?.() ?? []) { + manifest[f.path] = { generatedBy: f.generatedBy, ...(f.sourceNode !== undefined && { sourceNode: f.sourceNode }) }; + } + // The ledger is the requirement gate's; a gate that throws leaves the section out (as a + // missing verify input does) rather than failing the whole run in the docs step. + let ledger: unknown; + try { + ledger = ctx.requirementLedger?.(); + } catch (err) { + ctx.warn(`docs-site: the requirement ledger could not be built, so the site shows none: ${err instanceof Error ? err.message : String(err)}`); + } + // No route at all means this run recorded none (no routes generator, or REST off), which the + // site says as such; an empty list would claim the run mounted nothing. + const routes = runRoutes(ctx); + const explorer = buildExplorer({ + model: JSON.parse(canonicalSerializeEffective(ctx.loadedRoot)) as unknown, + ...(ledger !== undefined && { verify: ledger }), + manifest, + ...(routes.length > 0 && { routes }), + }); + const texts = readPromptTexts(explorer, promptSourceDirs(ctx, project, opts, ctx.sourceRoots ?? [])); + const owned = ownedSiteDirs(project); + if (owned.templatesDir !== undefined && !existsSync(join(owned.templatesDir, EXPLORER_MARKER_TEMPLATE))) { + ctx.warn( + `docs-site: ${OWNED_DOCS_SITE_DIR}/templates holds the classic site's theme, which the explorer ` + + "does not read; it uses its bundled templates. Re-run `meta eject docs-site` to own the " + + "explorer's theme, or pass `site: \"classic\"`.", + ); + } + return buildInScratch(prefix, (outDir) => generateExplorerSite({ + explorer, + outDir, + title: opts?.title ?? (basename(project) || "Metadata"), + stamp: opts?.stamp ?? "", + texts, + ...(opts?.assetBase !== undefined ? { assetBase: opts.assetBase } : {}), + ...owned, + }).files); +} diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index 57464372d..27ad6f8d5 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -6,7 +6,7 @@ export { runGen, declaredGeneratorNames, stableGeneratorName, undeclaredGeneratorsMessage } from "./runner.js"; export type { RunGenOpts, RunGenResult } from "./runner.js"; -export type { Generator, GenContext, EmittedFile, GeneratorFactory } from "./generator.js"; +export type { Generator, GenContext, EmittedFile, GeneratorFactory, RunOutputFile } from "./generator.js"; export { perEntity, perPackage, perModel, oncePerRun } from "./generator.js"; // The one aggregation of the UI-tier markers, Hono routes and the routes' `expose` option: // the run's own answers to "does this run emit a UI tier?" and "which verbs are mounted?". diff --git a/server/typescript/packages/codegen-ts/src/overwrite-policy.ts b/server/typescript/packages/codegen-ts/src/overwrite-policy.ts index 70311f155..74d9d5dd0 100644 --- a/server/typescript/packages/codegen-ts/src/overwrite-policy.ts +++ b/server/typescript/packages/codegen-ts/src/overwrite-policy.ts @@ -260,6 +260,26 @@ export function recordGeneratedProvenance( if (changed) writeRawManifest(genStateDir, raw); } +/** + * Every recorded path that carries provenance, as `recordGeneratedProvenance` wrote it: the + * generator, and the source node when there is one. A plain `""` entry carries no + * generator and is left out. Sorted by path. + */ +export function listGeneratedProvenance( + genStateDir: string, +): { path: string; generatedBy: string; sourceNode?: string }[] { + const out: { path: string; generatedBy: string; sourceNode?: string }[] = []; + for (const [path, entry] of Object.entries(readRawManifest(genStateDir))) { + if (!isRecord(entry) || typeof entry.generatedBy !== "string") continue; + out.push({ + path, + generatedBy: entry.generatedBy, + ...(typeof entry.sourceNode === "string" && { sourceNode: entry.sourceNode }), + }); + } + return out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +} + // --------------------------------------------------------------------------- // FR-038 §8 — readers for orphan reconciliation. // diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts index f8bc70baa..19dae5524 100644 --- a/server/typescript/packages/codegen-ts/src/runner.ts +++ b/server/typescript/packages/codegen-ts/src/runner.ts @@ -1,4 +1,4 @@ -import { join, relative, resolve, isAbsolute, dirname } from "node:path"; +import { join, relative, resolve, isAbsolute, dirname, sep } from "node:path"; import { warnMissingPromptGenerators } from "./prompt-generator-gate.js"; import { runEmitsEntityModule, runEmitsHonoRoutes, runEmitsQueries, runEmitsRestRoutes, runEmitsUiTier, runRoutesExpose, warnUnmarkedRoutesGenerators, warnUnmarkedUiGenerators } from "./ui-tier-gate.js"; import { warnRetiredCodegenAttrs } from "./retired-codegen-attrs.js"; @@ -16,7 +16,7 @@ import { assertNoCollectionNameCollisions } from "./naming/collection-name-colli import { isAbstract } from "./instance-artifacts.js"; import { dbEmittingObjects, missingDialectMessage } from "./db-emitting.js"; import { generatableObjects, hasAnyRdbSource } from "./source-detect.js"; -import type { Generator, GenContext, EmittedFile } from "./generator.js"; +import type { Generator, GenContext, EmittedFile, RunOutputFile } from "./generator.js"; import type { MetaobjectsGenConfig } from "./metaobjects-config.js"; import { normalizeConfig, assertCodegenDialect, DEFAULT_TARGET_NAME } from "./metaobjects-config.js"; import type { ResolvedTarget } from "./import-path.js"; @@ -34,6 +34,7 @@ import { loadEngineVersion, saveEngineVersion, recordGeneratedProvenance, + listGeneratedProvenance, type GeneratedProvenance, type WriteResult, type MergeStrategy, @@ -190,6 +191,21 @@ export interface RunGenOpts { * `GenContext.libraries`. */ libraries?: readonly string[]; + /** + * The requirement ledger as `meta verify --format json` states it, threaded onto + * `GenContext.requirementLedger` for the docs site's model explorer. codegen-ts cannot + * compute it (the requirement gate is the CLI's), so the caller that can supplies it; the + * runner calls it at most once, and only when a generator asks. Absent: the explorer + * shows no ledger. + */ + requirementLedger?: () => unknown; + /** + * Directories inside `projectRoot` that stand in for output directories OUTSIDE the real + * project. `meta verify --codegen` regenerates into a temp tree and moves an out-of-tree + * target into a slot under it; a file written there is treated as outside the project, as + * `meta gen` treats the real one: written, never recorded, and absent from `ctx.runOutput`. + */ + untrackedOutDirs?: readonly string[]; } export interface RunGenResult { @@ -358,10 +374,12 @@ export async function runGen(opts: RunGenOpts): Promise { // a watch loop, a test) would otherwise find the first run's snapshot and three-way merge, // which is exactly the record-keeping an untracked file does not have. const untrackedGenStateDir = join(tmpdir(), `meta-gen-untracked-${process.pid}-${randomUUID()}`); + const untrackedOutDirs = opts.untrackedOutDirs ?? []; const outsideProject = (fullPath: string): boolean => { if (projectRoot === undefined) return false; const rel = relative(projectRoot, fullPath); - return rel.startsWith("..") || isAbsolute(rel); + if (rel.startsWith("..") || isAbsolute(rel)) return true; + return untrackedOutDirs.some((dir) => isNestedIn(fullPath, dir)); }; let untrackedCount = 0; const policyFor = (fullPath: string, handEdits?: HandEdits): DecideAndWriteOpts => { @@ -791,6 +809,10 @@ export async function runGen(opts: RunGenOpts): Promise { if (unknown.length > 0) throw new Error(undeclaredGeneratorsMessage(unknown, declared)); selected = config.generators.filter((_, i) => wanted.has(declared[i]!)); } + // A generator that reads the run's output runs after every one that writes it, each group in + // declared order, so the list it reads is complete. + selected = [...selected.filter((g) => g.readsRunOutput !== true), ...selected.filter((g) => g.readsRunOutput === true)]; + const requirementLedger = opts.requirementLedger !== undefined ? once(opts.requirementLedger) : undefined; // 4. Run each generator with a per-target render context; collect with full path. const emitted: { @@ -820,6 +842,43 @@ export async function runGen(opts: RunGenOpts): Promise { // Generators that ran and produced no file. See the note at the `files.length === 0` // check below. A Set because a generator may run once per target. const emptyGenerators = new Set(); + // `ctx.runOutput`: what this run has generated so far, keyed as the manifest keys it. A run + // narrowed with `--generators` or to some entities adds what the committed manifest records + // for the rest — the declared generators it did not run, and the objects it did not render — + // so a narrowed run reads what a full run reads. A record for a generator no longer declared, + // or for a file no longer on disk, is stale and stays out. + const entityNarrowed = opts.entityFilter !== undefined && opts.entityFilter.length > 0; + const runOutputFiles = (): RunOutputFile[] => { + const out = new Map(); + const keyOf = (fullPath: string): string | undefined => { + if (projectRoot === undefined) return fullPath.split(sep).join("/"); + if (outsideProject(fullPath)) return undefined; + return relative(projectRoot, fullPath).split(sep).join("/"); + }; + if (opts.generatorNames !== undefined || entityNarrowed) { + const ranNames = new Set(selected.map((g) => g.name)); + const declaredNames = new Set(config.generators.map((g) => g.name)); + const renderedNodes = new Set(safeEntities.map((e) => e.resolutionKey())); + for (const rec of listGeneratedProvenance(genStateDir)) { + if (!declaredNames.has(rec.generatedBy)) continue; + // A generator that ran states its own output, except for the objects an entity + // filter kept it from. + if (ranNames.has(rec.generatedBy) && (!entityNarrowed || rec.sourceNode === undefined || renderedNodes.has(rec.sourceNode))) continue; + if (projectRoot !== undefined && !existsSync(join(projectRoot, rec.path))) continue; + out.set(rec.path, rec); + } + } + for (const file of emitted) { + const path = keyOf(file.fullPath); + if (path === undefined) continue; + out.set(path, { + path, + generatedBy: file.generatedBy, + ...(file.sourceNode !== undefined && { sourceNode: file.sourceNode }), + }); + } + return [...out.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + }; for (const generator of selected) { const selfTarget = targetOf(generator); @@ -909,6 +968,8 @@ export async function runGen(opts: RunGenOpts): Promise { ...(config.providers !== undefined && { providers: config.providers }), docs: docsFactsFor(selfTarget.name), ...(selectedManifests !== undefined && { libraries: selectedManifests }), + ...(generator.readsRunOutput === true && { runOutput: runOutputFiles }), + ...(requirementLedger !== undefined && { requirementLedger }), warn: (msg) => warnings.push(`[${generator.name}] ${msg}`), }; @@ -1349,3 +1410,16 @@ export function undeclaredGeneratorsMessage(unknown: readonly string[], declared "." ); } + +/** Memoize a zero-argument callback: called at most once, and only when first asked. */ +function once(fn: () => T): () => T { + let done = false; + let value: T | undefined; + return () => { + if (!done) { + value = fn(); + done = true; + } + return value as T; + }; +} diff --git a/server/typescript/packages/codegen-ts/test/docs-site-explorer.test.ts b/server/typescript/packages/codegen-ts/test/docs-site-explorer.test.ts new file mode 100644 index 000000000..b343a604d --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/docs-site-explorer.test.ts @@ -0,0 +1,105 @@ +// docsSiteFile() builds the model explorer by default: explorer.json from the effective model, +// the requirement ledger the caller hands the run, the files the run wrote and the routes it +// mounts. It runs after the generators declared beside it, whatever their order. +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { MetaDataLoader, type MetaRoot } from "@metaobjectsdev/metadata"; +import { FileSource } from "@metaobjectsdev/metadata/core"; +import { runGen } from "../src/runner.js"; +import { defineConfig } from "../src/metaobjects-config.js"; +import type { Generator } from "../src/generator.js"; +import { docsSiteFile, entityFile, routesFile } from "../src/generators/index.js"; + +const FIXTURE = resolve(import.meta.dir, "fixtures", "single-entity.json"); + +interface ExplorerShape { + generated?: { node: string; files: { path: string; generatedBy: string }[] }[]; + api?: { node: string; method: string; path: string }[]; + ledger?: unknown; +} + +let tmp: string; +let root: MetaRoot; +// The run's one metadata source directory (the classic site loads whole directories). +let sources: string; +beforeEach(async () => { + tmp = mkdtempSync(join(tmpdir(), "codegen-docs-site-")); + sources = join(tmp, "metaobjects"); + mkdirSync(sources); + copyFileSync(FIXTURE, join(sources, "meta.blog.json")); + root = (await new MetaDataLoader().load([new FileSource(FIXTURE)])).root; +}); +afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + +const config = (generators: Generator[]) => + defineConfig({ + outDir: join(tmp, "src"), + extStyle: "none", + dbImport: "../db", + dialect: "sqlite", + apiPrefix: "/api", + targets: { docs: { outDir: join(tmp, "docs") } }, + generators, + }); + +const explorerOf = (): ExplorerShape => + JSON.parse(readFileSync(join(tmp, "docs", "site", "explorer.json"), "utf8")) as ExplorerShape; + +describe("docsSiteFile() builds the model explorer", () => { + test("declared first, it still sees what the code generators wrote, and the routes they mount", async () => { + await runGen({ + config: config([docsSiteFile(), entityFile(), routesFile()]), + metadata: root, + projectRoot: tmp, + sourceRoots: [sources], + }); + expect(existsSync(join(tmp, "docs", "site", "index.html"))).toBe(true); + const ex = explorerOf(); + const post = ex.generated?.find((g) => g.node.endsWith("Post")); + expect(post?.files.map((f) => f.generatedBy)).toEqual(expect.arrayContaining(["entity-file", "routes-file"])); + expect(ex.api?.some((r) => r.node.endsWith("Post") && r.method === "GET" && r.path.startsWith("/api/"))).toBe(true); + expect(readFileSync(join(tmp, "docs", "site", "api.html"), "utf8")).toContain("/api/"); + }); + + test("without a routes generator in the run, the site says no routes were recorded, not that none are mounted", async () => { + await runGen({ config: config([entityFile(), docsSiteFile()]), metadata: root, projectRoot: tmp, sourceRoots: [sources] }); + expect(explorerOf().api).toBeUndefined(); + expect(readFileSync(join(tmp, "docs", "site", "api.html"), "utf8")).toContain("recorded no routes"); + }); + + test("a requirement ledger that throws leaves the ledger out with a warning, and the site still builds", async () => { + const result = await runGen({ + config: config([docsSiteFile()]), + metadata: root, + projectRoot: tmp, + sourceRoots: [sources], + requirementLedger: () => { throw new Error("gate exploded"); }, + }); + expect(explorerOf().ledger).toBeUndefined(); + expect(result.warnings.some((w) => w.includes("gate exploded"))).toBe(true); + }); + + test("the requirement ledger comes from the caller, asked for once", async () => { + let calls = 0; + await runGen({ + config: config([docsSiteFile()]), + metadata: root, + projectRoot: tmp, + sourceRoots: [sources], + requirementLedger: () => { + calls += 1; + return { requirementLedger: [], requirementCounts: { total: 0 }, requirements: { rows: [] } }; + }, + }); + expect(calls).toBe(1); + expect(explorerOf().ledger).toBeDefined(); + }); + + test("site: \"classic\" builds the earlier site and no explorer.json", async () => { + await runGen({ config: config([docsSiteFile({ site: "classic" })]), metadata: root, projectRoot: tmp, sourceRoots: [sources] }); + expect(existsSync(join(tmp, "docs", "site", "assets", "site.css"))).toBe(true); + expect(existsSync(join(tmp, "docs", "site", "explorer.json"))).toBe(false); + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/run-output.test.ts b/server/typescript/packages/codegen-ts/test/run-output.test.ts new file mode 100644 index 000000000..b76b1b610 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/run-output.test.ts @@ -0,0 +1,111 @@ +// `readsRunOutput`: a generator that lists what the rest of the run generated (the docs site's +// model explorer) runs after every other generator and reads the files through `ctx.runOutput`, +// keyed as the manifest keys them. `requirementLedger` is the caller's callback, called once. +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { MetaDataLoader } from "@metaobjectsdev/metadata"; +import { FileSource } from "@metaobjectsdev/metadata/core"; +import { runGen } from "../src/runner.js"; +import { defineConfig } from "../src/metaobjects-config.js"; +import { perEntity, type Generator, type RunOutputFile } from "../src/generator.js"; + +const FIXTURE = resolve(import.meta.dir, "fixtures", "single-entity.json"); +const TWO = resolve(import.meta.dir, "fixtures", "two-entities-fk.json"); + +let tmp: string; +beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), "codegen-run-output-")); }); +afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + +async function load(file = FIXTURE) { + return (await new MetaDataLoader().load([new FileSource(file)])).root; +} + +function reader(seen: { files?: readonly RunOutputFile[] | undefined; ledger?: unknown[] }): Generator { + return { + name: "reader", + readsRunOutput: true, + generate(ctx) { + seen.files = ctx.runOutput?.(); + seen.ledger = [ctx.requirementLedger?.(), ctx.requirementLedger?.()]; + return [{ path: "list.txt", content: (seen.files ?? []).map((f) => f.path).join("\n") }]; + }, + }; +} + +const alpha: Generator = { name: "alpha", generate: perEntity((e) => ({ path: `${e.name}.alpha.ts`, content: "// a\n" })) }; + +describe("readsRunOutput", () => { + test("runs after a generator declared later and sees its files, project-relative", async () => { + const seen: { files?: readonly RunOutputFile[] | undefined } = {}; + let calls = 0; + await runGen({ + config: defineConfig({ outDir: join(tmp, "gen"), extStyle: "none", dbImport: "../db", dialect: "sqlite", generators: [reader(seen), alpha] }), + metadata: await load(), + projectRoot: tmp, + requirementLedger: () => { calls++; return { requirementLedger: [] }; }, + }); + expect(seen.files).toEqual([{ path: "gen/Post.alpha.ts", generatedBy: "alpha", sourceNode: "demo::Post" }]); + expect(calls).toBe(1); + }); + + test("an unmarked generator gets no runOutput", async () => { + let had: unknown = "unset"; + const plain: Generator = { name: "plain", generate(ctx) { had = ctx.runOutput; return []; } }; + await runGen({ + config: defineConfig({ outDir: join(tmp, "gen"), extStyle: "none", dbImport: "../db", dialect: "sqlite", generators: [plain] }), + metadata: await load(), + projectRoot: tmp, + }); + expect(had).toBeUndefined(); + }); + + test("a narrowed run reads the manifest for the generators it did not run", async () => { + const config = defineConfig({ outDir: join(tmp, "gen"), extStyle: "none", dbImport: "../db", dialect: "sqlite", generators: [alpha, reader({})] }); + await runGen({ config, metadata: await load(), projectRoot: tmp }); + const seen: { files?: readonly RunOutputFile[] | undefined } = {}; + const narrowed = defineConfig({ outDir: join(tmp, "gen"), extStyle: "none", dbImport: "../db", dialect: "sqlite", generators: [alpha, reader(seen)] }); + await runGen({ config: narrowed, metadata: await load(), projectRoot: tmp, generatorNames: ["reader"] }); + expect(seen.files).toEqual([{ path: "gen/Post.alpha.ts", generatedBy: "alpha", sourceNode: "demo::Post" }]); + }); + + test("a run narrowed to some entities still reads the other objects' files from the manifest", async () => { + const config = (seen: { files?: readonly RunOutputFile[] | undefined }) => + defineConfig({ outDir: join(tmp, "gen"), extStyle: "none", dbImport: "../db", dialect: "sqlite", generators: [alpha, reader(seen)] }); + await runGen({ config: config({}), metadata: await load(TWO), projectRoot: tmp }); + const seen: { files?: readonly RunOutputFile[] | undefined } = {}; + await runGen({ config: config(seen), metadata: await load(TWO), projectRoot: tmp, entityFilter: ["Post"] }); + expect(seen.files?.map((f) => f.path)).toEqual(["gen/Post.alpha.ts", "gen/User.alpha.ts"]); + }); + + test("a narrowed run leaves out records for a generator no longer declared or a file no longer on disk", async () => { + const beta: Generator = { name: "beta", generate: perEntity((e) => ({ path: `${e.name}.beta.ts`, content: "// b\n" })) }; + const base = { outDir: join(tmp, "gen"), extStyle: "none" as const, dbImport: "../db", dialect: "sqlite" as const }; + await runGen({ config: defineConfig({ ...base, generators: [alpha, beta, reader({})] }), metadata: await load(TWO), projectRoot: tmp }); + unlinkSync(join(tmp, "gen", "User.alpha.ts")); + const seen: { files?: readonly RunOutputFile[] | undefined } = {}; + await runGen({ config: defineConfig({ ...base, generators: [alpha, reader(seen)] }), metadata: await load(TWO), projectRoot: tmp, generatorNames: ["reader"] }); + expect(seen.files?.map((f) => f.path)).toEqual(["gen/Post.alpha.ts"]); + }); + + test("a stand-in for an out-of-tree outDir is left out, as the out-of-tree outDir itself is", async () => { + const webAlpha: Generator = { ...alpha, target: "web" }; + const config = (web: string, seen: { files?: readonly RunOutputFile[] | undefined }) => + defineConfig({ outDir: join(tmp, "project", "gen"), targets: { web: { outDir: web } }, extStyle: "none", dbImport: "../db", dialect: "sqlite", generators: [webAlpha, reader(seen)] }); + // `meta gen`: the web target sits outside the project. + const gen: { files?: readonly RunOutputFile[] | undefined } = {}; + await runGen({ config: config(join(tmp, "web"), gen), metadata: await load(), projectRoot: join(tmp, "project") }); + // `meta verify --codegen`: the same target, moved to a slot inside the regenerated tree. + const verify: { files?: readonly RunOutputFile[] | undefined } = {}; + const slot = join(tmp, "verify", "_web"); + await runGen({ + config: { ...config(slot, verify), outDir: join(tmp, "verify", "gen") }, + metadata: await load(), + projectRoot: join(tmp, "verify"), + untrackedOutDirs: [slot], + }); + expect(gen.files).toEqual([]); + expect(verify.files).toEqual(gen.files); + }); +}); diff --git a/server/typescript/packages/docs-site/README.md b/server/typescript/packages/docs-site/README.md index af95f2c5a..c3938a9f7 100644 --- a/server/typescript/packages/docs-site/README.md +++ b/server/typescript/packages/docs-site/README.md @@ -1,8 +1,14 @@ # @metaobjectsdev/docs-site -HTML documentation-site generator for metaobjects models — a browsable multi-page -site (package nav, Cmd+K search, per-object / package / prompt / output pages, and -kind-aware ER diagrams that encode object kind by shape and domain by color). +HTML documentation-site generator for metaobjects models. It builds two sites: + +- **The model explorer** (the default): what the software does, the model map with slices + and colour-by, a page per object, prompt and requirement, the traceability matrix, the + trace, the API index and the gaps, all drawn from one `explorer.json`. See + [`docs/features/explorer-site.md`](../../../../docs/features/explorer-site.md). +- **The classic site** (`site: "classic"`): package nav, Cmd+K search, per-object / package / + prompt / output pages, and kind-aware ER diagrams that encode object kind by shape and + domain by color. It is the engine behind the `docs-site` generator, `docsSiteFile()` from `@metaobjectsdev/codegen-ts/generators` (it was `meta docs --site` until 1.1.0). Output @@ -23,14 +29,20 @@ export default defineConfig({ ``` `meta gen` writes the site to `site/` under the target (`./docs/generated/site/`), beside -whatever other documentation the config lists. Options: `subDir` (default `site`), +whatever other documentation the config lists. Options: `site` (`"explorer"`, the +default, or `"classic"`), `subDir` (default `site`), `prompts` (an extra directory of prompt `.mustache` sources), `title`, `stamp` (footer text; default none, so the pages depend on the model alone and `meta verify --codegen` can check them), `assetBase` (see below) and `target`. ## Offline use: `assetBase` -The page chrome loads five third-party files from jsDelivr (`https://cdn.jsdelivr.net/npm`): +The explorer loads one third-party file, Cytoscape.js, from +`${assetBase}/cytoscape@3.30.2/dist/cytoscape.min.js`, and only on the pages that draw a graph +(the map, the object mini-maps, the trace). Without it every page still renders and the graphs +show a notice. The rest of this section is about the classic site. + +The classic page chrome loads five third-party files from jsDelivr (`https://cdn.jsdelivr.net/npm`): daisyUI's two stylesheets, Tailwind's browser build, Mermaid and svg-pan-zoom. A site opened without network access therefore renders unstyled, and the Mermaid diagrams and pan/zoom do not work. `assetBase` replaces that base in all five tags; the default is unchanged. @@ -60,6 +72,17 @@ packaged copy is what the option fills. ## Usage (as a library) +The explorer: + +```ts +import { buildExplorer, generateExplorerSite, readPromptTexts } from "@metaobjectsdev/docs-site"; + +const explorer = buildExplorer({ model, verify, manifest, routes }); +generateExplorerSite({ explorer, outDir: "docs/site", title: "My Model", texts: readPromptTexts(explorer, ["prompts"]) }); +``` + +The classic site: + ```ts import { generateSite } from "@metaobjectsdev/docs-site"; @@ -72,7 +95,7 @@ await generateSite({ }); ``` -`generateSite` throws if the generated site contains any dangling link. +Both throw if the generated site contains any dangling link. ## Own your docs-site theme @@ -83,8 +106,8 @@ repo: meta eject docs-site ``` -This writes the 9 mustache templates to `codegen/docs-site/templates/` and the CSS/JS -to `codegen/docs-site/assets/` — only files that don't already exist, so your edits are +This writes both sites' mustache templates to `codegen/docs-site/templates/` (the +explorer's are `explorer-*.mustache`) and their CSS/JS to `codegen/docs-site/assets/` — only files that don't already exist, so your edits are never clobbered (`--force` overwrites). Edit them, then regenerate: ``` @@ -95,7 +118,7 @@ meta gen file you didn't override falls back to the bundled default. The engine (`@metaobjectsdev/docs-site`) stays a versioned dependency — you own only the theme. -When calling `generateSite` directly, pass `templatesDir` / `assetsDir` to point at your +When calling `generateSite` or `generateExplorerSite` directly, pass `templatesDir` / `assetsDir` to point at your owned copies (a file of the same basename there wins over the bundled one). ## License diff --git a/server/typescript/packages/docs-site/assets/explorer.css b/server/typescript/packages/docs-site/assets/explorer.css new file mode 100644 index 000000000..aae678b02 --- /dev/null +++ b/server/typescript/packages/docs-site/assets/explorer.css @@ -0,0 +1,232 @@ +/* The model explorer's theme. `meta eject docs-site` copies this file to + codegen/docs-site/assets/, where an edit wins over the bundled copy. + + Colour rules (design report §4.2), which the classes below keep apart: + .s- a DECLARED status: filled chip, with its glyph in the text + .v- a GATE verdict: an outline (ring), never a fill + .bar .b-* a ROLL-UP of what sits below: a separate stacked bar + No class here colours a test result. */ + +:root { + --bg: #f7f7f5; --panel: #ffffff; --ink: #1d1f23; --muted: #5f6670; --line: #e2e3e0; + --accent: #3b5bdb; --accent-soft: #e8ecfd; --accent-ink: #ffffff; + --live: #2f9e44; --live-soft: #e3f5e7; --partial: #e8890c; --partial-soft: #fdf0dc; + --planned: #4c6ef5; --planned-soft: #e9edff; --retired: #868e96; --retired-soft: #eef0f2; + --bad: #e03131; --bad-soft: #fde8e8; --warn: #e8890c; --code: #f1f2ef; + --entity: #495057; --value: #7048e8; --prompt: #0c8599; --projection: #1971c2; --report: #c2255c; + --shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 1px 3px rgba(16, 24, 40, .06); + --shadow-lift: 0 4px 14px rgba(16, 24, 40, .10); + color-scheme: light; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #15171a; --panel: #1d2024; --ink: #e7e9ec; --muted: #9aa2ab; --line: #2e3238; + --accent: #8da2fb; --accent-soft: #252c47; --accent-ink: #15171a; + --live: #51cf66; --live-soft: #1e3324; --partial: #ffa94d; --partial-soft: #3a2c17; + --planned: #91a7ff; --planned-soft: #232a45; --retired: #adb5bd; --retired-soft: #2a2d31; + --bad: #ff6b6b; --bad-soft: #3a1f1f; --warn: #ffa94d; --code: #24272c; + --entity: #ced4da; --value: #b197fc; --prompt: #3bc9db; --projection: #74c0fc; --report: #f783ac; + --shadow: 0 1px 2px rgba(0, 0, 0, .3); --shadow-lift: 0 4px 14px rgba(0, 0, 0, .45); + color-scheme: dark; + } +} +:root[data-theme="dark"] { + --bg: #15171a; --panel: #1d2024; --ink: #e7e9ec; --muted: #9aa2ab; --line: #2e3238; + --accent: #8da2fb; --accent-soft: #252c47; --accent-ink: #15171a; + --live: #51cf66; --live-soft: #1e3324; --partial: #ffa94d; --partial-soft: #3a2c17; + --planned: #91a7ff; --planned-soft: #232a45; --retired: #adb5bd; --retired-soft: #2a2d31; + --bad: #ff6b6b; --bad-soft: #3a1f1f; --warn: #ffa94d; --code: #24272c; + --entity: #ced4da; --value: #b197fc; --prompt: #3bc9db; --projection: #74c0fc; --report: #f783ac; + --shadow: 0 1px 2px rgba(0, 0, 0, .3); --shadow-lift: 0 4px 14px rgba(0, 0, 0, .45); + color-scheme: dark; +} + +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; } +code { background: var(--code); border-radius: 4px; padding: 0 4px; } +code.digest { display: block; overflow-wrap: anywhere; font-size: 11.5px; padding: 4px 6px; } +pre code, .mono code { background: none; padding: 0; } + +/* ── Header ─────────────────────────────────────────────────────────────── */ +header.top { position: sticky; top: 0; z-index: 10; background: var(--panel); border-bottom: 1px solid var(--line); display: flex; gap: 16px; align-items: center; padding: 10px 20px; flex-wrap: wrap; } +.brand { font-weight: 700; letter-spacing: .2px; color: var(--ink); white-space: nowrap; } +.brand:hover { text-decoration: none; } +.brand small { font-weight: 400; color: var(--muted); margin-left: 6px; } +.nav { display: flex; gap: 4px; flex-wrap: wrap; } +.nav a { padding: 5px 10px; border-radius: 6px; color: var(--ink); white-space: nowrap; } +.nav a:hover { background: var(--code); text-decoration: none; } +.nav a.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; } +#search { margin-left: auto; position: relative; } +#search input { width: 260px; max-width: 60vw; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); font: inherit; } +#search input:focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); } +#results { position: absolute; right: 0; top: 36px; width: 420px; max-width: 90vw; max-height: 60vh; overflow: auto; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow-lift); display: none; } +#results a { display: block; padding: 7px 12px; border-bottom: 1px solid var(--line); color: var(--ink); } +#results a:hover, #results a.sel { background: var(--accent-soft); text-decoration: none; } +#results a small { color: var(--muted); display: block; } +#results .none { padding: 8px 12px; color: var(--muted); } +#theme { padding: 5px 9px; } + +/* ── Page ───────────────────────────────────────────────────────────────── */ +main { padding: 24px 20px 20px; max-width: 1400px; margin: 0 auto; } +h1 { font-size: 22px; margin: 0 0 4px; line-height: 1.3; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +h2 { font-size: 16px; margin: 26px 0 10px; } +h3 { font-size: 14px; margin: 16px 0 6px; } +h3.first { margin-top: 0; } +.h-sub { color: var(--muted); font-weight: 400; } +.sub { color: var(--muted); } +.small { font-size: 12px; } +.crumbs { color: var(--muted); font-size: 12.5px; margin-bottom: 6px; } +.meta-line { margin-top: 4px; } +.page { margin-top: 14px; } +.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; box-shadow: var(--shadow); min-width: 0; } +.grid { display: grid; gap: 12px; } +.tiles { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); margin: 18px 0; } +a.tile { color: var(--ink); transition: box-shadow .15s, border-color .15s, transform .15s; } +a.tile:hover { text-decoration: none; border-color: var(--accent); box-shadow: var(--shadow-lift); transform: translateY(-1px); } +.tile .n { font-size: 26px; font-weight: 700; letter-spacing: -.3px; font-variant-numeric: tabular-nums; } +.tile .l { color: var(--muted); font-size: 12.5px; line-height: 1.4; } +.two { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +.side { grid-template-columns: minmax(0, 1fr) 340px; align-items: start; } +.apis { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); } +@media (max-width: 1000px) { .side { grid-template-columns: minmax(0, 1fr); } } +@media (max-width: 900px) { .two { grid-template-columns: minmax(0, 1fr); } } + +table { border-collapse: collapse; width: 100%; } +th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; } +th { font-size: 12px; color: var(--muted); font-weight: 600; } +tr:target td { background: var(--accent-soft); } +table.kv th { width: 120px; } +.tablewrap { overflow-x: auto; } + +/* ── Status, verdict and roll-up ────────────────────────────────────────── */ +.chip { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; font-size: 12px; border: 1px solid var(--line); white-space: nowrap; margin: 1px 2px 1px 0; font-weight: 500; line-height: 1.6; } +h1 .chip { font-size: 12.5px; } +.s-live { background: var(--live-soft); color: var(--live); border-color: transparent; } +.s-partial { background: var(--partial-soft); color: var(--partial); border-color: transparent; } +.s-planned { background: var(--planned-soft); color: var(--planned); border-color: transparent; } +.s-retired { background: var(--retired-soft); color: var(--retired); border-color: transparent; text-decoration: line-through; } +.v-error, .v-unclaimed { background: transparent; color: var(--bad); border: 1.5px solid var(--bad); } +.v-warn { background: transparent; color: var(--warn); border: 1.5px solid var(--warn); } +.glyph { font-size: 12px; } +.g-live { color: var(--live); } .g-partial { color: var(--partial); } .g-planned { color: var(--planned); } .g-retired { color: var(--retired); } +.lvl { display: inline-block; min-width: 24px; text-align: center; font-size: 11px; font-weight: 700; border-radius: 4px; background: var(--code); color: var(--muted); padding: 0 4px; margin-right: 6px; line-height: 18px; } +h1 .lvl { font-size: 12px; line-height: 22px; } +.bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--code); min-width: 80px; } +.bar span { display: block; height: 100%; } +.b-live { background: var(--live); } .b-partial { background: var(--partial); } .b-planned { background: var(--planned); } .b-retired { background: var(--retired); } +.minibar { display: inline-block; width: 90px; vertical-align: middle; margin: 0 4px; } +.legend { display: flex; gap: 8px; flex-wrap: wrap; font-size: 12px; color: var(--muted); align-items: center; margin: 4px 0; } +.legend-row { margin: 10px 0; } +.pill { font-size: 11px; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; margin-left: 4px; white-space: nowrap; } +.pill.gate { color: var(--accent); border-color: var(--accent); } +a.k-entity { color: var(--accent); font-weight: 500; } +span.k-entity { color: var(--entity); } .k-value { color: var(--value); } .k-prompt, .k-template { color: var(--prompt); } +.k-projection { color: var(--projection); } .k-report { color: var(--report); } +.kdot { font-size: 10px; } +.kdot.k-entity { color: var(--entity); } .kdot.k-value { color: var(--value); } .kdot.k-prompt { color: var(--prompt); } +.k-badge { color: var(--projection); } +.vals { color: var(--muted); font-size: 11.5px; } + +/* ── S1 cards ───────────────────────────────────────────────────────────── */ +.cap { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; } +.cap .card h3 { margin-top: 0; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +.capcard .statement { margin: 2px 0 0; } +.rollup { margin: 10px 0 4px; } +.rollup .sub { margin-top: 3px; } +ul { padding-left: 18px; margin: 6px 0; } +.cap li, .kids li { margin: 4px 0; } +.members { margin-top: 8px; line-height: 1.9; } +details.summary { margin-top: 6px; } +details > summary { cursor: pointer; color: var(--muted); font-size: 12px; } +.note { border-left: 3px solid var(--accent); background: var(--accent-soft); padding: 8px 12px; border-radius: 6px; font-size: 13px; margin: 8px 0; } +.note.intro { margin: 4px 0 8px; font-size: 13.5px; } + +/* ── S5 tree ────────────────────────────────────────────────────────────── */ +.tabs { display: flex; gap: 6px; margin: 12px 0; flex-wrap: wrap; } +.tabs a { padding: 5px 12px; border: 1px solid var(--line); border-radius: 6px; color: var(--ink); background: var(--panel); } +.tabs a.on { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } +.tree { margin-top: 10px; } +.tree ul { list-style: none; padding-left: 18px; border-left: 1px dashed var(--line); margin-left: 8px; } +.tree > ul { border-left: none; padding-left: 0; margin-left: 0; } +.tree li { margin: 4px 0; } +.tree .row { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; } +.tree .stmt { color: var(--muted); font-size: 12.5px; flex-basis: 100%; margin-left: 30px; } + +/* ── Graphs ─────────────────────────────────────────────────────────────── */ +.graph { width: 100%; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; } +#cy { height: 640px; } #cymini { height: 260px; } #cytrace { height: 680px; } +.panel h3 { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } +.maplegend { margin-top: 8px; } +.toolbar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin: 10px 0; } +select, button { padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); font: inherit; max-width: 100%; } +button { cursor: pointer; } +button:hover { border-color: var(--accent); } +.layout, .gen, .route { margin: 4px 0; } +.gen .pill { margin-left: 0; } + +/* ── S4 text ────────────────────────────────────────────────────────────── */ +pre { background: var(--code); padding: 10px 12px; border-radius: 8px; overflow: auto; font-size: 12px; white-space: pre-wrap; word-break: break-word; } +pre.text { max-height: 560px; } +.var { color: var(--prompt); } + +/* ── S6 proof ───────────────────────────────────────────────────────────── */ +.proof { font-size: 12.5px; } +.proof td:first-child { white-space: nowrap; font-weight: 500; } + +/* ── S7 matrix ──────────────────────────────────────────────────────────── */ +.mx { overflow: auto; max-height: 78vh; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); } +.mx table { width: auto; border-collapse: separate; border-spacing: 0; } +/* The header row is sticky as a whole: a sticky cell per column would paint over the slanted + label of the column before it. */ +.mx thead { position: sticky; top: 0; z-index: 2; background: var(--panel); } +.mx th.col { height: 150px; white-space: nowrap; vertical-align: bottom; padding: 0; } +.mx th.col.corner { position: sticky; left: 0; background: var(--panel); } +.mx th.col div { transform: rotate(-60deg); transform-origin: left bottom; width: 18px; margin-left: 12px; font-weight: 500; font-size: 11.5px; max-width: 18px; } +.mx th.col div a { display: inline-block; max-width: 150px; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; } +.mx th.col.unc div a { color: var(--bad); } +.mx td.c { width: 20px; min-width: 20px; height: 22px; padding: 0; text-align: center; font-size: 11px; border-right: 1px solid var(--line); } +.mx td.rh { position: sticky; left: 0; background: var(--panel); z-index: 1; border-right: 1px solid var(--line); white-space: nowrap; max-width: 360px; overflow: hidden; text-overflow: ellipsis; font-size: 12px; } +.mx tr.grp td { background: var(--code); font-weight: 600; } +.mx td.c.live { background: var(--live-soft); } .mx td.c.planned { background: var(--planned-soft); } +.mx td.c.partial { background: var(--partial-soft); } .mx td.c.retired { background: var(--retired-soft); } + +/* ── S9 gaps, API ───────────────────────────────────────────────────────── */ +.gap h3 { display: flex; gap: 8px; align-items: center; margin-top: 0; flex-wrap: wrap; } +.gap .why { margin-bottom: 8px; } +.count { font-weight: 700; font-size: 12px; border-radius: 999px; padding: 0 8px; background: var(--code); } +.method { display: inline-block; min-width: 52px; text-align: center; font: 600 11px ui-monospace, SFMono-Regular, Menlo, monospace; border-radius: 4px; padding: 1px 4px; background: var(--code); color: var(--muted); } +.m-GET, .m-get { background: var(--live-soft); color: var(--live); } +.m-POST, .m-post { background: var(--planned-soft); color: var(--planned); } +.m-PUT, .m-put, .m-PATCH, .m-patch { background: var(--partial-soft); color: var(--partial); } +.m-DELETE, .m-delete { background: var(--bad-soft); color: var(--bad); } + +footer { color: var(--muted); font-size: 12px; padding: 24px 20px; text-align: center; } + +/* ── Phone ──────────────────────────────────────────────────────────────── */ +@media (max-width: 700px) { + header.top { gap: 8px 12px; padding: 10px 16px; } + .nav { order: 3; flex-basis: 100%; flex-wrap: nowrap; overflow-x: auto; margin: 0 -16px; padding: 0 16px 2px; scrollbar-width: none; } + .nav::-webkit-scrollbar { display: none; } + #search { order: 4; margin-left: 0; flex-basis: 100%; } + #search input { width: 100%; max-width: none; } + #results { left: 0; right: 0; width: auto; max-width: none; } + #theme { margin-left: auto; } + main { padding: 16px; } + h1 { font-size: 20px; } + .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } + .tile .n { font-size: 22px; } + .cap { grid-template-columns: minmax(0, 1fr); } + .apis { grid-template-columns: minmax(0, 1fr); } + #cy { height: 440px; } #cytrace { height: 520px; } + .mx { max-height: none; } + /* A wide table scrolls inside its own box, never the page. */ + main table { display: block; max-width: 100%; overflow-x: auto; } + .mx table { display: table; max-width: none; } + .tree .stmt { margin-left: 0; } + .tree ul { padding-left: 12px; margin-left: 4px; } +} +@media print { header.top, #search, #theme, .toolbar { display: none; } .card { box-shadow: none; } } diff --git a/server/typescript/packages/docs-site/assets/explorer.js b/server/typescript/packages/docs-site/assets/explorer.js new file mode 100644 index 000000000..680dc59d2 --- /dev/null +++ b/server/typescript/packages/docs-site/assets/explorer.js @@ -0,0 +1,315 @@ +// The model explorer's scripts: search, the theme toggle, and the three graphs (the model map, +// an object page's one-hop mini-map, the trace). They read window.MO_EXPLORER, which +// assets/explorer-data.js sets from explorer.json; nothing here derives a model fact. +// `meta eject docs-site` copies this file to codegen/docs-site/assets/. +(function () { + "use strict"; + var D = window.MO_EXPLORER || { nodes: [], edges: [], slices: {}, features: [], reqs: [], generated: {}, search: [] }; + var ROOT = document.body.getAttribute("data-root") || ""; + var $ = function (s, r) { return (r || document).querySelector(s); }; + var esc = function (s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); }; + var css = function (v) { return getComputedStyle(document.documentElement).getPropertyValue(v).trim(); }; + var STATUS = ["live", "partial", "planned", "retired"]; + var GLYPH = { live: "●", partial: "◐", planned: "○", retired: "⊘" }; + var PAL = ["#1971c2", "#e8590c", "#2b8a3e", "#ae3ec9", "#c2255c", "#0b7285", "#5c940d", "#f08c00"]; + var byId = {}; + D.nodes.forEach(function (n) { byId[n.id] = n; }); + var reqById = {}; + D.reqs.forEach(function (r) { reqById[r.id] = r; }); + var href = function (rel) { return ROOT + rel; }; + var redraw = []; + + // ── Theme ─────────────────────────────────────────────────────────────── + var themeBtn = $("#theme"); + if (themeBtn) themeBtn.onclick = function () { + var cur = document.documentElement.getAttribute("data-theme"); + var dark = cur ? cur === "dark" : matchMedia("(prefers-color-scheme: dark)").matches; + var next = dark ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + try { localStorage.setItem("mo-theme", next); } catch (e) { /* private window: the toggle still works for this page */ } + redraw.forEach(function (f) { f(); }); + }; + + // ── Search ────────────────────────────────────────────────────────────── + // The built-in index ships in explorer-data.js and works from file://. When the site has been + // indexed with Pagefind (`npx pagefind --site `) and is served over http, Pagefind's + // full-text index answers instead. + var q = $("#q"), res = $("#results"), pagefind = null, pagefindTried = false, sel = -1; + function loadPagefind() { + if (pagefindTried || !/^https?:$/.test(location.protocol)) return Promise.resolve(null); + pagefindTried = true; + // Pagefind takes its base URL from where pagefind.js loads, so loading it from ROOT makes every + // result URL carry the path the site is served under (a sub-path included). + var url = new URL(ROOT + "pagefind/pagefind.js", location.href).href; + return fetch(url, { method: "HEAD" }).then(function (r) { + if (!r.ok) return null; + return import(url).then(function (m) { pagefind = m; return m; }); + }).catch(function () { return null; }); + } + function show(items) { + sel = -1; + res.innerHTML = items.length ? items.map(function (x) { return '' + esc(x.t) + "" + esc(x.s) + ""; }).join("") : '
No match
'; + res.style.display = "block"; + } + function builtIn(v) { + var words = v.split(/\s+/).filter(Boolean); + return D.search.filter(function (x) { return words.every(function (w) { return x.k.indexOf(w) >= 0; }); }) + .sort(function (a, b) { return (a.t.toLowerCase().indexOf(words[0]) === 0 ? 0 : 1) - (b.t.toLowerCase().indexOf(words[0]) === 0 ? 0 : 1); }) + .slice(0, 25).map(function (x) { return { t: x.t, s: x.s, h: href(x.h) }; }); + } + if (q && res) { + q.addEventListener("input", function () { + var v = q.value.trim().toLowerCase(); + if (!v) { res.style.display = "none"; return; } + show(builtIn(v)); + loadPagefind().then(function (pf) { + if (!pf || q.value.trim().toLowerCase() !== v) return; + pf.search(v).then(function (r) { + return Promise.all(r.results.slice(0, 12).map(function (x) { return x.data(); })); + }).then(function (rows) { + if (q.value.trim().toLowerCase() !== v || !rows.length) return; + show(rows.map(function (d) { return { t: (d.meta && d.meta.title) || d.url, s: (d.excerpt || "").replace(/<[^>]+>/g, ""), h: d.url }; })); + }).catch(function () { /* keep the built-in results */ }); + }); + }); + q.addEventListener("keydown", function (e) { + var links = res.querySelectorAll("a"); + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + sel = Math.max(0, Math.min(links.length - 1, sel + (e.key === "ArrowDown" ? 1 : -1))); + links.forEach(function (a, i) { a.classList.toggle("sel", i === sel); }); + } else if (e.key === "Enter" && links.length) { + location.href = links[Math.max(0, sel)].href; + } else if (e.key === "Escape") { res.style.display = "none"; q.blur(); } + }); + document.addEventListener("click", function (e) { if (!$("#search").contains(e.target)) res.style.display = "none"; }); + document.addEventListener("keydown", function (e) { + if (e.key === "/" && document.activeElement !== q && !/^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement.tagName)) { e.preventDefault(); q.focus(); } + }); + } + + if (typeof window.cytoscape !== "function") { + document.querySelectorAll(".graph").forEach(function (g) { + g.innerHTML = '
The graph library did not load (it comes from the site\'s assetBase). Every node still has its own page.
'; + }); + return; + } + + // ── Colours ───────────────────────────────────────────────────────────── + // Fill: the kind, the feature, or the DECLARED status of the claims on a node. + // Ring: a gate verdict (unclaimed). Never a test result. + function kindColour(n) { + return css({ entity: "--entity", value: "--value", prompt: "--prompt", template: "--prompt", projection: "--projection", report: "--report" }[n.kind] || "--entity"); + } + function fill(n, mode) { + if (n.abstract) return css("--retired"); + if (mode === "kind") return kindColour(n); + if (mode === "api") return n.api.length ? css("--accent") : css("--retired-soft"); + if (mode === "claims") return n.claims > 8 ? css("--live") : n.claims > 2 ? css("--partial") : n.claims > 0 ? css("--planned") : css("--retired-soft"); + if (mode === "cap") return n.feature >= 0 ? PAL[n.feature % PAL.length] : n.quality ? css("--retired") : css("--retired-soft"); + return n.status === "unclaimed" ? css("--retired-soft") : css("--" + n.status); + } + function legend(mode) { + var ring = D.hasClaims ? 'red ring: unclaimed (gate)' : ""; + if (mode === "cap") return '
' + D.features.map(function (f, i) { return '● ' + esc(f.label) + ""; }).join("") + '● qualities only○ no claim' + ring + "Fill is the feature (L1) with the most claims on the node; size is fields plus claims.
"; + if (mode === "status") return '
' + STATUS.map(function (s) { return '' + GLYPH[s] + " " + s + ""; }).join("") + '○ no claim' + ring + "Fill is the best declared status among the requirements that claim the node.
"; + if (mode === "api") return '
● mounted routes○ none
'; + if (mode === "claims") return '
● more than 8 claims● 3 to 8● 1 or 2○ none' + ring + "
"; + return '
' + ["entity", "value", "projection", "report", "prompt"].map(function (k) { return '● ' + k + ""; }).join("") + "Abstract bases are dashed and grey.
"; + } + function shape(n) { return n.kind === "prompt" || n.kind === "template" ? "diamond" : n.kind === "value" ? "round-triangle" : n.kind === "projection" || n.kind === "report" ? "round-hexagon" : n.abstract ? "round-rectangle" : "ellipse"; } + function baseStyle() { + return [ + { selector: "node", style: { "background-color": "data(col)", label: "data(label)", width: "data(size)", height: "data(size)", shape: "data(shape)", color: css("--ink"), "font-size": 10, "text-valign": "bottom", "text-margin-y": 3, "border-width": 1, "border-color": css("--line"), "text-wrap": "none" } }, + { selector: "node[dashed = 1]", style: { "border-style": "dashed", "border-width": 2, "background-opacity": 0.4 } }, + { selector: "node[ring = 1]", style: { "border-width": 3, "border-color": css("--bad") } }, + { selector: "node[faded = 1]", style: { opacity: 0.18 } }, + { selector: "edge", style: { width: 1.2, "line-color": css("--muted"), "target-arrow-color": css("--muted"), "target-arrow-shape": "triangle", "curve-style": "bezier", "arrow-scale": 0.7, opacity: 0.6 } }, + { selector: "edge[ext = 1]", style: { "line-style": "dashed", "target-arrow-shape": "triangle-tee" } }, + { selector: "edge[faded = 1]", style: { opacity: 0.08 } }, + { selector: ":selected", style: { "border-width": 3, "border-color": css("--accent") } }, + ]; + } + var bar = function (c) { + var t = STATUS.reduce(function (a, s) { return a + (c[s] || 0); }, 0); + if (!t) return '
'; + return '
' + STATUS.filter(function (s) { return c[s]; }).map(function (s) { return ''; }).join("") + "
"; + }; + var chip = function (s) { return s ? '' + (GLYPH[s] || "") + " " + esc(s) + "" : ""; }; + var nodeLink = function (id) { var n = byId[id]; return n ? '' + esc(n.name) + "" : esc(id); }; + + // ── S2: the model map ─────────────────────────────────────────────────── + // cose from a grid (not from one point) gives the same picture on every load. + var COSE = { name: "cose", animate: false, randomize: false, nodeRepulsion: 12000, nodeOverlap: 24, idealEdgeLength: 90, componentSpacing: 90, gravity: 0.6, padding: 24, nodeDimensionsIncludeLabels: true }; + function seedGrid(els) { + var nodes = els.filter(function (e) { return !e.data.source; }), cols = Math.ceil(Math.sqrt(nodes.length || 1)); + nodes.forEach(function (e, i) { e.position = { x: (i % cols) * 120, y: Math.floor(i / cols) * 100 }; }); + } + var cyEl = $("#cy"); + if (cyEl) { + var cy = null; + var params = new URLSearchParams(location.search); + var sliceSel = $("#slice"), colourSel = $("#colour"); + if (params.get("slice") && sliceSel.querySelector('option[value="' + CSS.escape(params.get("slice")) + '"]')) sliceSel.value = params.get("slice"); + if (params.get("colour") && colourSel.querySelector('option[value="' + CSS.escape(params.get("colour")) + '"]')) colourSel.value = params.get("colour"); + var focus = params.get("focus"); + var promptSet = function () { + var s = {}; + D.nodes.forEach(function (n) { if (n.kind === "prompt" || n.kind === "template") s[n.id] = 1; }); + D.edges.forEach(function (e) { if (s[e.s] && (e.k === "payload" || e.k === "reply")) s[e.t] = 1; }); + D.edges.forEach(function (e) { if (s[e.s] && byId[e.s] && byId[e.s].kind === "value" && e.k === "objectRef") s[e.t] = 1; }); + return s; + }; + var build = function (relayout) { + var slice = sliceSel.value, mode = colourSel.value, hide = $("#hideOut").checked, showExt = $("#showExt").checked; + $("#maplegend").innerHTML = legend(mode); + var inSlice = null; + if (slice === "@prompts") inSlice = promptSet(); + else if (slice && D.slices[slice]) { inSlice = {}; D.slices[slice].forEach(function (id) { inSlice[id] = 1; }); } + var showVals = $("#showValues").checked || slice === "@prompts" || (inSlice && Object.keys(inSlice).some(function (id) { return byId[id] && byId[id].kind !== "entity"; })); + var entityCount = D.nodes.filter(function (n) { return n.kind === "entity"; }).length; + if (!entityCount) showVals = true; + var nodes = D.nodes.filter(function (n) { return (n.kind === "entity" || showVals) && (!hide || !inSlice || inSlice[n.id]); }); + var ids = {}; + nodes.forEach(function (n) { ids[n.id] = 1; }); + var els = nodes.map(function (n) { + return { data: { id: n.id, label: n.name, col: fill(n, mode), size: 18 + Math.min(40, n.fields * 1.6 + n.claims * 2), shape: shape(n), faded: inSlice && !inSlice[n.id] ? 1 : 0, dashed: n.abstract, ring: n.unclaimed } }; + }); + D.edges.forEach(function (e, i) { + if (!ids[e.s] || !ids[e.t] || (!showExt && e.k === "extends")) return; + els.push({ data: { id: "e" + i, source: e.s, target: e.t, label: e.v || e.k, ext: e.k === "extends" ? 1 : 0, faded: inSlice && !(inSlice[e.s] && inSlice[e.t]) ? 1 : 0 } }); + }); + seedGrid(els); + var pan = cy && !relayout ? { pos: {} } : null; + if (pan) cy.nodes().forEach(function (n) { pan.pos[n.id()] = n.position(); }); + if (cy) cy.destroy(); + var keep = pan && els.every(function (el) { return el.data.source || pan.pos[el.data.id]; }); + cy = cytoscape({ + container: cyEl, elements: els, wheelSensitivity: 0.3, maxZoom: 1.5, style: baseStyle(), + layout: keep ? { name: "preset", positions: function (n) { return pan.pos[n.id()]; }, fit: true, padding: 20 } : COSE, + }); + cy.on("tap", "node", function (ev) { showPanel(ev.target.id()); }); + cy.on("dbltap", "node", function (ev) { var n = byId[ev.target.id()]; if (n) location.href = href(n.href); }); + var sliceReq = slice.indexOf("requirement:") === 0 ? reqById[slice.slice("requirement:".length)] : null; + if (sliceReq) $("#panel").innerHTML = slicePanel(sliceReq, D.slices[slice] || []); + if (focus && byId[focus] && cy.getElementById(focus).length) { cy.getElementById(focus).select(); showPanel(focus); focus = null; } + var u = new URL(location.href); + if (slice) u.searchParams.set("slice", slice); else u.searchParams.delete("slice"); + u.searchParams.set("colour", mode); + history.replaceState(null, "", u.search ? u.pathname + u.search : u.pathname); + }; + ["#slice", "#colour", "#hideOut", "#showValues", "#showExt"].forEach(function (s) { $(s).onchange = function () { build(false); }; }); + $("#relayout").onclick = function () { if (cy) cy.layout(Object.assign({}, COSE, { randomize: true })).run(); }; + redraw.push(function () { build(false); }); + build(true); + } + function slicePanel(r, nodes) { + return '
Slice

' + (r.level ? "L" + r.level : "—") + '' + esc(r.label) + " " + chip(r.status) + "

" + esc(r.statement) + "
" + + "

" + r.claimed + " model node" + (r.claimed === 1 ? "" : "s") + " claimed below

" + nodes.map(nodeLink).join(", ") + + "

Status below

" + bar(r.rollup); + } + function showPanel(id) { + var n = byId[id], p = $("#panel"); + if (!n || !p) return; + var claims = D.reqs.filter(function (r) { return r.targets.indexOf(id) >= 0; }); + var byTop = {}; + claims.forEach(function (r) { var t = r; while (t.parent && reqById[t.parent]) t = reqById[t.parent]; (byTop[t.id] = byTop[t.id] || []).push(r); }); + var refs = D.edges.filter(function (e) { return e.s === id || e.t === id; }).length; + var gen = (D.generated[id] || []).map(function (g) { return '' + esc(g[0]) + " " + g[1]; }).join(" · "); + p.innerHTML = '
' + esc(n.pkg) + " · " + esc(n.kind) + (n.storage ? " · " + esc(n.storage) : "") + "
" + + '

' + nodeLink(id) + (n.unclaimed ? ' unclaimed' : "") + "

" + + "

Why it exists (" + claims.length + " claim" + (claims.length === 1 ? "" : "s") + ")

" + + (claims.length ? Object.keys(byTop).map(function (t) { + return "
" + esc(reqById[t].label) + ": " + byTop[t].slice(0, 6).map(function (r) { return '' + (GLYPH[r.status] || "") + " " + esc(r.label) + ""; }).join(", ") + (byTop[t].length > 6 ? " +" + (byTop[t].length - 6) : "") + "
"; + }).join("") : 'No requirement claims this node.') + + "

Shape

" + n.fields + " field" + (n.fields === 1 ? "" : "s") + ", " + refs + " reference" + (refs === 1 ? "" : "s") + + (n.api.length ? "

API

" + n.api.map(function (a) { return "
" + esc(a) + "
"; }).join("") : "") + + "

Generated

" + (gen || 'nothing traced'); + } + + // ── S3: the one-hop mini-map ──────────────────────────────────────────── + var mini = $("#cymini"); + if (mini) { + var me = mini.getAttribute("data-node"); + var drawMini = function () { + var es = D.edges.filter(function (e) { return e.s === me || e.t === me; }); + var ids = {}; + ids[me] = 1; + es.forEach(function (e) { ids[e.s] = 1; ids[e.t] = 1; }); + var els = Object.keys(ids).filter(function (id) { return byId[id]; }).sort().map(function (id) { + var n = byId[id]; + return { data: { id: id, label: n.name, col: fill(n, D.hasClaims ? "status" : "kind"), me: id === me ? 1 : 0, ring: n.unclaimed, shape: shape(n) } }; + }).concat(es.map(function (e, i) { return { data: { id: "m" + i, source: e.s, target: e.t, label: e.v || e.k } }; })); + mini.innerHTML = ""; + var c = cytoscape({ + container: mini, elements: els, maxZoom: 1.3, + style: [ + { selector: "node", style: { "background-color": "data(col)", label: "data(label)", shape: "data(shape)", "font-size": 10, color: css("--ink"), "text-valign": "bottom", "text-margin-y": 2, width: 18, height: 18, "border-width": 1, "border-color": css("--line") } }, + { selector: "node[ring = 1]", style: { "border-width": 3, "border-color": css("--bad") } }, + { selector: "node[me = 1]", style: { width: 30, height: 30, "border-width": 3, "border-color": css("--accent") } }, + { selector: "edge", style: { width: 1, label: "data(label)", "font-size": 8, color: css("--muted"), "line-color": css("--muted"), "target-arrow-shape": "triangle", "target-arrow-color": css("--muted"), "curve-style": "bezier", "text-rotation": "autorotate", "text-background-color": css("--panel"), "text-background-opacity": 1, "text-background-padding": 2 } }, + ], + layout: { name: "concentric", concentric: function (n) { return n.data("me") ? 2 : 1; }, levelWidth: function () { return 1; }, startAngle: Math.PI, padding: 30, minNodeSpacing: 60 }, + }); + c.on("tap", "node", function (ev) { var n = byId[ev.target.id()]; if (n && n.id !== me) location.href = href(n.href); }); + }; + redraw.push(drawMini); + drawMini(); + } + + // ── S8: the trace ─────────────────────────────────────────────────────── + var tr = $("#cytrace"), tstart = $("#tstart"); + if (tr && tstart) { + var p2 = new URLSearchParams(location.search).get("start"); + if (p2 && reqById[p2]) tstart.value = p2; + var drawTrace = function () { + var r0 = reqById[tstart.value]; + if (!r0) return; + var kids = {}; + D.reqs.forEach(function (r) { if (r.parent) (kids[r.parent] = kids[r.parent] || []).push(r); }); + var reqs = [], walk = function (r) { reqs.push(r); (kids[r.id] || []).forEach(walk); }; + walk(r0); + var cols = [[], [], [], []], els = [], seen = {}; + var add = function (id, label, col, data) { if (seen[id]) return; seen[id] = 1; cols[col].push(id); data.id = id; data.label = label; els.push({ data: data }); }; + reqs.forEach(function (r) { add("r:" + r.id, (r.level ? "L" + r.level + " " : "") + r.label, r.level >= 4 ? 1 : 0, { col: r.status ? css("--" + r.status) : css("--muted"), shape: "round-rectangle", go: r.href }); }); + reqs.forEach(function (r) { if (r.parent && seen["r:" + r.parent]) els.push({ data: { id: "pe:" + r.id, source: "r:" + r.parent, target: "r:" + r.id } }); }); + reqs.forEach(function (r) { + r.targets.forEach(function (t) { + var n = byId[t]; + if (!n) return; + add("o:" + t, n.name, 2, { col: kindColour(n), shape: shape(n), go: n.href, ring: n.unclaimed }); + if (!seen["le:" + r.id + ">" + t]) { seen["le:" + r.id + ">" + t] = 1; els.push({ data: { id: "le:" + r.id + ">" + t, source: "r:" + r.id, target: "o:" + t, col: r.status ? css("--" + r.status) : css("--muted") } }); } + }); + }); + cols[2].slice().forEach(function (oid) { + var id = oid.slice(2); + var gen = D.generated[id] || []; + if (!gen.length) return; + var total = gen.reduce(function (a, g) { return a + g[1]; }, 0); + var gid = "g:" + id; + add(gid, total + " generated (" + gen.map(function (g) { return g[0]; }).join(", ") + ")", 3, { col: css("--accent"), shape: "tag", go: byId[id].href }); + els.push({ data: { id: "ge:" + gid, source: oid, target: gid } }); + }); + var pos = {}; + cols.forEach(function (c, ci) { c.forEach(function (id, i) { pos[id] = { x: ci * 260, y: (i - c.length / 2) * 26 }; }); }); + tr.innerHTML = ""; + var c = cytoscape({ + container: tr, elements: els, wheelSensitivity: 0.3, maxZoom: 1.6, + style: [ + { selector: "node", style: { "background-color": "data(col)", label: "data(label)", shape: "data(shape)", "font-size": 11, color: css("--ink"), "text-halign": "right", "text-valign": "center", "text-margin-x": 6, width: 12, height: 12 } }, + { selector: "node[ring = 1]", style: { "border-width": 2, "border-color": css("--bad") } }, + { selector: "edge", style: { width: 1, "line-color": "data(col)", opacity: 0.5, "curve-style": "unbundled-bezier" } }, + { selector: "edge[!col]", style: { "line-color": css("--muted") } }, + ], + layout: { name: "preset", positions: function (n) { return pos[n.id()]; }, fit: true, padding: 24 }, + }); + c.on("tap", "node", function (ev) { var go = ev.target.data("go"); if (go) location.href = href(go); }); + }; + tstart.onchange = function () { + var u = new URL(location.href); u.searchParams.set("start", tstart.value); history.replaceState(null, "", u.pathname + u.search); + drawTrace(); + }; + redraw.push(drawTrace); + drawTrace(); + } +})(); diff --git a/server/typescript/packages/docs-site/package.json b/server/typescript/packages/docs-site/package.json index a559b1c5e..3c3129bb1 100644 --- a/server/typescript/packages/docs-site/package.json +++ b/server/typescript/packages/docs-site/package.json @@ -1,7 +1,7 @@ { "name": "@metaobjectsdev/docs-site", "version": "1.2.0", - "description": "HTML documentation-site generator for metaobjects models — a browsable multi-page site (nav, search, per-object/package pages, kind-aware ER diagrams). Powers the `docs-site` generator (`docsSiteFile()`).", + "description": "HTML documentation-site generator for metaobjects models — a model explorer (what the software does, model map, requirement pages, matrix, trace, gaps) and a classic multi-page site with kind-aware ER diagrams. Powers the `docs-site` generator (`docsSiteFile()`).", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/server/typescript/packages/docs-site/src/explorer/build.ts b/server/typescript/packages/docs-site/src/explorer/build.ts index f7918becf..344fbc4af 100644 --- a/server/typescript/packages/docs-site/src/explorer/build.ts +++ b/server/typescript/packages/docs-site/src/explorer/build.ts @@ -51,6 +51,7 @@ import { type ExplorerLedger, type ExplorerLedgerRow, type ExplorerNode, + type ExplorerRoute, type ExplorerSlice, } from "./types.js"; @@ -62,6 +63,9 @@ export interface ExplorerInput { verify?: unknown; /** Parsed `.metaobjects/.gen-state/.hashes.json`. Without it there is no `generated`. */ manifest?: unknown; + /** The routes the project mounts: `[{ node, method, path }]`, one per route, `node` a node + * id. A route whose node is not in the model is dropped. Without it there is no `api`. */ + routes?: unknown; } const ROOT_KEY = `${TYPE_METADATA}${TYPE_SUBTYPE_SEPARATOR}${SUBTYPE_ROOT}`; @@ -157,6 +161,7 @@ function walkModel(model: unknown): Walked[] { subType: child.subType, name, package: pkg, + order: index, ...(parent !== undefined ? { parent: parent.node.id } : {}), ...(typeof ext === "string" && ext !== "" ? { extends: nodeIdOf(ext) } : {}), ...(child.body[RESERVED_KEY_ABSTRACT] === true ? { abstract: true as const } : {}), @@ -241,7 +246,7 @@ function modelEdges(walked: Walked[], byId: Map): ExplorerEdge[] function asLinks(v: unknown): { ref: string; resolves: boolean; target?: string }[] { if (!Array.isArray(v)) return []; - return v.filter(isRecord).map(asLink).filter((l): l is NonNullable => l !== undefined); + return v.map(asLink).filter((l): l is NonNullable => l !== undefined); } function asLink(v: unknown): { ref: string; resolves: boolean; target?: string } | undefined { @@ -266,6 +271,7 @@ function ledgerFrom(verify: unknown, byId: Map, edges: ExplorerE code: String(d.code ?? ""), severity: String(d.severity ?? ""), source: String(d.source ?? ""), + message: String(d.message ?? ""), })) : []; rows.push({ @@ -316,6 +322,18 @@ function generatedFrom(manifest: unknown): ExplorerGenerated[] | undefined { })); } +function routesFrom(routes: unknown, byId: Map): ExplorerRoute[] | undefined { + if (!Array.isArray(routes)) return undefined; + const seen = new Map(); + for (const r of routes) { + if (!isRecord(r) || typeof r.node !== "string" || typeof r.method !== "string" || typeof r.path !== "string") continue; + if (!byId.has(r.node)) continue; + const route: ExplorerRoute = { node: r.node, method: r.method.toUpperCase(), path: r.path }; + seen.set(`${route.node}\u0000${route.path}\u0000${route.method}`, route); + } + return [...seen.keys()].sort(byCodeUnit).map((k) => seen.get(k) as ExplorerRoute); +} + function derivedSlices(walked: Walked[], byId: Map, edges: ExplorerEdge[]): ExplorerSlice[] { const slices: ExplorerSlice[] = []; const ownerOf = (id: string): string => byId.get(id)?.owner ?? id; @@ -381,12 +399,13 @@ export function buildExplorer(input: ExplorerInput): ExplorerJson { const edges = modelEdges(walked, byId); const ledger = input.verify !== undefined ? ledgerFrom(input.verify, byId, edges) : undefined; const generated = input.manifest !== undefined ? generatedFrom(input.manifest) : undefined; + const api = input.routes !== undefined ? routesFrom(input.routes, byId) : undefined; // One edge per (kind, from, to, via): an object can reach one target twice through two // members, which is two edges, but never the same edge twice. const unique = new Map(); for (const e of edges) unique.set(edgeKey(e), e); - const sortedEdges = [...unique.values()].sort((a, b) => byCodeUnit(edgeKey(a), edgeKey(b))); + const sortedEdges = [...unique].sort(([a], [b]) => byCodeUnit(a, b)).map(([, e]) => e); return { format: EXPLORER_FORMAT, @@ -394,6 +413,7 @@ export function buildExplorer(input: ExplorerInput): ExplorerJson { edges: sortedEdges, ...(ledger !== undefined ? { ledger } : {}), ...(generated !== undefined ? { generated } : {}), + ...(api !== undefined ? { api } : {}), slices: derivedSlices(walked, byId, sortedEdges), }; } diff --git a/server/typescript/packages/docs-site/src/explorer/html.ts b/server/typescript/packages/docs-site/src/explorer/html.ts new file mode 100644 index 000000000..278824a70 --- /dev/null +++ b/server/typescript/packages/docs-site/src/explorer/html.ts @@ -0,0 +1,85 @@ +// HTML fragments the explorer pages share: status chips, level badges, roll-up bars, links. +// +// The colour rules are design report §4.2, and every fragment here keeps them apart: +// - a requirement's DECLARED status is a filled chip plus a glyph (● ◐ ○ ⊘), so it reads +// without colour; +// - a GATE verdict (a finding on a requirement, an unclaimed entity) is an outlined badge or a +// ring, never a fill; +// - a ROLL-UP of the statuses below is a separate stacked bar, and never repaints the chip. +// Nothing here colours a test result: the build does not read one. + +import { REQUIREMENT_ATTR_DISPOSITION } from "@metaobjectsdev/metadata"; +import { STATUSES, STATUS_GLYPH, type ReqEntry, type Rollup } from "./index-model.js"; + +export function esc(s: unknown): string { + return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); +} + +/** A declared status: a filled chip with its glyph. */ +export function statusChip(status: string): string { + if (status === "") return ""; + return `${STATUS_GLYPH[status] ?? ""} ${esc(status)}`; +} + +/** A declared status as its glyph alone, in the status colour (child lists, matrix cells). */ +export function statusGlyph(status: string): string { + if (status === "") return ""; + return `${STATUS_GLYPH[status] ?? ""}`; +} + +/** The level badge: `L3`, or `—` for a flat architectural policy. */ +export function levelBadge(r: ReqEntry): string { + return r.level !== undefined + ? `L${r.level}` + : `—`; +} + +/** The `source` a verify finding carries when it can fail the build; anything else is an + * authoring advisory, which never can. */ +export const GATE_SOURCE = "gate"; + +/** A gate verdict as an outlined badge: `error` or `warn`, with the finding codes. Authoring + * advisories never reach it. */ +export function verdictBadge(r: ReqEntry): string { + const gate = (r.row?.diagnostics ?? []).filter((d) => d.source === GATE_SOURCE); + if (gate.length === 0) return ""; + const worst = gate.some((d) => d.severity === "error") ? "error" : "warn"; + const codes = gate.map((d) => d.code).join(", "); + const n = gate.length; + return `${worst === "error" ? "✕" : "!"} ${n} gate ${worst === "error" ? (n === 1 ? "error" : "errors") : n === 1 ? "warning" : "warnings"}`; +} + +/** The disposition pill (`accepted`, `deferred`). */ +export function dispositionPill(r: ReqEntry): string { + const d = r.node.attrs[REQUIREMENT_ATTR_DISPOSITION]; + return typeof d === "string" ? `${esc(d)}` : ""; +} + +/** The roll-up: a stacked bar of the declared statuses below, drawn apart from any chip. */ +export function rollupBar(c: Rollup): string { + const total = STATUSES.reduce((a, s) => a + (c[s] ?? 0), 0); + const title = STATUSES.filter((s) => (c[s] ?? 0) > 0).map((s) => `${c[s]} ${s}`).join(", "); + if (total === 0) return `
`; + const spans = STATUSES.filter((s) => (c[s] ?? 0) > 0) + .map((s) => ``) + .join(""); + return ``; +} + +/** A width that prints the same on every run and every platform. */ +function pct(x: number): string { + return `${(Math.round(x * 10000) / 100).toString()}%`; +} + +/** The legend every requirement view carries. */ +export function legendHtml(withUnclaimed: boolean): string { + const chips = STATUSES.map((s) => statusChip(s)).join(""); + const unclaimed = withUnclaimed ? `unclaimed` : ""; + return `
${chips}${unclaimed}! gate findingFill and glyph are the declared @status; outlines are meta verify findings; bars roll up what sits below. No colour here reports a test result.
`; +} + +/** A path from one page to another, both relative to the site root. */ +export function relHref(fromPage: string, toPage: string): string { + const depth = fromPage.split("/").length - 1; + return "../".repeat(depth) + toPage; +} diff --git a/server/typescript/packages/docs-site/src/explorer/index-model.ts b/server/typescript/packages/docs-site/src/explorer/index-model.ts new file mode 100644 index 000000000..6b1014a83 --- /dev/null +++ b/server/typescript/packages/docs-site/src/explorer/index-model.ts @@ -0,0 +1,343 @@ +// An in-memory index over one `explorer.json`: the lookups every explorer page shares. +// +// It reads the document and nothing else. A view asks this index; it never walks the loader, so +// what a page shows is exactly what the data spine states (design report §3.1, "views never +// re-derive model facts"). The only arithmetic here is the explorer's own: the roll-up of the +// statuses below a requirement, which §4.2 calls computable and ungated, and which a page shows +// as a separate bar, never as the requirement's colour. + +import { + OBJECT_SUBTYPE_ENTITY, + OBJECT_SUBTYPE_PROJECTION, + OBJECT_SUBTYPE_REPORT, + OBJECT_SUBTYPE_VALUE, + SOURCE_ATTR_KIND, + SOURCE_ATTR_TABLE, + SOURCE_ATTR_VIEW, + SOURCE_KIND_VIEW, + TEMPLATE_SUBTYPE_PROMPT, + TYPE_FIELD, + TYPE_OBJECT, + TYPE_REQUIREMENT, + TYPE_SOURCE, + TYPE_TEMPLATE, +} from "@metaobjectsdev/metadata/constants"; +import { + REQUIREMENT_ATTR_LEVEL, + REQUIREMENT_ATTR_STATUS, + REQUIREMENT_SUBTYPE_ARCHITECTURAL, + REQUIREMENT_SUBTYPE_FUNCTIONAL, + REQUIREMENT_STATUSES, + REQUIREMENT_STATUS_LIVE, + REQUIREMENT_STATUS_PARTIAL, + REQUIREMENT_STATUS_PLANNED, + REQUIREMENT_STATUS_RETIRED, +} from "@metaobjectsdev/metadata"; +import type { ExplorerEdge, ExplorerGeneratedFile, ExplorerJson, ExplorerLedgerRow, ExplorerNode, ExplorerRoute, ExplorerSlice } from "./types.js"; + +/** The four declared statuses, in the order every bar and legend draws them: live first, since + * it is the claim a reader looks for, and the bar reads left to right as "done". */ +export const STATUSES: readonly string[] = [ + REQUIREMENT_STATUS_LIVE, + REQUIREMENT_STATUS_PARTIAL, + REQUIREMENT_STATUS_PLANNED, + REQUIREMENT_STATUS_RETIRED, +].filter((s) => (REQUIREMENT_STATUSES as readonly string[]).includes(s)); +const STATUS_ORDER = STATUSES; + +/** The glyph for each status, so the colour is never the only signal (§4.2). */ +export const STATUS_GLYPH: Readonly> = { + [REQUIREMENT_STATUS_LIVE]: "●", + [REQUIREMENT_STATUS_PARTIAL]: "◐", + [REQUIREMENT_STATUS_PLANNED]: "○", + [REQUIREMENT_STATUS_RETIRED]: "⊘", +}; + +/** Edges drawn between model nodes on the map and the mini-maps. */ +export const MODEL_EDGE_KINDS: readonly string[] = ["references", "association", "objectRef", "payload", "reply", "extends"]; + +const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + +/** One requirement as the pages read it: the node, its ledger row, its place in the tree. */ +export interface ReqEntry { + node: ExplorerNode; + row?: ExplorerLedgerRow; + parent?: ReqEntry; + children: ReqEntry[]; + level?: number; + status: string; + /** functional | architectural */ + kind: string; +} + +/** A requirement's claim on a model node, as the ledger resolved it. */ +export interface Claim { + req: ReqEntry; + /** The member path when the claim names a member (`Order.reference`), else undefined. */ + member?: string; +} + +export type Rollup = Record; + +export class ExplorerIndex { + readonly doc: ExplorerJson; + readonly byId = new Map(); + private readonly kids = new Map(); + /** Top-level object nodes (entity, value, projection, report, …), by id. */ + readonly objects: ExplorerNode[] = []; + /** Top-level template nodes (prompt, output, …), by id. */ + readonly templates: ExplorerNode[] = []; + /** Every requirement, in document order (the ledger's order when there is one). */ + readonly requirements: ReqEntry[] = []; + readonly reqById = new Map(); + /** The top of each requirement tree, in document order. */ + readonly topRequirements: ReqEntry[] = []; + private readonly claimsOn = new Map(); + private readonly generatedOf = new Map(); + private readonly routesOf = new Map(); + readonly packages: string[]; + + constructor(doc: ExplorerJson) { + this.doc = doc; + for (const n of doc.nodes) this.byId.set(n.id, n); + for (const n of doc.nodes) { + if (n.parent === undefined) continue; + const list = this.kids.get(n.parent) ?? []; + list.push(n); + this.kids.set(n.parent, list); + } + for (const list of this.kids.values()) list.sort((a, b) => a.order - b.order || byCodeUnit(a.id, b.id)); + for (const n of doc.nodes) { + if (n.parent !== undefined) continue; + if (n.type === TYPE_OBJECT) this.objects.push(n); + else if (n.type === TYPE_TEMPLATE) this.templates.push(n); + } + this.packages = [...new Set(doc.nodes.filter((n) => n.parent === undefined).map((n) => n.package))].sort(byCodeUnit); + this.indexRequirements(); + for (const e of doc.edges) { + if (e.kind !== "implementedBy") continue; + const req = this.reqById.get(e.from); + if (req === undefined) continue; + const owner = this.ownerOf(e.to); + const member = owner === e.to ? undefined : e.to.slice(owner.length + 1); + const list = this.claimsOn.get(owner) ?? []; + list.push({ req, ...(member !== undefined ? { member } : {}) }); + this.claimsOn.set(owner, list); + } + for (const g of doc.generated ?? []) this.generatedOf.set(g.node, g.files); + for (const r of doc.api ?? []) { + const list = this.routesOf.get(r.node) ?? []; + list.push(r); + this.routesOf.set(r.node, list); + } + } + + private indexRequirements(): void { + const rows = new Map((this.doc.ledger?.rows ?? []).map((r) => [r.node, r] as const)); + const reqNodes = this.doc.nodes.filter((n) => n.type === TYPE_REQUIREMENT); + // Document order: the ledger's when there is one (the verify payload walks the model in + // declaration order), else each node's sibling order under its parent. + const ledgerOrder = new Map((this.doc.ledger?.rows ?? []).map((r, i) => [r.node, i] as const)); + const depthFirst: ExplorerNode[] = []; + const visit = (n: ExplorerNode): void => { + depthFirst.push(n); + for (const c of this.children(n.id)) if (c.type === TYPE_REQUIREMENT) visit(c); + }; + const tops = reqNodes + .filter((n) => n.parent === undefined || this.byId.get(n.parent)?.type !== TYPE_REQUIREMENT) + .sort((a, b) => (ledgerOrder.get(a.id) ?? Infinity) - (ledgerOrder.get(b.id) ?? Infinity) || a.order - b.order || byCodeUnit(a.id, b.id)); + for (const t of tops) visit(t); + for (const n of depthFirst) { + const row = rows.get(n.id); + const level = typeof n.attrs[REQUIREMENT_ATTR_LEVEL] === "number" ? (n.attrs[REQUIREMENT_ATTR_LEVEL] as number) : row?.level; + const status = typeof n.attrs[REQUIREMENT_ATTR_STATUS] === "string" ? (n.attrs[REQUIREMENT_ATTR_STATUS] as string) : (row?.status ?? ""); + const parent = n.parent !== undefined ? this.reqById.get(n.parent) : undefined; + const entry: ReqEntry = { + node: n, + ...(row !== undefined ? { row } : {}), + ...(parent !== undefined ? { parent } : {}), + children: [], + ...(level !== undefined ? { level } : {}), + status, + kind: n.subType, + }; + this.requirements.push(entry); + this.reqById.set(n.id, entry); + if (parent !== undefined) parent.children.push(entry); + else this.topRequirements.push(entry); + } + } + + /** Direct children of a node, in declaration order. */ + children(id: string): ExplorerNode[] { + return this.kids.get(id) ?? []; + } + + /** The top-level node a node sits under (itself at the top). */ + ownerOf(id: string): string { + let cur = this.byId.get(id); + while (cur?.parent !== undefined) cur = this.byId.get(cur.parent); + return cur?.id ?? id; + } + + fields(id: string): ExplorerNode[] { + return this.children(id).filter((c) => c.type === TYPE_FIELD); + } + + /** The node's `source.*` child, when it declares one. */ + source(id: string): ExplorerNode | undefined { + return this.children(id).find((c) => c.type === TYPE_SOURCE); + } + + /** `table x`, `view v_x`, or undefined: what the source declares, never a derived name. */ + storage(id: string): { kind: "table" | "view"; name?: string } | undefined { + const s = this.source(id); + if (s === undefined) return undefined; + const view = s.attrs[SOURCE_ATTR_VIEW]; + const table = s.attrs[SOURCE_ATTR_TABLE]; + const isView = s.attrs[SOURCE_ATTR_KIND] === SOURCE_KIND_VIEW || typeof view === "string"; + const name = typeof view === "string" ? view : typeof table === "string" ? table : undefined; + return { kind: isView ? "view" : "table", ...(name !== undefined ? { name } : {}) }; + } + + isEntity(n: ExplorerNode): boolean { + return n.type === TYPE_OBJECT && n.subType === OBJECT_SUBTYPE_ENTITY; + } + + isPrompt(n: ExplorerNode): boolean { + return n.type === TYPE_TEMPLATE && n.subType === TEMPLATE_SUBTYPE_PROMPT; + } + + /** The kind a page and the map name a top-level node by. */ + kindOf(n: ExplorerNode): "entity" | "value" | "projection" | "report" | "prompt" | "template" | "object" { + if (n.type === TYPE_TEMPLATE) return n.subType === TEMPLATE_SUBTYPE_PROMPT ? "prompt" : "template"; + if (n.subType === OBJECT_SUBTYPE_ENTITY) return "entity"; + if (n.subType === OBJECT_SUBTYPE_VALUE) return "value"; + if (n.subType === OBJECT_SUBTYPE_PROJECTION) return "projection"; + if (n.subType === OBJECT_SUBTYPE_REPORT) return "report"; + return "object"; + } + + /** Concrete stored entities: `object.entity`, not abstract. */ + storedEntities(): ExplorerNode[] { + return this.objects.filter((o) => this.isEntity(o) && o.abstract !== true); + } + + claims(id: string): Claim[] { + return this.claimsOn.get(id) ?? []; + } + + generated(id: string): ExplorerGeneratedFile[] { + return this.generatedOf.get(id) ?? []; + } + + routes(id: string): ExplorerRoute[] { + return this.routesOf.get(id) ?? []; + } + + hasRequirements(): boolean { + return this.requirements.length > 0; + } + + /** Whether any requirement claims anything: object coverage only means something then. */ + hasClaims(): boolean { + return this.claimsOn.size > 0; + } + + /** The requirement and every requirement below it, depth first. */ + subtree(r: ReqEntry): ReqEntry[] { + const out: ReqEntry[] = []; + const walk = (x: ReqEntry): void => { + out.push(x); + for (const c of x.children) walk(c); + }; + walk(r); + return out; + } + + below(r: ReqEntry): ReqEntry[] { + return this.subtree(r).slice(1); + } + + /** The declared statuses of the entries below that name the model (L4, L5, or a flat + * architectural policy with links), counted. A leaf counts itself. */ + rollup(r: ReqEntry): Rollup { + const linked = this.subtree(r).filter((x) => (x.level ?? 0) >= 4 || (x.level === undefined && (x.row?.implementedBy.length ?? 0) > 0)); + const counted = linked.length > 0 ? linked : [r]; + const c: Rollup = {}; + for (const x of counted) if (x.status !== "") c[x.status] = (c[x.status] ?? 0) + 1; + return c; + } + + /** The model nodes the subtree claims (owners, deduplicated, sorted). */ + targetsOf(r: ReqEntry): string[] { + const set = new Set(); + for (const x of this.subtree(r)) { + for (const l of x.row?.implementedBy ?? []) if (l.target !== undefined && this.byId.has(l.target)) set.add(this.ownerOf(l.target)); + } + return [...set].sort(byCodeUnit); + } + + /** The ledger's gate verdict for a requirement: "error" | "warn" | "clean" | "". */ + verdict(r: ReqEntry): string { + return r.row?.verdict ?? ""; + } + + /** The L1 feature (functional top) with the most claims on a node, by document order on a tie. */ + featureOf(id: string): ReqEntry | undefined { + const count = new Map(); + for (const c of this.claims(id)) { + let top = c.req; + while (top.parent !== undefined) top = top.parent; + if (top.kind !== REQUIREMENT_SUBTYPE_FUNCTIONAL) continue; + count.set(top, (count.get(top) ?? 0) + 1); + } + let best: ReqEntry | undefined; + for (const t of this.features()) { + const n = count.get(t) ?? 0; + if (n > 0 && (best === undefined || n > (count.get(best) ?? 0))) best = t; + } + return best; + } + + features(): ReqEntry[] { + return this.topRequirements.filter((r) => r.kind === REQUIREMENT_SUBTYPE_FUNCTIONAL); + } + + qualities(): ReqEntry[] { + return this.topRequirements.filter((r) => r.kind === REQUIREMENT_SUBTYPE_ARCHITECTURAL); + } + + /** The best declared status among a node's claims, or "unclaimed". */ + claimStatus(id: string): string { + const cl = this.claims(id); + if (cl.length === 0) return "unclaimed"; + for (const s of STATUS_ORDER) if (cl.some((c) => c.req.status === s)) return s; + return "unclaimed"; + } + + /** Model edges between two top-level nodes (a member's edge is drawn from its owner). */ + modelEdges(): ExplorerEdge[] { + const out: ExplorerEdge[] = []; + const seen = new Set(); + for (const e of this.doc.edges) { + if (!MODEL_EDGE_KINDS.includes(e.kind)) continue; + const from = this.ownerOf(e.from); + const to = this.ownerOf(e.to); + if (from === to) continue; + const a = this.byId.get(from); + const b = this.byId.get(to); + if (a === undefined || b === undefined) continue; + if (a.type === TYPE_REQUIREMENT || b.type === TYPE_REQUIREMENT) continue; + const key = `${e.kind}\u0000${from}\u0000${to}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ kind: e.kind, from, to, ...(e.via !== undefined ? { via: e.via } : {}) }); + } + return out; + } + + slice(id: string): ExplorerSlice | undefined { + return this.doc.slices.find((s) => s.id === id); + } +} diff --git a/server/typescript/packages/docs-site/src/explorer/pages.ts b/server/typescript/packages/docs-site/src/explorer/pages.ts new file mode 100644 index 000000000..684729f76 --- /dev/null +++ b/server/typescript/packages/docs-site/src/explorer/pages.ts @@ -0,0 +1,977 @@ +// The explorer's page models: one function per screen (design report §3.3, S1 to S9, plus the +// API index, the package pages and the prompt index). Each returns the payload its Mustache +// template renders; every string in it is already HTML-escaped, because the renderer's text +// format does not escape. +// +// Every fact a page states is read off the ExplorerIndex, which reads only `explorer.json`. The +// pages add presentation (grouping, ordering, wording) and the explorer checks the gaps page +// labels as such; they never recompute what the loader or the gate decided. + +import { + FIELD_ATTR_COLUMN, + FIELD_ATTR_DEFAULT, + FIELD_ATTR_FILTERABLE, + FIELD_ATTR_MAX_LENGTH, + FIELD_ATTR_OBJECT_REF, + FIELD_ATTR_REQUIRED, + FIELD_ATTR_SORTABLE, + FIELD_ATTR_VALUES, + FIELD_SUBTYPE_ENUM, + IDENTITY_ATTR_FIELDS, + IDENTITY_REFERENCE_ATTR_REFERENCES, + OBJECT_REPORT_ATTR_DIMENSIONS, + OBJECT_REPORT_ATTR_FILTER, + OBJECT_REPORT_ATTR_FROM, + OBJECT_REPORT_ATTR_MEASURES, + OBJECT_REPORT_ATTR_SEGMENT, + OBJECT_REPORT_ATTR_SPINE, + ORIGIN_AGGREGATE_ATTR_AGG, + ORIGIN_AGGREGATE_ATTR_FILTER, + ORIGIN_AGGREGATE_ATTR_OF, + ORIGIN_AGGREGATE_ATTR_VIA, + ORIGIN_COMPUTED_ATTR_EXPR, + ORIGIN_PASSTHROUGH_ATTR_FROM, + RELATIONSHIP_ATTR_CARDINALITY, + RELATIONSHIP_ATTR_OBJECT_REF, + RELATIONSHIP_ATTR_THROUGH, + REPORTING_ATTR_AGG, + REPORTING_ATTR_DEFAULT, + REPORTING_ATTR_DENOMINATOR, + REPORTING_ATTR_FILTER, + REPORTING_ATTR_GRAINS, + REPORTING_ATTR_NUMERATOR, + REPORTING_ATTR_OF, + REPORTING_ATTR_SEGMENT, + REPORTING_ATTR_VIA, + TEMPLATE_ATTR_FORMAT, + TEMPLATE_ATTR_MAX_TOKENS, + TEMPLATE_ATTR_MODEL, + TEMPLATE_ATTR_PAYLOAD_REF, + TEMPLATE_ATTR_PROMPT_STYLE, + TEMPLATE_ATTR_RESPONSE_FORMAT, + TEMPLATE_ATTR_RESPONSE_REF, + TEMPLATE_ATTR_TEXT_REF, + DOC_ATTR_DESCRIPTION, + DOC_ATTR_SUMMARY, + DOC_ATTR_TITLE, + TYPE_DIMENSION, + TYPE_FIELD, + TYPE_IDENTITY, + TYPE_INDEX, + TYPE_LAYOUT, + TYPE_MEASURE, + TYPE_ORIGIN, + TYPE_RELATIONSHIP, + TYPE_REQUIREMENT, + TYPE_SEGMENT, +} from "@metaobjectsdev/metadata/constants"; +import { + LAYOUT_DATA_GRID_ATTR_COLUMNS, + LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_FIELD, + LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_ORDER, + LAYOUT_DATA_GRID_ATTR_PAGE_SIZE, + REQUIREMENT_ATTR_COUNTEREXAMPLE, + REQUIREMENT_ATTR_DISPOSITION, + REQUIREMENT_ATTR_STATEMENT, + REQUIREMENT_ATTR_TRACKED_BY, + REQUIREMENT_LEVEL_OBJECT, + REQUIREMENT_LEVEL_SERVICE, + REQUIREMENT_STATUS_LIVE, + REQUIREMENT_STATUS_PLANNED, + REQUIREMENT_STATUSES_WITH_OUTSTANDING_WORK, + REQUIREMENT_SUBTYPE_ARCHITECTURAL, + REQUIREMENT_SUBTYPE_FUNCTIONAL, +} from "@metaobjectsdev/metadata"; +import type { ExplorerIndex, ReqEntry } from "./index-model.js"; +import { STATUSES } from "./index-model.js"; +import type { ExplorerNode } from "./types.js"; +import { + dispositionPill, + esc, + GATE_SOURCE, + levelBadge, + relHref, + rollupBar, + statusChip, + statusGlyph, + verdictBadge, +} from "./html.js"; + +// ─── Where every page lives ────────────────────────────────────────────────────────────────── + +export const PAGE_OVERVIEW = "index.html"; +export const PAGE_MAP = "map.html"; +export const PAGE_FEATURES = "requirements/features.html"; +export const PAGE_QUALITIES = "requirements/qualities.html"; +export const PAGE_MATRIX = "matrix.html"; +export const PAGE_TRACE = "trace.html"; +export const PAGE_PROMPTS = "prompts.html"; +export const PAGE_API = "api.html"; +export const PAGE_GAPS = "gaps.html"; +export const PAGE_MODEL = "model/index.html"; + +/** The member types a page gives an anchor, so an L5 link can land on its row. */ +const ANCHORED_TYPES = new Set([TYPE_FIELD, TYPE_IDENTITY, TYPE_INDEX, TYPE_RELATIONSHIP, TYPE_DIMENSION, TYPE_MEASURE, TYPE_SEGMENT]); + +/** A member row's anchor: `m-` for a field, `m--` for anything else, since + * siblings of different types may share a name. */ +const memberAnchor = (n: ExplorerNode): string => (n.type === TYPE_FIELD ? `m-${n.name}` : `m-${n.type}-${n.name}`); + +/** The gate codes that report the level and nesting rules (the proof table's "Level rules"). */ +const LEVEL_RULE_CODES = new Set(["ERR_REQUIREMENT_BAD_LEVEL", "ERR_REQUIREMENT_LEVEL_NESTING", "ERR_REQUIREMENT_LINK_ABOVE_FLOOR"]); +const CODE_OBJECT_UNCLAIMED = "WARN_REQUIREMENT_OBJECT_UNCLAIMED"; + +const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); +const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined); +const strs = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : typeof v === "string" ? [v] : []); + +/** The directory of a package's pages. A package segment is an identifier, so `-root` (for + * the empty package) cannot be one. */ +function pkgDir(pkg: string): string { + return pkg === "" ? "-root" : pkg.split("::").join("/"); +} + +/** A page's file stem: the name, unless it would take the package page's `index.html` (on a + * case-insensitive file system too). */ +function pageStem(name: string): string { + return name.toLowerCase() === "index" ? `${name}.node` : name; +} + +/** A short form of an id for display: the part after the package. */ +export function localName(id: string): string { + const i = id.lastIndexOf("::"); + return i < 0 ? id : id.slice(i + 2); +} + +export class Pages { + constructor(readonly ix: ExplorerIndex) {} + + // ─── Addresses ─────────────────────────────────────────────────────────────── + + objectPage(n: ExplorerNode): string { + return `model/${pkgDir(n.package)}/${pageStem(n.name)}.html`; + } + + packagePage(pkg: string): string { + return `model/${pkgDir(pkg)}/index.html`; + } + + reqPage(r: ReqEntry): string { + return `requirements/${pkgDir(r.node.package)}/${pageStem(this.reqPath(r))}.html`; + } + + /** The requirement's dotted path, as every `verify` diagnostic prints it. */ + reqPath(r: ReqEntry): string { + if (r.row !== undefined) return r.row.path; + const prefix = r.node.package === "" ? "" : `${r.node.package}::`; + return r.node.id.startsWith(prefix) ? r.node.id.slice(prefix.length) : r.node.id; + } + + reqLabel(r: ReqEntry): string { + return str(r.node.attrs[DOC_ATTR_TITLE]) ?? r.node.name; + } + + /** Where a node id lands: its own page, or its owner's page at the member's row. */ + pageOf(id: string): string | undefined { + const req = this.ix.reqById.get(id); + if (req !== undefined) return this.reqPage(req); + const ownerId = this.ix.ownerOf(id); + const owner = this.ix.byId.get(ownerId); + if (owner === undefined || owner.type === TYPE_REQUIREMENT) return undefined; + const page = this.objectPage(owner); + if (ownerId === id) return page; + const member = this.memberOf(ownerId, id); + return member !== undefined && ANCHORED_TYPES.has(member.type) ? `${page}#${memberAnchor(member)}` : page; + } + + /** The direct child of `ownerId` that `id` sits in (or is). */ + private memberOf(ownerId: string, id: string): ExplorerNode | undefined { + let cur = this.ix.byId.get(id); + while (cur !== undefined && cur.parent !== ownerId) cur = cur.parent !== undefined ? this.ix.byId.get(cur.parent) : undefined; + return cur; + } + + kindClass(n: ExplorerNode): string { + return `k-${this.ix.kindOf(n)}`; + } + + /** A link to any node, relative to `from`; an unknown id renders as a red "unresolved" chip. */ + link(from: string, id: string, label?: string): string { + const page = this.pageOf(id); + const n = this.ix.byId.get(this.ix.ownerOf(id)); + if (page === undefined || n === undefined) return `${esc(label ?? localName(id))} (unresolved)`; + const req = this.ix.reqById.get(id); + const text = label ?? (req !== undefined ? this.reqLabel(req) : id === n.id ? n.name : `${n.name}.${id.slice(n.id.length + 1)}`); + const cls = req !== undefined ? "" : ` class="${this.kindClass(n)}"`; + return `${esc(text)}`; + } + + reqLink(from: string, r: ReqEntry): string { + return `${esc(this.reqLabel(r))}`; + } + + description(n: ExplorerNode): string { + return esc(str(n.attrs[DOC_ATTR_DESCRIPTION]) ?? str(n.attrs[DOC_ATTR_SUMMARY]) ?? ""); + } + + topOf(r: ReqEntry): ReqEntry { + let t = r; + while (t.parent !== undefined) t = t.parent; + return t; + } + + // ─── S1: What it does ──────────────────────────────────────────────────────── + + overview(title: string): Record { + const ix = this.ix; + const from = PAGE_OVERVIEW; + const stored = ix.storedEntities(); + const abstract = ix.objects.filter((o) => ix.isEntity(o) && o.abstract === true).length; + const prompts = ix.templates.filter((t) => ix.isPrompt(t)); + const values = ix.objects.filter((o) => ix.kindOf(o) === "value").length; + const projections = ix.objects.filter((o) => ix.kindOf(o) === "projection").length; + const reports = ix.objects.filter((o) => ix.kindOf(o) === "report").length; + const counts = ix.doc.ledger?.counts ?? {}; + const num = (k: string): number | undefined => (typeof counts[k] === "number" ? (counts[k] as number) : undefined); + const features = ix.requirements.filter((r) => r.kind === REQUIREMENT_SUBTYPE_FUNCTIONAL).length; + const qualities = ix.requirements.filter((r) => r.kind === REQUIREMENT_SUBTYPE_ARCHITECTURAL).length; + const tiles: { n: string; l: string; href: string }[] = []; + tiles.push({ n: `${stored.length}`, l: `stored entities${abstract > 0 ? ` (${abstract} abstract base${abstract === 1 ? "" : "s"})` : ""}`, href: relHref(from, PAGE_MAP) }); + tiles.push({ n: `${prompts.length}`, l: `declared prompt${prompts.length === 1 ? "" : "s"}, ${count(values, "payload or reply type", "payload and reply types")}`, href: relHref(from, PAGE_PROMPTS) }); + if (ix.hasRequirements()) { + tiles.push({ n: `${num("total") ?? ix.requirements.length}`, l: `requirements: ${count(num("functional") ?? features, "feature")}, ${count(num("architectural") ?? qualities, "quality", "qualities")}`, href: relHref(from, PAGE_FEATURES) }); + } else { + tiles.push({ n: `${projections + reports}`, l: `read models: ${projections} projection${projections === 1 ? "" : "s"}, ${reports} report${reports === 1 ? "" : "s"}`, href: relHref(from, PAGE_MODEL) }); + } + const claimed = num("entitiesClaimed"); + const claimTotal = num("entitiesTotal"); + if (claimed !== undefined && claimTotal !== undefined && claimTotal > 0) { + tiles.push({ n: `${claimed}/${claimTotal}`, l: "entities claimed by a requirement (gate)", href: relHref(from, PAGE_GAPS) }); + } else { + tiles.push({ n: `${ix.packages.length}`, l: `package${ix.packages.length === 1 ? "" : "s"} in the model`, href: relHref(from, PAGE_MODEL) }); + } + if (ix.doc.api !== undefined) { + const routed = ix.objects.filter((o) => ix.routes(o.id).length > 0).length; + tiles.push({ n: `${routed}`, l: "objects with mounted REST routes", href: relHref(from, PAGE_API) }); + } else { + tiles.push({ n: "—", l: "REST routes: none recorded by this run", href: relHref(from, PAGE_API) }); + } + const genFiles = new Set((ix.doc.generated ?? []).flatMap((g) => g.files.map((f) => f.path))); + tiles.push({ + n: ix.doc.generated !== undefined ? `${genFiles.size}` : "—", + l: ix.doc.generated !== undefined ? "generated files traced to a node" : "generated files: no manifest read", + href: relHref(from, PAGE_TRACE), + }); + const sources = ["the effective model"]; + if (ix.doc.ledger !== undefined) sources.push("the requirement gate's ledger"); + if (ix.doc.generated !== undefined) sources.push("the generation manifest"); + return { + heading: esc(`What ${title} does`), + sourcesLine: esc(joinAnd(sources)), + tiles: tiles.map((t) => ({ n: esc(t.n), l: esc(t.l), href: esc(t.href) })), + hasRequirements: ix.hasRequirements(), + featureCards: ix.features().map((r) => this.capCard(from, r)), + qualityCards: ix.qualities().map((r) => this.capCard(from, r)), + hasFeatures: ix.features().length > 0, + hasQualities: ix.qualities().length > 0, + packageCards: ix.packages.map((p) => this.packageCard(from, p)), + }; + } + + private capCard(from: string, r: ReqEntry): Record { + const below = this.ix.below(r).length; + const claimed = this.ix.targetsOf(r).length; + return { + levelHtml: levelBadge(r), + linkHtml: this.reqLink(from, r), + statusHtml: statusChip(r.status), + verdictHtml: verdictBadge(r), + statement: esc(str(r.node.attrs[REQUIREMENT_ATTR_STATEMENT]) ?? ""), + barHtml: rollupBar(this.ix.rollup(r)), + countsLine: esc(`${below} requirement${below === 1 ? "" : "s"} below · ${claimed} model node${claimed === 1 ? "" : "s"} claimed`), + children: r.children.map((c) => ({ levelHtml: levelBadge(c), linkHtml: this.reqLink(from, c), glyphHtml: statusGlyph(c.status) })), + hasChildren: r.children.length > 0, + }; + } + + private packageCard(from: string, pkg: string): Record { + const members = [...this.ix.objects, ...this.ix.templates].filter((n) => n.package === pkg); + const kinds = new Map(); + for (const m of members) kinds.set(this.ix.kindOf(m), (kinds.get(this.ix.kindOf(m)) ?? 0) + 1); + const order = ["entity", "value", "projection", "report", "prompt", "template", "object"]; + const kindLine = order.filter((k) => kinds.has(k)).map((k) => `● ${kinds.get(k)} ${plural(k, kinds.get(k) ?? 0)}`).join(" · "); + const shown = members.slice(0, 12); + return { + name: esc(pkg === "" ? "(no package)" : pkg), + href: esc(relHref(from, this.packagePage(pkg))), + kindLineHtml: kindLine, + membersHtml: shown.map((m) => this.link(from, m.id)).join(", ") + (members.length > shown.length ? ` +${members.length - shown.length}` : ""), + }; + } + + // ─── S2: Model map ─────────────────────────────────────────────────────────── + + map(): Record { + const ix = this.ix; + const groups: { label: string; options: { value: string; label: string }[] }[] = []; + const base = [{ value: "", label: "Whole model" }]; + if (ix.templates.some((t) => ix.isPrompt(t))) base.push({ value: "@prompts", label: "Prompts and their payloads" }); + groups.push({ label: "Model", options: base }); + for (const top of ix.topRequirements) { + const opts: { value: string; label: string }[] = []; + for (const r of [top, ...top.children]) { + const s = ix.slice(`requirement:${r.node.id}`); + if (s === undefined) continue; + const depth = r === top ? "" : " "; + opts.push({ value: s.id, label: `${depth}${r.level !== undefined ? `L${r.level} ` : ""}${this.reqLabel(r)} (${ix.targetsOf(r).length})` }); + } + if (opts.length > 0) groups.push({ label: `${top.kind === REQUIREMENT_SUBTYPE_FUNCTIONAL ? "Feature" : "Quality"}: ${this.reqLabel(top)}`, options: opts }); + } + if (ix.packages.length > 1) groups.push({ label: "Package", options: ix.packages.map((p) => ({ value: `package:${p}`, label: p === "" ? "(no package)" : p })) }); + return { + sliceGroups: groups.map((g) => ({ label: esc(g.label), options: g.options.map((o) => ({ value: esc(o.value), label: esc(o.label) })) })), + hasRequirements: ix.hasRequirements(), + }; + } + + // ─── S3: Object page (entity, value, projection, report) ───────────────────── + + object(n: ExplorerNode): Record { + const ix = this.ix; + const from = this.objectPage(n); + const kind = ix.kindOf(n); + const claims = ix.claims(n.id); + const storage = ix.storage(n.id); + const storageLine = storage === undefined ? (kind === "value" ? "value object: no storage" : "no source") : `${storage.kind}${storage.name !== undefined ? ` ${storage.name}` : ""}`; + const fields = ix.fields(n.id).map((f) => this.fieldRow(from, n, f)); + const hasColumn = fields.some((f) => f.column !== ""); + const keys = ix.children(n.id).filter((c) => c.type === TYPE_IDENTITY || c.type === TYPE_INDEX || c.type === TYPE_RELATIONSHIP).map((c) => this.keyRow(from, c)); + const reporting = ix.children(n.id).filter((c) => c.type === TYPE_DIMENSION || c.type === TYPE_MEASURE || c.type === TYPE_SEGMENT).map((c) => this.reportingRow(from, c)); + const layouts = ix.children(n.id).filter((c) => c.type === TYPE_LAYOUT).map((l) => this.layoutRow(l)); + const isUnclaimed = this.unclaimedEntity(n); + const whyRows = this.whyRows(from, n); + const usedBy = ix.doc.edges.filter((e) => (e.kind === "payload" || e.kind === "reply") && e.to === n.id).map((e) => this.link(from, e.from)); + const refBy = [...new Set(ix.modelEdges().filter((e) => e.to === n.id && e.kind !== "payload" && e.kind !== "reply").map((e) => e.from))].sort(byCodeUnit).map((id) => this.link(from, id)); + const ext = n.extends !== undefined ? this.link(from, n.extends) : ""; + return { + id: esc(n.id), + name: esc(n.name), + kindLabel: esc(`object.${n.subType}`), + crumbsHtml: `Model › ${esc(n.package === "" ? "(no package)" : n.package)} › object.${esc(n.subType)}`, + isAbstract: n.abstract === true, + isUnclaimed, + isReadOnly: storage?.kind === "view", + kindBadge: esc(kind), + description: this.description(n), + storageLine: esc(storageLine), + extendsHtml: ext, + whyRows, + hasWhy: whyRows.length > 0, + whyEmptyHtml: this.whyEmpty(n), + fields, + hasFields: fields.length > 0, + hasColumn, + // The L5 column only where the model declares requirements to claim a field. + showClaims: this.ix.hasRequirements(), + keys, + hasKeys: keys.length > 0, + reporting, + hasReporting: reporting.length > 0, + layouts, + hasLayouts: layouts.length > 0, + projection: kind === "projection" ? this.projectionBlock(from, n) : undefined, + report: kind === "report" ? this.reportBlock(from, n) : undefined, + routes: ix.routes(n.id).map((r) => ({ method: esc(r.method), path: esc(r.path) })), + hasRoutes: ix.routes(n.id).length > 0, + routesNote: ix.doc.api === undefined ? "This run recorded no routes." : "No generated routes are mounted for it.", + usedByHtml: usedBy.join(", "), + referencedByHtml: refBy.join(", "), + generated: this.generatedGroups(n.id), + hasGenerated: ix.generated(n.id).length > 0, + generatedNote: ix.doc.generated === undefined ? "No generation manifest was read." : "Nothing generated is traced to it.", + claimCount: claims.length, + }; + } + + /** A concrete entity that no requirement claims, once any requirement claims anything. That + * is the gate's object coverage (`WARN_REQUIREMENT_OBJECT_UNCLAIMED`), which only activates + * when the project's requirements claim something. */ + unclaimedEntity(n: ExplorerNode): boolean { + const ix = this.ix; + if (!ix.isEntity(n) || n.abstract === true) return false; + const diags = ix.doc.ledger?.diagnostics ?? []; + return diags.some((d) => isDiag(d) && d.code === CODE_OBJECT_UNCLAIMED && d.node === n.id); + } + + private whyRows(from: string, n: ExplorerNode): Record[] { + const claims = [...this.ix.claims(n.id)].sort((a, b) => this.ix.requirements.indexOf(a.req) - this.ix.requirements.indexOf(b.req)); + const rows: Record[] = []; + let lastTop: ReqEntry | undefined; + for (const c of claims) { + const top = this.topOf(c.req); + rows.push({ + capabilityHtml: top === lastTop ? "" : this.reqLink(from, top), + reqHtml: this.reqLink(from, c.req), + statement: esc(str(c.req.node.attrs[REQUIREMENT_ATTR_STATEMENT]) ?? ""), + levelHtml: levelBadge(c.req), + statusHtml: statusChip(c.req.status), + onHtml: c.member !== undefined ? `${esc(c.member)}` : `whole object`, + }); + lastTop = top; + } + return rows; + } + + private whyEmpty(n: ExplorerNode): string { + if (!this.ix.hasRequirements()) return `This model declares no requirements, so nothing states why ${esc(n.name)} exists.`; + if (this.unclaimedEntity(n)) return `No requirement claims ${esc(n.name)}. The gate reports this as ${CODE_OBJECT_UNCLAIMED}.`; + return `No requirement claims ${esc(n.name)}.`; + } + + private fieldRow(from: string, owner: ExplorerNode, f: ExplorerNode): { [k: string]: unknown; column: string } { + const ref = str(f.attrs[FIELD_ATTR_OBJECT_REF]); + const values = f.subType === FIELD_SUBTYPE_ENUM ? strs(f.attrs[FIELD_ATTR_VALUES]) : []; + const flags: string[] = []; + if (f.attrs[FIELD_ATTR_REQUIRED] === true) flags.push("required"); + if (f.attrs[FIELD_ATTR_FILTERABLE] === true) flags.push("filterable"); + if (f.attrs[FIELD_ATTR_SORTABLE] === true) flags.push("sortable"); + if (f.attrs[FIELD_ATTR_MAX_LENGTH] !== undefined) flags.push(`max ${String(f.attrs[FIELD_ATTR_MAX_LENGTH])}`); + if (f.attrs[FIELD_ATTR_DEFAULT] !== undefined) flags.push(`default ${fmt(f.attrs[FIELD_ATTR_DEFAULT])}`); + if (this.ix.children(f.id).some((c) => c.type === TYPE_ORIGIN)) flags.push("derived"); + const claims = this.ix.claims(owner.id).filter((c) => c.member !== undefined && c.member.split(".")[0] === f.name); + return { + anchor: esc(memberAnchor(f)), + name: esc(f.name), + inheritedHtml: f.inheritedFrom !== undefined ? `inherited` : "", + type: esc(`${f.subType}${f.isArray === true ? "[]" : ""}`), + refHtml: ref !== undefined ? ` → ${this.link(from, ref)}` : "", + valuesHtml: values.length > 0 ? `
${values.map(esc).join(" | ")}
` : "", + column: esc(str(f.attrs[FIELD_ATTR_COLUMN]) ?? ""), + flagsHtml: flags.map((x) => `${esc(x)}`).join(""), + claimsHtml: claims.map((c) => `${statusGlyph(c.req.status)} ${esc(this.reqLabel(c.req))}`).join("
"), + }; + } + + private keyRow(from: string, c: ExplorerNode): Record { + const fields = strs(c.attrs[IDENTITY_ATTR_FIELDS]); + const target = str(c.attrs[IDENTITY_REFERENCE_ATTR_REFERENCES]) ?? str(c.attrs[RELATIONSHIP_ATTR_OBJECT_REF]); + const through = str(c.attrs[RELATIONSHIP_ATTR_THROUGH]); + const card = str(c.attrs[RELATIONSHIP_ATTR_CARDINALITY]); + const shape = c.type === TYPE_RELATIONSHIP ? [card, through !== undefined ? `through ${localName(through)}` : undefined].filter((x) => x !== undefined).join(", ") : `(${fields.join(", ")})`; + return { + anchor: esc(memberAnchor(c)), + kind: esc(`${c.type}.${c.subType}`), + name: esc(c.name), + inheritedHtml: c.inheritedFrom !== undefined ? `inherited` : "", + shape: esc(shape), + targetHtml: target !== undefined ? `→ ${this.link(from, target)}` : "", + }; + } + + private reportingRow(from: string, c: ExplorerNode): Record { + const a = c.attrs; + const parts: string[] = []; + const agg = str(a[REPORTING_ATTR_AGG]); + const of = strs(a[REPORTING_ATTR_OF]); + if (agg !== undefined) parts.push(`${esc(agg)}(${of.map((o) => this.link(from, o, localName(o))).join(", ")})`); + else if (of.length > 0) parts.push(`of ${of.map((o) => this.link(from, o, localName(o))).join(", ")}`); + const num = str(a[REPORTING_ATTR_NUMERATOR]); + const den = str(a[REPORTING_ATTR_DENOMINATOR]); + if (num !== undefined && den !== undefined) parts.push(`${esc(num)} ÷ ${esc(den)}`); + const via = str(a[REPORTING_ATTR_VIA]); + if (via !== undefined) parts.push(`via ${esc(localName(via))}`); + const grains = strs(a[REPORTING_ATTR_GRAINS]); + if (grains.length > 0) parts.push(`grains ${grains.map((g) => `${esc(g)}`).join(" ")}`); + const seg = str(a[REPORTING_ATTR_SEGMENT]); + if (seg !== undefined) parts.push(`segment ${esc(seg)}`); + if (a[REPORTING_ATTR_FILTER] !== undefined) parts.push(`filter ${esc(fmt(a[REPORTING_ATTR_FILTER]))}`); + if (a[REPORTING_ATTR_DEFAULT] !== undefined) parts.push(`default ${esc(fmt(a[REPORTING_ATTR_DEFAULT]))}`); + return { anchor: esc(memberAnchor(c)), kind: esc(`${c.type}.${c.subType}`), name: esc(c.name), definitionHtml: parts.join(" · ") }; + } + + private layoutRow(l: ExplorerNode): Record { + const parts: string[] = []; + const cols = strs(l.attrs[LAYOUT_DATA_GRID_ATTR_COLUMNS]); + if (cols.length > 0) parts.push(`columns ${cols.map((c) => `${esc(c)}`).join(", ")}`); + const sort = str(l.attrs[LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_FIELD]); + if (sort !== undefined) parts.push(`sorted by ${esc(sort)}${str(l.attrs[LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_ORDER]) !== undefined ? ` ${esc(str(l.attrs[LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_ORDER]))}` : ""}`); + if (typeof l.attrs[LAYOUT_DATA_GRID_ATTR_PAGE_SIZE] === "number") parts.push(`${l.attrs[LAYOUT_DATA_GRID_ATTR_PAGE_SIZE]} per page`); + return { kind: esc(`${l.type}.${l.subType}`), name: esc(l.name), detailHtml: parts.join(", ") }; + } + + private projectionBlock(from: string, n: ExplorerNode): Record { + const ix = this.ix; + const rows: Record[] = []; + const bases = new Set(); + for (const f of ix.fields(n.id)) { + if (f.extends !== undefined) bases.add(ix.ownerOf(f.extends)); + for (const o of ix.children(f.id).filter((c) => c.type === TYPE_ORIGIN)) { + const def = this.originDefinition(from, o); + const src = str(o.attrs[ORIGIN_PASSTHROUGH_ATTR_FROM]); + if (src !== undefined && ix.byId.has(src)) bases.add(ix.ownerOf(src)); + rows.push({ field: esc(f.name), origin: esc(`origin.${o.subType}`), definitionHtml: def }); + } + } + return { + basesHtml: [...bases].filter((b) => b !== n.id).sort(byCodeUnit).map((b) => this.link(from, b)).join(", "), + origins: rows, + hasOrigins: rows.length > 0, + }; + } + + private originDefinition(from: string, o: ExplorerNode): string { + const a = o.attrs; + const ref = (v: unknown): string => { + const s = str(v); + return s === undefined ? "" : this.ix.byId.has(s) ? this.link(from, s, localName(s)) : `${esc(localName(s))}`; + }; + const parts: string[] = []; + const agg = str(a[ORIGIN_AGGREGATE_ATTR_AGG]); + if (agg !== undefined) parts.push(`${esc(agg)}(${ref(a[ORIGIN_AGGREGATE_ATTR_OF])})`); + else if (a[ORIGIN_AGGREGATE_ATTR_OF] !== undefined) parts.push(`of ${ref(a[ORIGIN_AGGREGATE_ATTR_OF])}`); + if (a[ORIGIN_PASSTHROUGH_ATTR_FROM] !== undefined) parts.push(`from ${ref(a[ORIGIN_PASSTHROUGH_ATTR_FROM])}`); + if (a[ORIGIN_AGGREGATE_ATTR_VIA] !== undefined) parts.push(`via ${esc(localName(String(a[ORIGIN_AGGREGATE_ATTR_VIA])))}`); + if (a[ORIGIN_COMPUTED_ATTR_EXPR] !== undefined) parts.push(`${esc(fmt(a[ORIGIN_COMPUTED_ATTR_EXPR]))}`); + if (a[ORIGIN_AGGREGATE_ATTR_FILTER] !== undefined) parts.push(`filter ${esc(fmt(a[ORIGIN_AGGREGATE_ATTR_FILTER]))}`); + return parts.join(" · "); + } + + private reportBlock(from: string, n: ExplorerNode): Record { + const a = n.attrs; + const fromId = str(a[OBJECT_REPORT_ATTR_FROM]); + const resolve = (name: string, kindType: string): string => { + const candidates = [fromId !== undefined ? `${fromId}.${name}` : undefined, `${n.package === "" ? "" : `${n.package}::`}${name}`].filter((x): x is string => x !== undefined); + const hit = candidates.find((c) => this.ix.byId.get(c)?.type === kindType || (this.ix.byId.has(c) && name.includes("."))); + return hit !== undefined ? this.link(from, hit, name) : `${esc(name)}`; + }; + const spine = str(a[OBJECT_REPORT_ATTR_SPINE]); + return { + fromHtml: fromId !== undefined ? this.link(from, fromId) : "", + spineHtml: spine !== undefined ? `${esc(spine)}` : "", + dimensionsHtml: strs(a[OBJECT_REPORT_ATTR_DIMENSIONS]).map((d) => resolve(d, TYPE_DIMENSION)).join(", "), + measuresHtml: strs(a[OBJECT_REPORT_ATTR_MEASURES]).map((m) => resolve(m, TYPE_MEASURE)).join(", "), + segmentHtml: strs(a[OBJECT_REPORT_ATTR_SEGMENT]).map((s) => resolve(s, TYPE_SEGMENT)).join(", "), + filterHtml: a[OBJECT_REPORT_ATTR_FILTER] !== undefined ? `${esc(fmt(a[OBJECT_REPORT_ATTR_FILTER]))}` : "", + }; + } + + private generatedGroups(id: string): Record[] { + const groups = new Map(); + for (const f of this.ix.generated(id)) groups.set(f.generatedBy, [...(groups.get(f.generatedBy) ?? []), f.path]); + return [...groups.keys()].sort(byCodeUnit).map((g) => ({ generator: esc(g), files: (groups.get(g) ?? []).map((p) => esc(p)) })); + } + + // ─── S4: Prompt (and other template) page ──────────────────────────────────── + + prompt(n: ExplorerNode, text: string | undefined): Record { + const ix = this.ix; + const from = this.objectPage(n); + const payloadId = str(n.attrs[TEMPLATE_ATTR_PAYLOAD_REF]); + const replyId = str(n.attrs[TEMPLATE_ATTR_RESPONSE_REF]); + const vars = text !== undefined ? templateVariables(text) : undefined; + const payload = payloadId !== undefined ? ix.byId.get(payloadId) : undefined; + const reply = replyId !== undefined ? ix.byId.get(replyId) : undefined; + const payloadRows = payload !== undefined ? ix.fields(payload.id).map((f) => ({ + name: esc(f.name), + requiredHtml: f.attrs[FIELD_ATTR_REQUIRED] === true ? `required` : "", + type: esc(`${f.subType}${f.isArray === true ? "[]" : ""}`), + refHtml: str(f.attrs[FIELD_ATTR_OBJECT_REF]) !== undefined ? ` → ${this.link(from, str(f.attrs[FIELD_ATTR_OBJECT_REF])!)}` : "", + usedHtml: vars === undefined ? `text not read` : vars.has(f.name) ? "yes" : `no`, + })) : []; + const replyRows = reply !== undefined ? ix.fields(reply.id).map((f) => ({ + name: esc(f.name), + type: esc(`${f.subType}${f.isArray === true ? "[]" : ""}`), + valuesHtml: f.subType === FIELD_SUBTYPE_ENUM ? `
${strs(f.attrs[FIELD_ATTR_VALUES]).map(esc).join(" | ")}
` : "", + })) : []; + const textRef = str(n.attrs[TEMPLATE_ATTR_TEXT_REF]) ?? ""; + const lines = text !== undefined ? text.replace(/\n$/, "").split("\n").length : 0; + const attrs = [TEMPLATE_ATTR_FORMAT, TEMPLATE_ATTR_RESPONSE_FORMAT, TEMPLATE_ATTR_PROMPT_STYLE, TEMPLATE_ATTR_MODEL, TEMPLATE_ATTR_MAX_TOKENS] + .filter((k) => n.attrs[k] !== undefined) + .map((k) => `@${esc(k)} ${esc(fmt(n.attrs[k]))}`) + .join(""); + const claimed = ix.claims(n.id); + const whyRows = this.whyRows(from, n); + return { + name: esc(n.name), + kindLabel: esc(`template.${n.subType}`), + crumbsHtml: `Prompts › ${esc(n.package === "" ? "(no package)" : n.package)} › template.${esc(n.subType)}`, + isUnclaimed: ix.hasClaims() && claimed.length === 0, + description: this.description(n), + attrsHtml: attrs, + payloadHtml: payload !== undefined ? this.link(from, payload.id) : "none", + payloadRows, + hasPayload: payload !== undefined, + replyHtml: reply !== undefined ? this.link(from, reply.id) : "none", + replyRows, + hasReply: reply !== undefined, + textRef: esc(textRef), + textMeta: esc(text !== undefined ? `${textRef}.mustache · ${lines} line${lines === 1 ? "" : "s"}${str(n.attrs[TEMPLATE_ATTR_FORMAT]) !== undefined ? ` · ${str(n.attrs[TEMPLATE_ATTR_FORMAT])}` : ""}` : `${textRef}.mustache`), + textHtml: text !== undefined ? highlightVariables(text) : "", + hasText: text !== undefined, + generated: this.generatedGroups(n.id), + hasGenerated: ix.generated(n.id).length > 0, + generatedNote: ix.doc.generated === undefined ? "No generation manifest was read." : "Nothing generated is traced to it.", + whyRows, + hasWhy: whyRows.length > 0, + whyEmptyHtml: ix.hasRequirements() ? `No requirement claims ${esc(n.name)}. The gate counts entity coverage only, so this shows here and on the gaps page, not in meta verify.` : `This model declares no requirements.`, + }; + } + + // ─── Prompt index ──────────────────────────────────────────────────────────── + + prompts(): Record { + const from = PAGE_PROMPTS; + const rows = this.ix.templates.map((p) => { + const payload = str(p.attrs[TEMPLATE_ATTR_PAYLOAD_REF]); + const reply = str(p.attrs[TEMPLATE_ATTR_RESPONSE_REF]); + const claims = this.ix.claims(p.id); + return { + linkHtml: this.link(from, p.id), + kind: esc(`template.${p.subType}`), + description: this.description(p), + payloadHtml: payload !== undefined ? this.link(from, payload) : `none`, + replyHtml: reply !== undefined ? this.link(from, reply) : `none (outbound only)`, + claimsHtml: claims.length > 0 ? claims.map((c) => statusChip(c.req.status)).join("") : this.ix.hasClaims() ? `unclaimed` : `—`, + }; + }); + return { rows, hasRows: rows.length > 0 }; + } + + // ─── S5: Requirements tree ─────────────────────────────────────────────────── + + requirements(kind: string, page: string): Record { + const tops = this.ix.topRequirements.filter((r) => r.kind === kind); + const treeHtml = tops.length > 0 ? `
    ${tops.map((r) => this.treeNode(page, r)).join("")}
` : ""; + return { + isFeatures: kind === REQUIREMENT_SUBTYPE_FUNCTIONAL, + featuresHref: esc(relHref(page, PAGE_FEATURES)), + qualitiesHref: esc(relHref(page, PAGE_QUALITIES)), + featureCount: this.ix.requirements.filter((r) => r.kind === REQUIREMENT_SUBTYPE_FUNCTIONAL).length, + qualityCount: this.ix.requirements.filter((r) => r.kind === REQUIREMENT_SUBTYPE_ARCHITECTURAL).length, + treeHtml, + hasTree: tops.length > 0, + emptyNote: kind === REQUIREMENT_SUBTYPE_FUNCTIONAL ? "No requirement.functional is declared." : "No requirement.architectural is declared.", + }; + } + + private treeNode(from: string, r: ReqEntry): string { + const links = (r.row?.implementedBy ?? []).map((l) => { + if (l.target !== undefined && this.ix.byId.has(l.target)) return `${this.link(from, l.target, l.ref)}`; + return r.status === REQUIREMENT_STATUS_PLANNED + ? `${esc(l.ref)} (not yet)` + : `${esc(l.ref)} (dangling)`; + }); + const bar = r.children.length > 0 ? `${rollupBar(this.ix.rollup(r))}` : ""; + const row = `
${levelBadge(r)}${this.reqLink(from, r)} ${statusChip(r.status)}${dispositionPill(r)}${verdictBadge(r)}${bar}${links.join("")}
${esc(str(r.node.attrs[REQUIREMENT_ATTR_STATEMENT]) ?? "")}
`; + return `
  • ${row}${r.children.length > 0 ? `
      ${r.children.map((c) => this.treeNode(from, c)).join("")}
    ` : ""}
  • `; + } + + // ─── S6: Requirement page ──────────────────────────────────────────────────── + + requirement(r: ReqEntry): Record { + const ix = this.ix; + const from = this.reqPage(r); + const chain: ReqEntry[] = []; + for (let x: ReqEntry | undefined = r; x !== undefined; x = x.parent) chain.unshift(x); + const tab = r.kind === REQUIREMENT_SUBTYPE_FUNCTIONAL ? { href: PAGE_FEATURES, label: "Features" } : { href: PAGE_QUALITIES, label: "Qualities" }; + const row = r.row; + const links = row?.implementedBy ?? []; + const linkRows = links.map((l) => { + const target = l.target !== undefined ? ix.byId.get(l.target) : undefined; + return { + refHtml: target !== undefined ? this.link(from, target.id, l.ref) : `${esc(l.ref)}`, + kind: esc(target !== undefined ? `${target.type}.${target.subType}` : ""), + resolvesHtml: l.resolves + ? `resolves` + : r.status === REQUIREMENT_STATUS_PLANNED + ? `not yet (allowed on planned)` + : `dangling`, + }; + }); + const findings = (row?.diagnostics ?? []).filter((d) => d.source === GATE_SOURCE); + const advisories = (row?.diagnostics ?? []).filter((d) => d.source !== GATE_SOURCE); + const levelFinding = row?.diagnostics.some((d) => LEVEL_RULE_CODES.has(d.code)) ?? false; + const testable = r.kind === REQUIREMENT_SUBTYPE_FUNCTIONAL && (r.level ?? 0) >= REQUIREMENT_LEVEL_OBJECT; + const linksOk = links.length === 0 ? "none" : links.every((l) => l.resolves) ? "yes" : "no"; + const below = ix.below(r); + const slice = ix.slice(`requirement:${r.node.id}`); + let traceStart: ReqEntry | undefined = r; + while (traceStart !== undefined && (traceStart.level ?? 0) > REQUIREMENT_LEVEL_SERVICE) traceStart = traceStart.parent; + const tracked = strs(r.node.attrs[REQUIREMENT_ATTR_TRACKED_BY]); + const desc = this.description(r.node); + return { + crumbsHtml: `${tab.label} › ${chain.map((x) => (x === r ? esc(this.reqLabel(x)) : this.reqLink(from, x))).join(" › ")}`, + levelHtml: levelBadge(r), + name: esc(this.reqLabel(r)), + statusHtml: statusChip(r.status), + dispositionHtml: dispositionPill(r), + verdictHtml: verdictBadge(r), + address: esc(`${row?.qualifiedPath ?? r.node.id} · requirement.${r.kind}`), + statement: esc(str(r.node.attrs[REQUIREMENT_ATTR_STATEMENT]) ?? ""), + counterexample: esc(str(r.node.attrs[REQUIREMENT_ATTR_COUNTEREXAMPLE]) ?? ""), + description: desc, + hasDescription: desc !== "", + linkRows, + hasLinks: linkRows.length > 0, + linksEmpty: r.level !== undefined && r.level < REQUIREMENT_LEVEL_OBJECT ? "Levels 1 to 3 never name the model." : "Names nothing.", + belowCount: below.length, + childCount: r.children.length, + deeper: below.length > r.children.length, + hasBelow: r.children.length > 0, + belowBarHtml: rollupBar(ix.rollup(r)), + children: r.children.map((c) => ({ levelHtml: levelBadge(c), linkHtml: this.reqLink(from, c), statusHtml: statusChip(c.status) })), + sliceHref: slice !== undefined ? esc(`${relHref(from, PAGE_MAP)}?slice=${encodeURIComponent(slice.id)}`) : "", + traceHref: traceStart !== undefined ? esc(`${relHref(from, PAGE_TRACE)}?start=${encodeURIComponent(traceStart.node.id)}`) : "", + proof: [ + { what: "Loads", verdictHtml: `yes`, how: "The loader enforces the closed @status enum and the required statement and counterexample." }, + { + what: "Links resolve", + verdictHtml: linksOk === "none" ? `no links` : linksOk === "yes" ? `yes` : `no`, + how: "ERR_REQUIREMENT_DANGLING_REF fails the build on live or partial; a planned entry may point ahead.", + }, + { what: "Level rules", verdictHtml: levelFinding ? `no` : `yes`, how: "Nesting agrees with level, and links sit only at L4 and L5." }, + { + what: "Gate findings", + verdictHtml: findings.length > 0 ? `${findings.length}` : row !== undefined ? `none` : `not read`, + how: findings.length > 0 ? findings.map((d) => `${esc(d.code)} ${esc(d.message)}`).join("
    ") : row !== undefined ? "Nothing reported for this entry." : "This build read no meta verify output.", + }, + { + what: "Authoring advisories", + verdictHtml: row !== undefined ? `${advisories.length}` : `not read`, + how: advisories.length > 0 + ? advisories.map((d) => `${esc(d.code)} ${esc(d.message)}`).join("
    ") + : row !== undefined ? "None. The authoring lint never fails a build." : "This build read no meta verify output.", + }, + { + what: "Test exists", + verdictHtml: testable ? `by generator` : `not by default`, + how: testable + ? "The stock requirement-tests generator writes a test for this entry, and the codegen drift gate holds the test set to the ledger." + : "The stock requirement-tests generator selects functional L4 and L5 entries only.", + }, + { what: "Test passes", verdictHtml: `not read`, how: "verify never reads test results, so nothing here is coloured by one." }, + { what: "Status is true", verdictHtml: `unprovable`, how: "No gate can prove the status. The colour is the author's claim, held to referential integrity." }, + ].map((p) => ({ what: esc(p.what), verdictHtml: p.verdictHtml, howHtml: p.how })), + trackedHtml: tracked.map((t) => `${esc(t)}`).join(" "), + digest: esc(row?.digest ?? ""), + }; + } + + // ─── S7: Matrix ────────────────────────────────────────────────────────────── + + matrix(): Record { + const ix = this.ix; + const from = PAGE_MATRIX; + // Stored entities and prompts, plus anything else a row claims (an abstract base a policy + // names), so no claim lands outside the grid. + const claimed = ix.requirements.flatMap((r) => (r.row?.implementedBy ?? []).flatMap((l) => (l.target !== undefined ? [ix.ownerOf(l.target)] : []))); + const colSet = new Set([...ix.storedEntities(), ...ix.templates.filter((t) => ix.isPrompt(t))].map((n) => n.id)); + for (const id of claimed) if (ix.byId.get(id)?.parent === undefined && ix.byId.get(id)?.type !== TYPE_REQUIREMENT) colSet.add(id); + const cols = [...colSet].map((id) => ix.byId.get(id)!).sort( + (a, b) => ix.claims(b.id).length - ix.claims(a.id).length || byCodeUnit(a.name, b.name) || byCodeUnit(a.id, b.id), + ); + const head = `${cols.map((o) => `
    ${this.link(from, o.id)}
    `).join("")}`; + const rows: string[] = []; + for (const top of ix.topRequirements) { + const leaves = ix.subtree(top).filter((x) => (x.row?.implementedBy.length ?? 0) > 0); + if (leaves.length === 0) continue; + rows.push(`${top.kind === REQUIREMENT_SUBTYPE_FUNCTIONAL ? "Feature" : "Quality"}: ${this.reqLink(from, top)}${cols.map(() => ``).join("")}`); + for (const r of leaves) { + const tg = new Set((r.row?.implementedBy ?? []).filter((l) => l.target !== undefined).map((l) => ix.ownerOf(l.target!))); + const label = this.reqPath(r).split(".").slice(1).join(".") || this.reqLabel(r); + rows.push( + `${levelBadge(r)}${esc(label)}${cols + .map((o) => (tg.has(o.id) ? `${statusGlyph(r.status)}` : ``)) + .join("")}`, + ); + } + } + return { tableHtml: rows.length > 0 ? `${head}${rows.join("")}
    ` : "", hasRows: rows.length > 0, hasRequirements: ix.hasRequirements() }; + } + + // ─── S8: Trace ─────────────────────────────────────────────────────────────── + + trace(): Record { + const starts = this.ix.requirements.filter((r) => r.level !== undefined && r.level <= REQUIREMENT_LEVEL_SERVICE); + const fallback = starts.length === 0 ? this.ix.topRequirements : []; + return { + options: [...starts, ...fallback].map((r) => ({ value: esc(r.node.id), label: esc(`${" ".repeat(Math.max(0, (r.level ?? 1) - 1))}${r.level !== undefined ? `L${r.level} ` : ""}${this.reqLabel(r)}`) })), + hasStarts: starts.length + fallback.length > 0, + hasUsage: false, + }; + } + + // ─── S9: Gaps ──────────────────────────────────────────────────────────────── + + gaps(): Record { + const ix = this.ix; + const from = PAGE_GAPS; + const cards: Record[] = []; + const card = (title: string, count: number | string, source: string, why: string, bodyHtml: string): void => { + cards.push({ title: esc(title), count: esc(String(count)), source: esc(source), isGate: source === "meta verify", whyHtml: why, bodyHtml }); + }; + if (ix.doc.ledger !== undefined) { + // Each requirement's own findings come from its ledger row, which the gate scoped to that + // node; the flat list contributes only the findings with no requirement subject. + const findingsOf = (gate: boolean): { req?: ReqEntry; d: { code: string; severity: string; message: string } }[] => [ + ...ix.requirements.flatMap((req) => (req.row?.diagnostics ?? []).filter((d) => (d.source === GATE_SOURCE) === gate).map((d) => ({ req, d }))), + ...(ix.doc.ledger?.diagnostics ?? []).filter((d): d is Diag => isDiag(d) && d.path === "" && (d.source === GATE_SOURCE) === gate).map((d) => ({ d })), + ]; + const table = (rows: ReturnType, chip: (severity: string) => string): string => + rows.length === 0 + ? `
    None.
    ` + : `${rows.map(({ req, d }) => ``).join("")}
    ${esc(d.code)}${req !== undefined ? this.reqLink(from, req) : ""}
    ${esc(d.message)}
    `; + const gate = findingsOf(true); + card("Gate findings", gate.length, "meta verify", "Printed by every run of the requirement gate.", table(gate, (sev) => `chip v-${sev === "error" ? "error" : "warn"}`)); + const advisories = findingsOf(false); + card("Authoring advisories", advisories.length, "authoring lint", "Printed beside the gate's findings, and never able to fail a build.", table(advisories, () => "chip")); + const unclaimed = ix.storedEntities().filter((e) => this.unclaimedEntity(e)); + card("Entities no requirement claims", unclaimed.length, "meta verify", "The gate's coverage warning. Each is a capability nobody has written down.", unclaimed.length > 0 ? unclaimed.map((e) => this.link(from, e.id)).join(", ") : `
    None.
    `); + const outstanding = (r: ReqEntry): boolean => (REQUIREMENT_STATUSES_WITH_OUTSTANDING_WORK as readonly string[]).includes(r.status); + const undecided = ix.requirements.filter((r) => outstanding(r) && r.node.attrs[REQUIREMENT_ATTR_DISPOSITION] === undefined && !ix.below(r).some(outstanding)); + const counted = ix.doc.ledger.counts["undecided"]; + card("Undecided gaps", typeof counted === "number" ? counted : "—", "meta verify", "A partial or planned entry with no @disposition and no outstanding work below it: a gap nobody has ruled on. A roll-up parent is left out, since a ruling on it settles nothing. The gate counts these.", list(undecided.map((r) => `${levelBadge(r)}${this.reqLink(from, r)} ${statusChip(r.status)}`))); + } + if (ix.hasRequirements()) { + const behind = ix.requirements.filter((r) => r.status === REQUIREMENT_STATUS_PLANNED && ix.below(r).some((x) => x.status === REQUIREMENT_STATUS_LIVE)); + card("Planned, with live work below", behind.length, "explorer check", "L1 to L3 status is written by hand and never rolled up. Either the parent should say partial, or it is right and its children are ahead of it.", list(behind.map((r) => `${levelBadge(r)}${this.reqLink(from, r)} ${statusChip(r.status)} ${rollupBar(ix.rollup(r))}`))); + const prompts = ix.templates.filter((t) => ix.isPrompt(t) && ix.claims(t.id).length === 0); + card("Prompts no requirement claims", prompts.length, "explorer check", "The gate counts entity coverage only. An L4 may claim a prompt.", prompts.length > 0 ? prompts.map((p) => this.link(from, p.id)).join(", ") : `
    None.
    `); + } + card("Generated modules nothing imports", "—", "explorer check", "Needs a scan of hand-written imports, which this build does not run.", `
    Not scanned.
    `); + const implied = ix.storedEntities().flatMap((e) => { + const declared = new Set(ix.children(e.id).filter((c) => c.type === TYPE_IDENTITY && c.attrs[IDENTITY_REFERENCE_ATTR_REFERENCES] !== undefined).flatMap((c) => strs(c.attrs[IDENTITY_ATTR_FIELDS]))); + return ix.fields(e.id).filter((f) => /[a-z]Id$/.test(f.name) && !declared.has(f.name)); + }); + card("Fields that look like references but are not declared", implied.length, "explorer check", "A field named like a foreign key with no identity.reference: the database does not enforce it and the map draws no line. Some are deliberate.", list(implied.map((f) => `${this.link(from, f.id)} ${esc(f.subType)}`))); + if (ix.templates.length > 0) { + const unused = this.unusedPayload(); + card("Payload fields the prompt text never uses", unused === undefined ? "—" : unused.length, "explorer check", "The template gate fails a variable the payload lacks; this is the other direction, payload bloat. Nested sections are not followed, so treat each as a lead.", unused === undefined ? `
    No prompt text was read.
    ` : list(unused.map(({ p, f }) => `${this.link(from, p.id)}: ${this.link(from, f.id, f.name)}`))); + } + return { cards }; + } + + /** Set by the site builder: prompt id → text, for the gaps page's payload check. */ + texts = new Map(); + + private unusedPayload(): { p: ExplorerNode; f: ExplorerNode }[] | undefined { + const out: { p: ExplorerNode; f: ExplorerNode }[] = []; + let read = false; + for (const p of this.ix.templates) { + const text = this.texts.get(p.id); + const payload = str(p.attrs[TEMPLATE_ATTR_PAYLOAD_REF]); + if (text === undefined || payload === undefined) continue; + read = true; + const vars = templateVariables(text); + for (const f of this.ix.fields(payload)) if (!vars.has(f.name)) out.push({ p, f }); + } + return read ? out : undefined; + } + + // ─── API index ─────────────────────────────────────────────────────────────── + + api(): Record { + const ix = this.ix; + const from = PAGE_API; + const groups = ix.objects.filter((o) => ix.routes(o.id).length > 0).map((o) => ({ + linkHtml: this.link(from, o.id), + kind: esc(ix.kindOf(o)), + isReadOnly: ix.storage(o.id)?.kind === "view", + routes: ix.routes(o.id).map((r) => ({ method: esc(r.method), methodClass: esc(r.method.toLowerCase()), path: esc(r.path) })), + })); + const total = groups.reduce((a, g) => a + g.routes.length, 0); + return { + groups, + hasGroups: groups.length > 0, + recorded: ix.doc.api !== undefined, + summary: esc(`${total} route${total === 1 ? "" : "s"} on ${groups.length} object${groups.length === 1 ? "" : "s"}`), + }; + } + + // ─── Model index and package pages ─────────────────────────────────────────── + + model(): Record { + return { packageCards: this.ix.packages.map((p) => this.packageCard(PAGE_MODEL, p)) }; + } + + packageIndex(pkg: string): Record { + const ix = this.ix; + const from = this.packagePage(pkg); + const rows = [...ix.objects, ...ix.templates].filter((n) => n.package === pkg).map((n) => { + const s = ix.storage(n.id); + const st = ix.claimStatus(n.id); + return { + linkHtml: this.link(from, n.id), + kind: esc(`${n.type}.${n.subType}`), + abstractHtml: n.abstract === true ? `abstract` : "", + storage: esc(s === undefined ? "" : `${s.kind}${s.name !== undefined ? ` ${s.name}` : ""}`), + fieldCount: ix.fields(n.id).length, + statusHtml: st === "unclaimed" ? (this.unclaimedEntity(n) ? `unclaimed` : "") : statusChip(st), + description: this.description(n), + }; + }); + return { name: esc(pkg === "" ? "(no package)" : pkg), crumbsHtml: `Model › ${esc(pkg === "" ? "(no package)" : pkg)}`, rows }; + } +} + +// ─── Small helpers ─────────────────────────────────────────────────────────────────────────── + +interface Diag { + code: string; + path: string; + severity: string; + source?: string; + /** The entity an object-coverage finding is about; "" or absent otherwise. */ + node?: string; + message: string; +} + +function isDiag(d: unknown): d is Diag { + if (typeof d !== "object" || d === null) return false; + const r = d as Record; + return typeof r["code"] === "string" && typeof r["path"] === "string" && typeof r["message"] === "string" && typeof r["severity"] === "string"; +} + +function list(items: string[]): string { + return items.length === 0 ? `
    None.
    ` : `
      ${items.map((i) => `
    • ${i}
    • `).join("")}
    `; +} + +function fmt(v: unknown): string { + return typeof v === "string" ? v : JSON.stringify(v); +} + +function joinAnd(xs: string[]): string { + return xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`; +} + +function count(n: number, one: string, many = `${one}s`): string { + return `${n} ${n === 1 ? one : many}`; +} + +function plural(kind: string, n: number): string { + if (n === 1) return kind; + return kind === "entity" ? "entities" : `${kind}s`; +} + +const TAG = /\{\{\s*([#^/&{]?)\s*([A-Za-z_][\w.]*)\s*\}?\}\}/g; + +/** The first path segment of every variable a Mustache text names (`{{a.b}}`, `{{#a}}`). */ +export function templateVariables(text: string): Set { + const out = new Set(); + for (const m of text.matchAll(TAG)) if (m[1] !== "/") out.add(m[2]!.split(".")[0]!); + return out; +} + +/** The text, escaped, with every tag picked out. */ +export function highlightVariables(text: string): string { + let out = ""; + let last = 0; + for (const m of text.matchAll(/\{\{[^}]*\}?\}\}/g)) { + out += esc(text.slice(last, m.index)); + out += `${esc(m[0])}`; + last = m.index! + m[0].length; + } + return out + esc(text.slice(last)); +} + +export { STATUSES }; diff --git a/server/typescript/packages/docs-site/src/explorer/site.ts b/server/typescript/packages/docs-site/src/explorer/site.ts new file mode 100644 index 000000000..ca6782e70 --- /dev/null +++ b/server/typescript/packages/docs-site/src/explorer/site.ts @@ -0,0 +1,294 @@ +// generateExplorerSite(): the model explorer as a static site (design report §3.3, phase P1). +// +// It reads one `explorer.json` (and, for the prompt pages, the prompt texts), renders a page per +// screen through the bundled Mustache templates, and writes: +// +// index.html S1 what the project does +// map.html S2 the model map (slices, colour-by) +// model//.html S3 an object page (entity, value, projection, report) +// S4 a prompt page (and any other template) +// requirements/{features,qualities}.html S5 +// requirements//.html S6 +// matrix.html, trace.html, gaps.html, api.html, prompts.html, model/index.html, model//index.html +// assets/explorer.css, assets/explorer.js the theme (`meta eject docs-site` copies them) +// assets/explorer-data.js the graph and the search index the scripts read +// explorer.json the data spine itself, byte for byte +// +// The output depends on its inputs alone (no clock, no random layout seed in the files), so the +// site can sit under `meta verify --codegen`. Search works offline from explorer-data.js; when the +// published site has been indexed with Pagefind (`npx pagefind --site `), the search box uses +// that index instead. The pages carry Pagefind's markup for it. + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { render, InMemoryProvider } from "@metaobjectsdev/render"; +import { REQUIREMENT_ATTR_STATEMENT, REQUIREMENT_SUBTYPE_ARCHITECTURAL, REQUIREMENT_SUBTYPE_FUNCTIONAL } from "@metaobjectsdev/metadata"; +import { TEMPLATE_ATTR_TEXT_REF, TYPE_REQUIREMENT, DOC_ATTR_DESCRIPTION, OBJECT_SUBTYPE_REPORT, OBJECT_REPORT_ATTR_FROM } from "@metaobjectsdev/metadata/constants"; +import { checkLinks } from "../link-check.js"; +import { EXPLORER_ASSET_NAMES, EXPLORER_TEMPLATE_NAMES } from "../scaffold.js"; +import { ExplorerIndex } from "./index-model.js"; +import { serializeExplorer } from "./build.js"; +import { esc, legendHtml, relHref } from "./html.js"; +import { + PAGE_API, + PAGE_FEATURES, + PAGE_GAPS, + PAGE_MAP, + PAGE_MATRIX, + PAGE_MODEL, + PAGE_OVERVIEW, + PAGE_PROMPTS, + PAGE_QUALITIES, + PAGE_TRACE, + Pages, +} from "./pages.js"; +import type { ExplorerJson } from "./types.js"; + +export { EXPLORER_ASSET_NAMES, EXPLORER_TEMPLATE_NAMES }; + +/** The graph library the map, mini-maps and trace load from `assetBase`. */ +export const CYTOSCAPE_PATH = "cytoscape@3.30.2/dist/cytoscape.min.js"; + +const DEFAULT_ASSET_BASE = "https://cdn.jsdelivr.net/npm"; + +export interface ExplorerSiteOptions { + explorer: ExplorerJson; + outDir: string; + /** The project's name, as the header and the home page print it. */ + title: string; + /** Printed in every footer. Default none, so the pages depend on the model alone. */ + stamp?: string; + /** Prompt text by template node id, for the prompt pages and the unused-payload check. */ + texts?: ReadonlyMap; + /** Override dirs: a file of the same basename there wins over the bundled one. */ + templatesDir?: string; + assetsDir?: string; + /** Where Cytoscape is loaded from. Default `https://cdn.jsdelivr.net/npm`. */ + assetBase?: string; +} + +export interface ExplorerSiteResult { + /** Every HTML page written, site-relative, in write order. */ + pages: string[]; + /** Every file written (pages, assets, data), site-relative, sorted. */ + files: string[]; +} + +const SELF_DIR = dirname(fileURLToPath(import.meta.url)); +const BUNDLED_TEMPLATES = resolve(SELF_DIR, "../../templates"); +const BUNDLED_ASSETS = resolve(SELF_DIR, "../../assets"); + +function loadFrom(bundled: string, name: string, overrideDir?: string): string { + if (overrideDir !== undefined) { + const candidate = join(overrideDir, name); + if (existsSync(candidate)) return readFileSync(candidate, "utf8"); + } + return readFileSync(join(bundled, name), "utf8"); +} + +interface NavItem { + key: string; + label: string; + href: string; +} + +const NAV: readonly NavItem[] = [ + { key: "overview", label: "Overview", href: PAGE_OVERVIEW }, + { key: "map", label: "Model map", href: PAGE_MAP }, + { key: "requirements", label: "Requirements", href: PAGE_FEATURES }, + { key: "matrix", label: "Matrix", href: PAGE_MATRIX }, + { key: "trace", label: "Trace", href: PAGE_TRACE }, + { key: "prompts", label: "Prompts", href: PAGE_PROMPTS }, + { key: "api", label: "API", href: PAGE_API }, + { key: "gaps", label: "Gaps", href: PAGE_GAPS }, +]; + +/** Read each prompt's `.mustache` text from the directories the model's `@textRef` resolves in. */ +export function readPromptTexts(explorer: ExplorerJson, dirs: readonly string[]): Map { + const out = new Map(); + for (const n of explorer.nodes) { + const ref = n.attrs[TEMPLATE_ATTR_TEXT_REF]; + if (n.parent !== undefined || typeof ref !== "string") continue; + // A ref names a file below one of the directories: an absolute ref, or one that climbs out + // with `..`, is not read, so a model cannot pull an arbitrary file into a published page. + const segments = ref.split(/[\\/]/); + if (isAbsolute(ref) || segments.some((s) => s === ".." || s === "")) continue; + for (const d of dirs) { + const p = join(d, ...segments) + ".mustache"; + if (existsSync(p)) { + out.set(n.id, readFileSync(p, "utf8")); + break; + } + } + } + return out; +} + +export function generateExplorerSite(opts: ExplorerSiteOptions): ExplorerSiteResult { + const ix = new ExplorerIndex(opts.explorer); + const pagesModel = new Pages(ix); + for (const [id, text] of opts.texts ?? []) pagesModel.texts.set(id, text); + // The one partial: the requirement card S1 repeats (`{{> card}}`). + const provider = new InMemoryProvider({ card: loadFrom(BUNDLED_TEMPLATES, "explorer-card.mustache", opts.templatesDir) }); + const head = loadFrom(BUNDLED_TEMPLATES, "explorer-head.mustache", opts.templatesDir); + const foot = loadFrom(BUNDLED_TEMPLATES, "explorer-foot.mustache", opts.templatesDir); + const assetBase = (opts.assetBase ?? DEFAULT_ASSET_BASE).replace(/\/+$/, ""); + const legend = legendHtml(ix.hasClaims()); + const pages: string[] = []; + const files: string[] = []; + // Each body template is read once, not once per page it renders. + const bodies = new Map(); + + const written = new Set(); + const write = (rel: string, content: string): void => { + // Two nodes that map to one page would silently keep only the last; refuse instead. + if (written.has(rel)) throw new Error(`docs-site explorer: two pages map to ${rel}`); + written.add(rel); + const abs = join(opts.outDir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + files.push(rel); + }; + + const page = (template: string, href: string, nav: string, pageTitle: string, data: Record, graph = false): void => { + const relRoot = relHref(href, ""); + let body = bodies.get(template); + if (body === undefined) { + body = loadFrom(BUNDLED_TEMPLATES, template, opts.templatesDir); + bodies.set(template, body); + } + const payload = { + ...data, + siteTitle: esc(opts.title), + pageTitle: esc(pageTitle), + stamp: esc(opts.stamp ?? ""), + relRoot, + assetBase, + cytoscapeSrc: `${assetBase}/${CYTOSCAPE_PATH}`, + needsGraph: graph, + pageKey: nav, + nav: NAV.map((n) => ({ label: n.label, href: relHref(href, n.href), on: n.key === nav })), + legendHtml: legend, + }; + write(href, render({ template: head + body + foot, payload, provider, format: "text" })); + pages.push(href); + }; + + // S1 and the indexes. + page("explorer-overview.html.mustache", PAGE_OVERVIEW, "overview", "Overview", pagesModel.overview(opts.title)); + page("explorer-map.html.mustache", PAGE_MAP, "map", "Model map", pagesModel.map(), true); + page("explorer-requirements.html.mustache", PAGE_FEATURES, "requirements", "Features", pagesModel.requirements(REQUIREMENT_SUBTYPE_FUNCTIONAL, PAGE_FEATURES)); + page("explorer-requirements.html.mustache", PAGE_QUALITIES, "requirements", "Qualities", pagesModel.requirements(REQUIREMENT_SUBTYPE_ARCHITECTURAL, PAGE_QUALITIES)); + page("explorer-matrix.html.mustache", PAGE_MATRIX, "matrix", "Matrix", pagesModel.matrix()); + page("explorer-trace.html.mustache", PAGE_TRACE, "trace", "Trace", pagesModel.trace(), true); + page("explorer-prompts.html.mustache", PAGE_PROMPTS, "prompts", "Prompts", pagesModel.prompts()); + page("explorer-api.html.mustache", PAGE_API, "api", "API", pagesModel.api()); + page("explorer-gaps.html.mustache", PAGE_GAPS, "gaps", "Gaps", pagesModel.gaps()); + page("explorer-model.html.mustache", PAGE_MODEL, "map", "Packages", pagesModel.model()); + + for (const pkg of ix.packages) { + const data = pagesModel.packageIndex(pkg); + page("explorer-package.html.mustache", pagesModel.packagePage(pkg), "map", pkg === "" ? "(no package)" : pkg, data); + } + for (const n of ix.objects) page("explorer-object.html.mustache", pagesModel.objectPage(n), "map", n.name, pagesModel.object(n), true); + for (const t of ix.templates) page("explorer-prompt.html.mustache", pagesModel.objectPage(t), "prompts", t.name, pagesModel.prompt(t, opts.texts?.get(t.id))); + for (const r of ix.requirements) page("explorer-requirement.html.mustache", pagesModel.reqPage(r), "requirements", pagesModel.reqLabel(r), pagesModel.requirement(r)); + + for (const a of EXPLORER_ASSET_NAMES) write(`assets/${a}`, loadFrom(BUNDLED_ASSETS, a, opts.assetsDir)); + write("assets/explorer-data.js", `window.MO_EXPLORER = ${JSON.stringify(graphData(ix, pagesModel))};\n`); + write("explorer.json", serializeExplorer(opts.explorer)); + + const dangling = checkLinks(opts.outDir, pages); + if (dangling.length > 0) throw new Error("explorer link check failed:\n" + dangling.join("\n")); + return { pages, files: [...files].sort() }; +} + +/** The data the map, mini-maps, trace and search read. Every value comes off the index. */ +function graphData(ix: ExplorerIndex, p: Pages): Record { + const features = ix.features(); + const top = [...ix.objects, ...ix.templates]; + const nodes = top.map((n) => { + const feature = ix.featureOf(n.id); + const claims = ix.claims(n.id); + return { + id: n.id, + name: n.name, + kind: ix.kindOf(n), + pkg: n.package, + href: p.objectPage(n), + abstract: n.abstract === true ? 1 : 0, + fields: ix.fields(n.id).length, + claims: claims.length, + status: ix.claimStatus(n.id), + feature: feature !== undefined ? features.indexOf(feature) : -1, + quality: claims.length > 0 && claims.every((c) => c.req.kind === REQUIREMENT_SUBTYPE_ARCHITECTURAL) ? 1 : 0, + unclaimed: p.unclaimedEntity(n) ? 1 : 0, + api: ix.routes(n.id).map((r) => `${r.method} ${r.path}`), + gen: ix.generated(n.id).length, + storage: ix.storage(n.id)?.kind ?? "", + }; + }); + const ids = new Set(top.map((n) => n.id)); + const slices: Record = {}; + for (const s of ix.doc.slices) slices[s.id] = [...new Set(s.nodes.map((x) => ix.ownerOf(x)).filter((x) => ids.has(x)))].sort(); + const reqs = ix.requirements.map((r) => ({ + id: r.node.id, + label: p.reqLabel(r), + path: p.reqPath(r), + level: r.level ?? 0, + status: r.status, + kind: r.kind, + parent: r.parent?.node.id ?? "", + href: p.reqPage(r), + targets: (r.row?.implementedBy ?? []).filter((l) => l.target !== undefined && ix.byId.has(l.target)).map((l) => ix.ownerOf(l.target!)), + statement: typeof r.node.attrs[REQUIREMENT_ATTR_STATEMENT] === "string" ? r.node.attrs[REQUIREMENT_ATTR_STATEMENT] : "", + rollup: ix.rollup(r), + claimed: ix.targetsOf(r).length, + })); + const generated: Record = {}; + for (const n of top) { + const by = new Map(); + for (const f of ix.generated(n.id)) by.set(f.generatedBy, (by.get(f.generatedBy) ?? 0) + 1); + if (by.size > 0) generated[n.id] = [...by.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + } + const search: { t: string; s: string; h: string; k: string }[] = []; + for (const n of top) { + const desc = typeof n.attrs[DOC_ATTR_DESCRIPTION] === "string" ? (n.attrs[DOC_ATTR_DESCRIPTION] as string) : ""; + search.push({ t: n.name, s: `${n.type}.${n.subType}${n.package !== "" ? ` · ${n.package}` : ""}`, h: p.objectPage(n), k: `${n.name} ${n.id} ${desc}`.toLowerCase() }); + for (const f of ix.fields(n.id)) { + if (f.inheritedFrom !== undefined) continue; + search.push({ t: `${n.name}.${f.name}`, s: `field.${f.subType}`, h: p.pageOf(f.id) ?? p.objectPage(n), k: `${n.name}.${f.name} ${f.id}`.toLowerCase() }); + } + } + for (const r of ix.requirements) { + search.push({ + t: p.reqLabel(r), + s: `${r.level !== undefined ? `L${r.level}` : "—"} ${r.kind}${r.status !== "" ? ` · ${r.status}` : ""} · ${p.reqPath(r)}`, + h: p.reqPage(r), + k: `${p.reqPath(r)} ${r.node.id} ${p.reqLabel(r)} ${String(r.node.attrs[REQUIREMENT_ATTR_STATEMENT] ?? "")}`.toLowerCase(), + }); + } + return { + nodes, + edges: [ + ...ix.modelEdges().map((e) => ({ s: e.from, t: e.to, k: e.kind, ...(e.via !== undefined ? { v: localTail(e.via) } : {}) })), + // A report reads its rows from the `@from` entity: drawn so a report is not an island. + ...ix.objects + .filter((n) => n.subType === OBJECT_SUBTYPE_REPORT && typeof n.attrs[OBJECT_REPORT_ATTR_FROM] === "string" && ix.byId.has(n.attrs[OBJECT_REPORT_ATTR_FROM] as string)) + .map((n) => ({ s: n.id, t: n.attrs[OBJECT_REPORT_ATTR_FROM] as string, k: "from" })), + ], + slices, + features: features.map((f) => ({ id: f.node.id, label: p.reqLabel(f) })), + reqs, + generated, + search, + hasClaims: ix.hasClaims(), + requirementType: TYPE_REQUIREMENT, + }; +} + +function localTail(id: string): string { + const i = id.lastIndexOf("."); + return i < 0 ? id : id.slice(i + 1); +} diff --git a/server/typescript/packages/docs-site/src/explorer/types.ts b/server/typescript/packages/docs-site/src/explorer/types.ts index 9fc5a69ce..30dcaf5c3 100644 --- a/server/typescript/packages/docs-site/src/explorer/types.ts +++ b/server/typescript/packages/docs-site/src/explorer/types.ts @@ -19,6 +19,9 @@ export interface ExplorerNode { name: string; /** The package of the node's top-level ancestor, "" when it has none. */ package: string; + /** Its position among its siblings in the effective tree (0-based): declaration order, + * which `nodes` (sorted by id) does not keep. */ + order: number; /** The id of the node this one is nested in; absent on a top-level node. */ parent?: string; /** The id `extends` names, as the effective tree spells it. */ @@ -70,7 +73,7 @@ export interface ExplorerLedgerRow { digest: string; implementedBy: { ref: string; resolves: boolean; target?: string }[]; supersededBy?: { ref: string; resolves: boolean; target?: string }; - diagnostics: { code: string; severity: string; source: string }[]; + diagnostics: { code: string; severity: string; source: string; message: string }[]; verdict: string; } @@ -109,6 +112,15 @@ export interface ExplorerSlice { nodes: string[]; } +/** One HTTP route the project mounts for a node, as the run that generated the routes states + * it. */ +export interface ExplorerRoute { + node: string; + method: string; + /** The mounted path, with the project's `apiPrefix`: `/api/orders/{id}`. */ + path: string; +} + export interface ExplorerJson { format: typeof EXPLORER_FORMAT; nodes: ExplorerNode[]; @@ -117,5 +129,7 @@ export interface ExplorerJson { ledger?: ExplorerLedger; /** Absent without a manifest. */ generated?: ExplorerGenerated[]; + /** Absent without a routes input. Sorted by node, then path, then method. */ + api?: ExplorerRoute[]; slices: ExplorerSlice[]; } diff --git a/server/typescript/packages/docs-site/src/index.ts b/server/typescript/packages/docs-site/src/index.ts index 6fdf286c9..98f363b62 100644 --- a/server/typescript/packages/docs-site/src/index.ts +++ b/server/typescript/packages/docs-site/src/index.ts @@ -1,6 +1,6 @@ export { generateSite, DEFAULT_ASSET_BASE } from "./site.js"; export type { SiteOptions, SiteResult } from "./site.js"; -export { SITE_TEMPLATE_NAMES, SITE_ASSET_NAMES, readSiteFile } from "./scaffold.js"; +export { SITE_TEMPLATE_NAMES, SITE_ASSET_NAMES, CLASSIC_TEMPLATE_NAMES, CLASSIC_ASSET_NAMES, readSiteFile } from "./scaffold.js"; export type { MetaDataTypeProvider } from "@metaobjectsdev/metadata"; export { buildExplorer, serializeExplorer } from "./explorer/build.js"; export type { ExplorerInput } from "./explorer/build.js"; @@ -17,3 +17,6 @@ export type { ExplorerSlice, ExplorerSliceKind, } from "./explorer/types.js"; +export { generateExplorerSite, readPromptTexts, EXPLORER_TEMPLATE_NAMES, EXPLORER_ASSET_NAMES, CYTOSCAPE_PATH } from "./explorer/site.js"; +export type { ExplorerSiteOptions, ExplorerSiteResult } from "./explorer/site.js"; +export type { ExplorerRoute } from "./explorer/types.js"; diff --git a/server/typescript/packages/docs-site/src/link-check.ts b/server/typescript/packages/docs-site/src/link-check.ts index 14fcd5597..f71405e81 100644 --- a/server/typescript/packages/docs-site/src/link-check.ts +++ b/server/typescript/packages/docs-site/src/link-check.ts @@ -15,7 +15,9 @@ export function checkLinks(outDir: string, pages: string[]): string[] { // A root-relative href is served from outside the site (an `assetBase` mirror); the site's // own links are all relative. if (/^(https?:|mailto:|#$|\/)/.test(href)) continue; - const [file = "", anchor] = href.split("#"); + const [target0 = "", anchor] = href.split("#"); + // A query string selects a view on the page (`map.html?slice=…`); the file is what must exist. + const file = target0.split("?")[0] ?? ""; const target = file === "" ? page : normalize(join(dirname(page), file)); const abs = join(outDir, target); if (!existsSync(abs)) { errs.push(`${page} -> ${href}`); continue; } diff --git a/server/typescript/packages/docs-site/src/scaffold.ts b/server/typescript/packages/docs-site/src/scaffold.ts index e0636338e..972dbbf20 100644 --- a/server/typescript/packages/docs-site/src/scaffold.ts +++ b/server/typescript/packages/docs-site/src/scaffold.ts @@ -2,8 +2,8 @@ import { readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -/** The 9 mustache templates the site is built from (basenames under templates/). */ -export const SITE_TEMPLATE_NAMES: readonly string[] = [ +/** The classic site's 9 mustache templates (basenames under templates/). */ +export const CLASSIC_TEMPLATE_NAMES: readonly string[] = [ "chrome-head.mustache", "chrome-foot.mustache", "index.html.mustache", @@ -15,8 +15,37 @@ export const SITE_TEMPLATE_NAMES: readonly string[] = [ "coverage.html.mustache", ]; -/** The site's themeable assets (basenames under assets/). search-index.json is generated, not themed. */ -export const SITE_ASSET_NAMES: readonly string[] = ["site.css", "site.js"]; +/** The classic site's themeable assets. search-index.json is generated, not themed. */ +export const CLASSIC_ASSET_NAMES: readonly string[] = ["site.css", "site.js"]; + +/** The explorer's templates (basenames under templates/), which `meta eject docs-site` copies. */ +export const EXPLORER_TEMPLATE_NAMES: readonly string[] = [ + "explorer-head.mustache", + "explorer-foot.mustache", + "explorer-card.mustache", + "explorer-overview.html.mustache", + "explorer-map.html.mustache", + "explorer-object.html.mustache", + "explorer-prompt.html.mustache", + "explorer-prompts.html.mustache", + "explorer-requirements.html.mustache", + "explorer-requirement.html.mustache", + "explorer-matrix.html.mustache", + "explorer-trace.html.mustache", + "explorer-gaps.html.mustache", + "explorer-api.html.mustache", + "explorer-model.html.mustache", + "explorer-package.html.mustache", +]; + +/** The explorer's themeable assets (basenames under assets/). */ +export const EXPLORER_ASSET_NAMES: readonly string[] = ["explorer.css", "explorer.js"]; + +/** Every template `meta eject docs-site` copies: both sites' themes, so either can be owned. */ +export const SITE_TEMPLATE_NAMES: readonly string[] = [...CLASSIC_TEMPLATE_NAMES, ...EXPLORER_TEMPLATE_NAMES]; + +/** Every asset `meta eject docs-site` copies. */ +export const SITE_ASSET_NAMES: readonly string[] = [...CLASSIC_ASSET_NAMES, ...EXPLORER_ASSET_NAMES]; /** Read a bundled template or asset by basename (for scaffolding into a consumer). */ export function readSiteFile(kind: "template" | "asset", name: string): string { diff --git a/server/typescript/packages/docs-site/templates/explorer-api.html.mustache b/server/typescript/packages/docs-site/templates/explorer-api.html.mustache new file mode 100644 index 000000000..62b5695a8 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-api.html.mustache @@ -0,0 +1,5 @@ +

    API

    +{{#recorded}}
    Every route the generated code mounts, by the object it serves: {{{summary}}}.
    +{{#hasGroups}}
    {{#groups}}

    {{{linkHtml}}} {{{kind}}}{{#isReadOnly}} read-only{{/isReadOnly}}

    {{#routes}}
    {{{method}}} {{{path}}}
    {{/routes}}
    {{/groups}}
    {{/hasGroups}} +{{^hasGroups}}
    The run generated no routes.
    {{/hasGroups}}{{/recorded}} +{{^recorded}}
    The run that built this site recorded no routes: it generated none, or ran without the routes generator.
    {{/recorded}} diff --git a/server/typescript/packages/docs-site/templates/explorer-card.mustache b/server/typescript/packages/docs-site/templates/explorer-card.mustache new file mode 100644 index 000000000..80980b53c --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-card.mustache @@ -0,0 +1,7 @@ +
    +

    {{{levelHtml}}}{{{linkHtml}}} {{{statusHtml}}}{{{verdictHtml}}}

    +
    {{{statement}}}
    +
    {{{barHtml}}}
    {{{countsLine}}}
    + {{#hasChildren}}
      {{#children}}
    • {{{levelHtml}}}{{{linkHtml}}} {{{glyphHtml}}}
    • {{/children}}
    {{/hasChildren}} +
    Summary from the levels below
    No summary is generated in this build.
    +
    diff --git a/server/typescript/packages/docs-site/templates/explorer-foot.mustache b/server/typescript/packages/docs-site/templates/explorer-foot.mustache new file mode 100644 index 000000000..04793fa69 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-foot.mustache @@ -0,0 +1,7 @@ + +
    Built from explorer.json by the metaobjects docs-site generator{{#stamp}} · {{{stamp}}}{{/stamp}}. Static: no server, no LLM call.
    + +{{#needsGraph}}{{/needsGraph}} + + + diff --git a/server/typescript/packages/docs-site/templates/explorer-gaps.html.mustache b/server/typescript/packages/docs-site/templates/explorer-gaps.html.mustache new file mode 100644 index 000000000..cddd16593 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-gaps.html.mustache @@ -0,0 +1,5 @@ +

    Gaps

    +
    What the model and the gate can already point at. Each card says whether it comes from the shipped gate or is a check this explorer adds.
    +
    +{{#cards}}

    {{{title}}} {{{count}}} {{{source}}}

    {{{whyHtml}}}
    {{{bodyHtml}}}
    +{{/cards}}
    diff --git a/server/typescript/packages/docs-site/templates/explorer-head.mustache b/server/typescript/packages/docs-site/templates/explorer-head.mustache new file mode 100644 index 000000000..c29d48126 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-head.mustache @@ -0,0 +1,19 @@ + + + + + +{{{pageTitle}}} · {{{siteTitle}}} + + + + + +
    + {{{siteTitle}}} model explorer + + + +
    +
    + diff --git a/server/typescript/packages/docs-site/templates/explorer-map.html.mustache b/server/typescript/packages/docs-site/templates/explorer-map.html.mustache new file mode 100644 index 000000000..05893b9e7 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-map.html.mustache @@ -0,0 +1,13 @@ +

    Model map

    +
    Entities and the references between them. A slice is the set of model nodes a requirement subtree claims, a package holds, or a prompt reads and returns.
    +
    + + + + + + +
    +
    Click a node to see why it exists, what it holds and what was generated from it. Double-click to open its page.
    +
    + diff --git a/server/typescript/packages/docs-site/templates/explorer-matrix.html.mustache b/server/typescript/packages/docs-site/templates/explorer-matrix.html.mustache new file mode 100644 index 000000000..655821bd4 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-matrix.html.mustache @@ -0,0 +1,5 @@ +

    Traceability matrix

    +
    Rows are requirements that name the model; columns are stored entities and prompts, most claimed first. An empty column is a node nothing claims; a cell's colour and glyph are the row's declared status.
    +{{#hasRows}}
    {{{legendHtml}}}
    +
    {{{tableHtml}}}
    {{/hasRows}} +{{^hasRows}}
    {{#hasRequirements}}No requirement names the model yet: only L4 and L5 entries do.{{/hasRequirements}}{{^hasRequirements}}This model declares no requirements, so there is nothing to trace.{{/hasRequirements}}
    {{/hasRows}} diff --git a/server/typescript/packages/docs-site/templates/explorer-model.html.mustache b/server/typescript/packages/docs-site/templates/explorer-model.html.mustache new file mode 100644 index 000000000..b71705cae --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-model.html.mustache @@ -0,0 +1,5 @@ +

    Packages

    +
    Every package in the model and what it declares.
    +
    +{{#packageCards}}

    {{{name}}}

    {{{kindLineHtml}}}
    {{{membersHtml}}}
    +{{/packageCards}}
    diff --git a/server/typescript/packages/docs-site/templates/explorer-object.html.mustache b/server/typescript/packages/docs-site/templates/explorer-object.html.mustache new file mode 100644 index 000000000..24d87b772 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-object.html.mustache @@ -0,0 +1,56 @@ +
    {{{crumbsHtml}}}
    +

    {{{name}}} {{#isAbstract}}abstract{{/isAbstract}}{{#isReadOnly}}read-only view{{/isReadOnly}}{{#isUnclaimed}}unclaimed{{/isUnclaimed}}

    + +{{#description}}
    {{{description}}}
    {{/description}} +
    {{{kindLabel}}} · {{{storageLine}}}{{#extendsHtml}} · extends {{{extendsHtml}}}{{/extendsHtml}}
    +
    +
    +

    Why it exists

    + {{#hasWhy}}
    + {{#whyRows}} + {{/whyRows}}
    CapabilityRequirementLevelStatusOn
    {{{capabilityHtml}}}{{{reqHtml}}}
    {{{statement}}}
    {{{levelHtml}}}{{{statusHtml}}}{{{onHtml}}}
    {{/hasWhy}} + {{^hasWhy}}
    {{{whyEmptyHtml}}}
    {{/hasWhy}} + {{#projection}} +

    Read model

    +
    + {{#basesHtml}}
    Surfaces fields of {{{basesHtml}}}.
    {{/basesHtml}} + {{#hasOrigins}}
    {{#origins}}{{/origins}}
    FieldOriginDefinition
    {{{field}}}{{{origin}}}{{{definitionHtml}}}
    {{/hasOrigins}} +
    + {{/projection}} + {{#report}} +

    Report

    +
    + {{#fromHtml}}{{/fromHtml}} + {{#spineHtml}}{{/spineHtml}} + {{#dimensionsHtml}}{{/dimensionsHtml}} + {{#measuresHtml}}{{/measuresHtml}} + {{#segmentHtml}}{{/segmentHtml}} + {{#filterHtml}}{{/filterHtml}} +
    From{{{fromHtml}}}
    Spine{{{spineHtml}}}
    Dimensions{{{dimensionsHtml}}}
    Measures{{{measuresHtml}}}
    Segment{{{segmentHtml}}}
    Filter{{{filterHtml}}}
    + {{/report}} + {{#hasFields}}

    Fields

    +
    {{#hasColumn}}{{/hasColumn}}{{#showClaims}}{{/showClaims}} + {{#fields}}{{#hasColumn}}{{/hasColumn}}{{#showClaims}}{{/showClaims}} + {{/fields}}
    FieldTypeColumnFlagsClaimed at L5
    {{{name}}} {{{inheritedHtml}}}{{{type}}}{{{refHtml}}}{{{valuesHtml}}}{{{column}}}{{{flagsHtml}}}{{{claimsHtml}}}
    {{/hasFields}} + {{#hasKeys}}

    Keys and references

    +
    {{#keys}}{{/keys}}
    {{{kind}}} {{{name}}} {{{inheritedHtml}}}{{{shape}}}{{{targetHtml}}}
    {{/hasKeys}} + {{#hasReporting}}

    Reporting vocabulary

    +
    {{#reporting}}{{/reporting}}
    NameKindDefinition
    {{{name}}}{{{kind}}}{{{definitionHtml}}}
    {{/hasReporting}} + {{#hasLayouts}}

    Screens declared

    {{#layouts}}
    {{{kind}}} {{{name}}}{{#detailHtml}}: {{{detailHtml}}}{{/detailHtml}}
    {{/layouts}}{{/hasLayouts}} +
    + +
    diff --git a/server/typescript/packages/docs-site/templates/explorer-overview.html.mustache b/server/typescript/packages/docs-site/templates/explorer-overview.html.mustache new file mode 100644 index 000000000..4a96540ac --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-overview.html.mustache @@ -0,0 +1,25 @@ +

    {{{heading}}}

    +
    Built from {{{sourcesLine}}}. Every number below is computed from the project's metadata or the gate's own output.
    +
    +{{#tiles}}
    {{{n}}}
    {{{l}}}
    +{{/tiles}}
    +{{#hasRequirements}}{{{legendHtml}}}{{/hasRequirements}} +{{#hasRequirements}} +

    Features requirement.functional, by level

    +{{#hasFeatures}}
    +{{#featureCards}}{{> card}}{{/featureCards}} +
    {{/hasFeatures}} +{{^hasFeatures}}
    No requirement.functional is declared.
    {{/hasFeatures}} +

    Qualities requirement.architectural, the non-functional policies

    +{{#hasQualities}}
    +{{#qualityCards}}{{> card}}{{/qualityCards}} +
    {{/hasQualities}} +{{^hasQualities}}
    No requirement.architectural is declared.
    {{/hasQualities}} +{{/hasRequirements}} +{{^hasRequirements}} +
    This model declares no requirements yet, so this page cannot say what each part is for. Declare requirement.functional entries (levels 1 to 3 for the problem, 4 and 5 naming the model) and this page opens on them, with every entity traced to the feature it serves.
    +{{/hasRequirements}} +

    Packages what the model holds

    +
    +{{#packageCards}}

    {{{name}}}

    {{{kindLineHtml}}}
    {{{membersHtml}}}
    +{{/packageCards}}
    diff --git a/server/typescript/packages/docs-site/templates/explorer-package.html.mustache b/server/typescript/packages/docs-site/templates/explorer-package.html.mustache new file mode 100644 index 000000000..1a7b65691 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-package.html.mustache @@ -0,0 +1,5 @@ +
    {{{crumbsHtml}}}
    +

    {{{name}}}

    +
    +{{#rows}} +{{/rows}}
    NameKindStorageFieldsClaim
    {{{linkHtml}}} {{{abstractHtml}}}{{#description}}
    {{{description}}}
    {{/description}}
    {{{kind}}}{{{storage}}}{{fieldCount}}{{{statusHtml}}}
    diff --git a/server/typescript/packages/docs-site/templates/explorer-prompt.html.mustache b/server/typescript/packages/docs-site/templates/explorer-prompt.html.mustache new file mode 100644 index 000000000..913025221 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-prompt.html.mustache @@ -0,0 +1,27 @@ +
    {{{crumbsHtml}}}
    +

    {{{name}}} {{#isUnclaimed}}unclaimed{{/isUnclaimed}}

    + +{{#description}}
    {{{description}}}
    {{/description}} +
    {{{attrsHtml}}}
    +
    +
    +

    Payload: {{{payloadHtml}}}

    + {{#hasPayload}}
    {{#payloadRows}}{{/payloadRows}}
    FieldTypeUsed in text
    {{{name}}} {{{requiredHtml}}}{{{type}}}{{{refHtml}}}{{{usedHtml}}}
    {{/hasPayload}} + {{^hasPayload}}
    No payload: the text renders from nothing typed.
    {{/hasPayload}} +

    Reply: {{{replyHtml}}}

    + {{#hasReply}}
    {{#replyRows}}{{/replyRows}}
    FieldType
    {{{name}}}{{{type}}}{{{valuesHtml}}}
    {{/hasReply}} + {{^hasReply}}
    Outbound only: no reply parser.
    {{/hasReply}} +

    Why it exists

    + {{#hasWhy}}
    {{#whyRows}}{{/whyRows}}
    CapabilityRequirementLevelStatus
    {{{capabilityHtml}}}{{{reqHtml}}}
    {{{statement}}}
    {{{levelHtml}}}{{{statusHtml}}}
    {{/hasWhy}} + {{^hasWhy}}
    {{{whyEmptyHtml}}}
    {{/hasWhy}} +
    +
    +

    Text {{{textMeta}}}

    + {{#hasText}}
    {{{textHtml}}}
    {{/hasText}} + {{^hasText}}
    The text {{{textRef}}} was not found beside the model, so this page cannot show it.
    {{/hasText}} +

    Generated

    + {{#hasGenerated}}{{#generated}}
    {{{generator}}}{{#files}}
    {{{.}}}
    {{/files}}
    {{/generated}}{{/hasGenerated}} + {{^hasGenerated}}
    {{{generatedNote}}}
    {{/hasGenerated}} +
    Proven today: meta verify --templates fails if the text names a variable the payload lacks. This page shows the other direction too: payload fields the text never uses.
    +
    +
    diff --git a/server/typescript/packages/docs-site/templates/explorer-prompts.html.mustache b/server/typescript/packages/docs-site/templates/explorer-prompts.html.mustache new file mode 100644 index 000000000..65b8bc94d --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-prompts.html.mustache @@ -0,0 +1,6 @@ +

    Prompts

    +
    Each declared template, its typed payload, its reply type, and the requirements that claim it.
    +{{#hasRows}}
    +{{#rows}} +{{/rows}}
    TemplatePayloadReplyClaimed by
    {{{linkHtml}}} {{{kind}}}{{#description}}
    {{{description}}}
    {{/description}}
    {{{payloadHtml}}}{{{replyHtml}}}{{{claimsHtml}}}
    {{/hasRows}} +{{^hasRows}}
    This model declares no template.*.
    {{/hasRows}} diff --git a/server/typescript/packages/docs-site/templates/explorer-requirement.html.mustache b/server/typescript/packages/docs-site/templates/explorer-requirement.html.mustache new file mode 100644 index 000000000..de8d0825e --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-requirement.html.mustache @@ -0,0 +1,20 @@ +
    {{{crumbsHtml}}}
    +

    {{{levelHtml}}}{{{name}}} {{{statusHtml}}}{{{dispositionHtml}}}{{{verdictHtml}}}

    + +
    {{{address}}}
    +
    +
    +

    Statement

    {{{statement}}}

    Counterexample

    {{{counterexample}}}{{#hasDescription}}

    Scope

    {{{description}}}
    {{/hasDescription}}
    +

    Implemented by

    + {{#hasLinks}}
    {{#linkRows}}{{/linkRows}}
    ReferenceKindResolves
    {{{refHtml}}}{{{kind}}}{{{resolvesHtml}}}
    {{/hasLinks}} + {{^hasLinks}}
    {{{linksEmpty}}}
    {{/hasLinks}} + {{#hasBelow}}

    Below ({{childCount}}){{#deeper}} {{belowCount}} in all{{/deeper}}

    {{{belowBarHtml}}}
      {{#children}}
    • {{{levelHtml}}}{{{linkHtml}}} {{{statusHtml}}}
    • {{/children}}
    {{/hasBelow}} +
    {{#sliceHref}}Show this slice on the model map{{/sliceHref}}{{#sliceHref}}{{#traceHref}} · {{/traceHref}}{{/sliceHref}}{{#traceHref}}Trace to code{{/traceHref}}
    +
    +
    +

    What the build proves about this entry

    +
    {{#proof}}{{/proof}}
    {{{what}}}{{{verdictHtml}}}{{{howHtml}}}
    + {{#trackedHtml}}

    Tracked by

    {{{trackedHtml}}}{{/trackedHtml}} + {{#digest}}

    Statement digest

    {{{digest}}}
    Moves when the statement changes; a summary of this entry would be pinned to it.
    {{/digest}} +
    +
    diff --git a/server/typescript/packages/docs-site/templates/explorer-requirements.html.mustache b/server/typescript/packages/docs-site/templates/explorer-requirements.html.mustache new file mode 100644 index 000000000..b4ec94a99 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-requirements.html.mustache @@ -0,0 +1,6 @@ +

    Requirements

    +
    Hierarchy is nesting. L1 to L3 describe the problem; only L4 (an object or prompt) and L5 (a member) may name the model.
    + +{{{legendHtml}}} +{{#hasTree}}
    {{{treeHtml}}}
    {{/hasTree}} +{{^hasTree}}
    {{{emptyNote}}}
    {{/hasTree}} diff --git a/server/typescript/packages/docs-site/templates/explorer-trace.html.mustache b/server/typescript/packages/docs-site/templates/explorer-trace.html.mustache new file mode 100644 index 000000000..f6f5ca414 --- /dev/null +++ b/server/typescript/packages/docs-site/templates/explorer-trace.html.mustache @@ -0,0 +1,6 @@ +

    Trace

    +
    From intent to code: requirement levels, then the model nodes they name, then the files generated from those nodes.
    +{{#hasStarts}}
    + +
    Hand-written modules that import the generated files are not scanned in this build, so the trace stops at the generated files. Click a requirement or a model node to open its page.
    {{/hasStarts}} +{{^hasStarts}}
    This model declares no requirements, so there is nothing to trace from. Every object's generated files are listed on its own page.
    {{/hasStarts}} diff --git a/server/typescript/packages/docs-site/test/explorer-build.test.ts b/server/typescript/packages/docs-site/test/explorer-build.test.ts index 00718de1e..7f0fc1f0f 100644 --- a/server/typescript/packages/docs-site/test/explorer-build.test.ts +++ b/server/typescript/packages/docs-site/test/explorer-build.test.ts @@ -61,6 +61,26 @@ describe("buildExplorer", () => { expect(build({ verify: true }).generated).toBeUndefined(); }); + test("routes are kept per known node, sorted, deduplicated; without them there is no api", () => { + const api = buildExplorer({ + model: json("effective.json"), + routes: [ + { node: "acme::shop::Order", method: "post", path: "/api/orders" }, + { node: "acme::shop::Order", method: "GET", path: "/api/orders" }, + { node: "acme::shop::Order", method: "GET", path: "/api/orders" }, + { node: "acme::shop::Customer", method: "GET", path: "/api/customers/{id}" }, + { node: "acme::shop::Gone", method: "GET", path: "/api/gone" }, + { node: "acme::shop::Order", path: "/api/no-method" }, + ], + }).api; + expect(api).toEqual([ + { node: "acme::shop::Customer", method: "GET", path: "/api/customers/{id}" }, + { node: "acme::shop::Order", method: "GET", path: "/api/orders" }, + { node: "acme::shop::Order", method: "POST", path: "/api/orders" }, + ]); + expect(build().api).toBeUndefined(); + }); + test("a requirement slice holds the subtree, its targets and the owner of a claimed member", () => { const slice = build().slices.find((s) => s.id === "requirement:acme::shop::Shop.Checkout.Orders.Recorded"); expect(slice?.nodes).toEqual([ diff --git a/server/typescript/packages/docs-site/test/explorer-site.test.ts b/server/typescript/packages/docs-site/test/explorer-site.test.ts new file mode 100644 index 000000000..56dade146 --- /dev/null +++ b/server/typescript/packages/docs-site/test/explorer-site.test.ts @@ -0,0 +1,247 @@ +// The model explorer site (`generateExplorerSite`) over the public fixtures: the shop +// explorer-conformance fixture (requirements, a prompt, an unclaimed entity) and the +// persistence-conformance fitness model (projections, reports, no requirements). +import { afterAll, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative, resolve } from "node:path"; +import { MetaDataLoader, canonicalSerializeEffective } from "@metaobjectsdev/metadata"; +import { FileSource } from "@metaobjectsdev/metadata/core"; +import { + buildExplorer, + EXPLORER_ASSET_NAMES, + generateExplorerSite, + readPromptTexts, + type ExplorerJson, +} from "../src/index.js"; + +const REPO = resolve(import.meta.dir, "../../../../.."); +const SHOP = join(REPO, "fixtures/explorer-conformance/shop"); +const FITNESS = join(REPO, "fixtures/persistence-conformance/canonical"); + +const dirs: string[] = []; +const scratch = (): string => { + const d = mkdtempSync(join(tmpdir(), "explorer-site-")); + dirs.push(d); + return d; +}; +afterAll(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); +}); + +function filesUnder(dir: string): string[] { + return readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((f) => f.isFile()) + .map((f) => relative(dir, join(f.parentPath, f.name)).split("\\").join("/")) + .sort(); +} + +const shopExplorer = (): ExplorerJson => JSON.parse(readFileSync(join(SHOP, "expected.json"), "utf8")) as ExplorerJson; + +function buildShop(): { out: string; pages: string[]; files: string[]; page: (p: string) => string } { + const explorer = shopExplorer(); + const out = scratch(); + const r = generateExplorerSite({ + explorer, + outDir: out, + title: "Shop", + texts: readPromptTexts(explorer, [join(SHOP, "project/prompts"), join(SHOP, "project")]), + }); + return { out, pages: r.pages, files: r.files, page: (p) => readFileSync(join(out, p), "utf8") }; +} + +async function buildFitness(): Promise<{ page: (p: string) => string; files: string[] }> { + const root = (await new MetaDataLoader().load([new FileSource(join(FITNESS, "meta.fitness.json"))])).root; + const explorer = buildExplorer({ model: JSON.parse(canonicalSerializeEffective(root)) as unknown }); + const out = scratch(); + const r = generateExplorerSite({ explorer, outDir: out, title: "Fitness", texts: readPromptTexts(explorer, [join(FITNESS, "prompts"), FITNESS]) }); + return { files: r.files, page: (p) => readFileSync(join(out, p), "utf8") }; +} + +describe("explorer site over explorer-conformance/shop", () => { + const shop = buildShop(); + + test("writes every screen, one page per object, prompt and requirement, and passes its own link check", () => { + for (const p of [ + "index.html", "map.html", "matrix.html", "trace.html", "prompts.html", "api.html", "gaps.html", + "requirements/features.html", "requirements/qualities.html", "model/index.html", + "model/acme/shop/Order.html", "model/acme/shop/AuditEntry.html", "model/acme/shop/OrderNotePrompt.html", + "requirements/acme/shop/Shop.html", "requirements/acme/shop/Shop.Checkout.Orders.Recorded.Quotable.html", + "requirements/acme/shop/Addressable.html", + ]) expect(shop.pages).toContain(p); + // generateExplorerSite throws on a dangling link, so reaching here is the link check passing. + for (const a of EXPLORER_ASSET_NAMES) expect(shop.files).toContain(`assets/${a}`); + expect(shop.files).toContain("assets/explorer-data.js"); + expect(shop.files).toContain("explorer.json"); + expect(filesUnder(shop.out)).toEqual([...shop.files].sort()); + }); + + test("is deterministic: a second build writes the same bytes", () => { + const again = buildShop(); + for (const f of shop.files) expect(again.page(f)).toBe(shop.page(f)); + }); + + test("the explorer.json it ships is the one it read", () => { + expect(shop.page("explorer.json")).toBe(readFileSync(join(SHOP, "expected.json"), "utf8")); + }); + + test("S1, the main page: what the software does, from the counts and the requirement tree", () => { + const index = shop.page("index.html"); + expect(index).toContain("

    What Shop does

    "); + expect(index.match(/class="card tile"/g)?.length).toBe(6); + expect(index).toContain('
    2/3
    '); + expect(index).toContain("requirements: 8 features, 1 quality"); + // A feature card: level badge, declared status, the roll-up bar, the level below. + expect(index).toContain('L1'); + expect(index).toContain('href="requirements/acme/shop/Shop.html"'); + expect(index).toContain('