From 85079fc5677dc75d2ef38f60c76f68227275201a Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 11:10:21 -0500 Subject: [PATCH 01/11] feat(docs-site): the model explorer site, built by docsSiteFile() docsSiteFile() now builds a model explorer by default: a main page that says what the software does (count tiles, feature and quality cards with roll-ups), a model map with slices and colour-by, a page per object, projection, report, prompt and requirement with a one-hop mini-map, the traceability matrix, a trace from a requirement to its generated files, 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 carry Pagefind markup, work at phone width, depend on the model alone so verify --codegen holds them, and are link-checked. meta eject docs-site copies the explorer theme beside the classic one. site: "classic" keeps the earlier site. --- CHANGELOG.md | 24 + .../references/typescript-docs.md | 2 +- docs/README.md | 2 + docs/features/cli.md | 2 +- docs/features/explorer-data.md | 20 +- docs/features/explorer-site.md | 171 ++++ docs/features/own-your-codegen.md | 19 +- .../references/typescript-docs.md | 2 +- .../references/typescript-docs.md | 2 +- .../explorer-conformance/shop/expected.json | 45 + .../packages/cli/src/commands/gen.ts | 3 + .../packages/cli/src/commands/verify.ts | 33 +- .../packages/cli/src/lib/codegen-drift.ts | 3 +- .../cli/src/lib/requirement-payload.ts | 99 ++ .../cli/test/gen-docs-generators.test.ts | 27 +- .../packages/cli/test/gen-docs-site.test.ts | 26 +- .../cli/test/unit/reporting-inert.test.ts | 4 +- .../packages/codegen-ts/src/generator.ts | 30 + .../src/generators/docs-site-file.ts | 149 ++- .../packages/codegen-ts/src/index.ts | 2 +- .../codegen-ts/src/overwrite-policy.ts | 20 + .../packages/codegen-ts/src/runner.ts | 60 +- .../test/docs-site-explorer.test.ts | 92 ++ .../codegen-ts/test/run-output.test.ts | 71 ++ .../typescript/packages/docs-site/README.md | 41 +- .../packages/docs-site/assets/explorer.css | 232 +++++ .../packages/docs-site/assets/explorer.js | 313 ++++++ .../packages/docs-site/package.json | 2 +- .../packages/docs-site/src/explorer/build.ts | 19 + .../packages/docs-site/src/explorer/html.ts | 79 ++ .../docs-site/src/explorer/index-model.ts | 343 +++++++ .../packages/docs-site/src/explorer/pages.ts | 943 ++++++++++++++++++ .../packages/docs-site/src/explorer/site.ts | 280 ++++++ .../packages/docs-site/src/explorer/types.ts | 14 + .../packages/docs-site/src/index.ts | 5 +- .../packages/docs-site/src/link-check.ts | 4 +- .../packages/docs-site/src/scaffold.ts | 37 +- .../templates/explorer-api.html.mustache | 5 + .../templates/explorer-card.mustache | 7 + .../templates/explorer-foot.mustache | 7 + .../templates/explorer-gaps.html.mustache | 5 + .../templates/explorer-head.mustache | 19 + .../templates/explorer-map.html.mustache | 13 + .../templates/explorer-matrix.html.mustache | 5 + .../templates/explorer-model.html.mustache | 5 + .../templates/explorer-object.html.mustache | 56 ++ .../templates/explorer-overview.html.mustache | 25 + .../templates/explorer-package.html.mustache | 5 + .../templates/explorer-prompt.html.mustache | 27 + .../templates/explorer-prompts.html.mustache | 6 + .../explorer-requirement.html.mustache | 20 + .../explorer-requirements.html.mustache | 6 + .../templates/explorer-trace.html.mustache | 6 + .../docs-site/test/explorer-build.test.ts | 20 + .../docs-site/test/explorer-site.test.ts | 183 ++++ .../packages/docs-site/test/scaffold.test.ts | 34 +- 56 files changed, 3565 insertions(+), 109 deletions(-) create mode 100644 docs/features/explorer-site.md create mode 100644 server/typescript/packages/cli/src/lib/requirement-payload.ts create mode 100644 server/typescript/packages/codegen-ts/test/docs-site-explorer.test.ts create mode 100644 server/typescript/packages/codegen-ts/test/run-output.test.ts create mode 100644 server/typescript/packages/docs-site/assets/explorer.css create mode 100644 server/typescript/packages/docs-site/assets/explorer.js create mode 100644 server/typescript/packages/docs-site/src/explorer/html.ts create mode 100644 server/typescript/packages/docs-site/src/explorer/index-model.ts create mode 100644 server/typescript/packages/docs-site/src/explorer/pages.ts create mode 100644 server/typescript/packages/docs-site/src/explorer/site.ts create mode 100644 server/typescript/packages/docs-site/templates/explorer-api.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-card.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-foot.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-gaps.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-head.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-map.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-matrix.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-model.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-object.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-overview.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-package.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-prompt.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-prompts.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-requirement.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-requirements.html.mustache create mode 100644 server/typescript/packages/docs-site/templates/explorer-trace.html.mustache create mode 100644 server/typescript/packages/docs-site/test/explorer-site.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d1633e25f..d926c11b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,22 @@ 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`. 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 +199,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 477959e311..ff88a88e8a 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 b710209043..542ee95aaa 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 0e6d1de713..f69c343808 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 fd55f77fa0..ecba4ffdd7 100644 --- a/docs/features/explorer-data.md +++ b/docs/features/explorer-data.md @@ -6,7 +6,9 @@ tree and whether each claim holds, and which generated files each node produced. is read from one data file, `explorer.json`. This page documents that file and the three 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 @@ -117,6 +119,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 +137,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 +175,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 +195,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 +214,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 0000000000..9e38c8eec4 --- /dev/null +++ b/docs/features/explorer-site.md @@ -0,0 +1,171 @@ +# 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 | +| 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'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, 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, undecided gaps, 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 | + +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 are what `meta verify` found. +- **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/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 0371bd297c..b74b1cbb1e 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/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 477959e311..ff88a88e8a 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 477959e311..ff88a88e8a 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 d28845d4f2..cd8ec56293 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.", diff --git a/server/typescript/packages/cli/src/commands/gen.ts b/server/typescript/packages/cli/src/commands/gen.ts index bdba49c353..6f96d480fc 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 4eac6c7432..cc10f4b49d 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, requirementPayload, type RequirementCounts } from "../lib/requirement-payload.js"; import { emitStructured, type OutputFormat } from "../lib/format.js"; import { antiPatternRows, missingBaseUrlRows, removedPropRows, ranSection, skippedSection, warnCapped, @@ -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"); @@ -1713,6 +1699,7 @@ export async function verifyCommand( config.dialect, (m) => log.warn(`verify --codegen: [agent-docs] ${m}`), ), + requirementLedger: () => requirementPayload(codegenRoot, genCollection), }); } catch (err) { log.error(`verify --codegen: regeneration failed: ${describeError(err)}`); @@ -1735,6 +1722,7 @@ export async function verifyCommand( genCollection.ownFiles, { sourceRoots: sourceRootsOf(genCollection), agentSchema: () => buildAgentSchemaInput(codegenRoot, config.dialect, () => {}), + requirementLedger: () => requirementPayload(codegenRoot, genCollection), }); return probe.error === undefined && probe.clean; } finally { @@ -1942,19 +1930,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 { diff --git a/server/typescript/packages/cli/src/lib/codegen-drift.ts b/server/typescript/packages/cli/src/lib/codegen-drift.ts index 334e02f512..abc8c7f4c3 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); @@ -278,6 +278,7 @@ export async function computeCodegenDrift( ...(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-payload.ts b/server/typescript/packages/cli/src/lib/requirement-payload.ts new file mode 100644 index 0000000000..32e8598816 --- /dev/null +++ b/server/typescript/packages/cli/src/lib/requirement-payload.ts @@ -0,0 +1,99 @@ +// 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 finding as the structured payload carries it. */ +export function requirementDiagnosticRow(d: Diagnostic, source: "gate" | "lint"): AdvisoryDiagnosticRow { + return { + code: d.code, + // "" rather than omitted: a tabular encoder (TOON) needs every row to carry the same keys. + path: d.path ?? "", + 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: AdvisoryDiagnosticRow[] }; +} + +/** + * 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/gen-docs-generators.test.ts b/server/typescript/packages/cli/test/gen-docs-generators.test.ts index 2e775d2b7c..0305f0a94d 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 ee722f1a0c..5f1efe4385 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 0294324565..25d13f73b0 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/codegen-ts/src/generator.ts b/server/typescript/packages/codegen-ts/src/generator.ts index d6caa13394..1cf0c7754d 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 213ce576bf..250f867f23 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,22 +94,7 @@ 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 sourceDirs = promptSourceDirs(ctx, project, opts, ctx.sourceRoots); 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 @@ -111,3 +130,83 @@ export const docsSiteFile = function docsSiteFile(opts?: DocsSiteFileOpts): Gene }, }; } 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 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 }) }; + } + const ledger = ctx.requirementLedger?.(); + const explorer = buildExplorer({ + model: JSON.parse(canonicalSerializeEffective(ctx.loadedRoot)) as unknown, + ...(ledger !== undefined && { verify: ledger }), + manifest, + routes: runRoutes(ctx), + }); + const texts = readPromptTexts(explorer, promptSourceDirs(ctx, project, opts, ctx.sourceRoots ?? [])); + const ownedTemplates = join(project, OWNED_DOCS_SITE_DIR, "templates"); + const ownedAssets = join(project, OWNED_DOCS_SITE_DIR, "assets"); + if (existsSync(ownedTemplates) && !existsSync(join(ownedTemplates, 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\"`.", + ); + } + const scratch = mkdtempSync(join(tmpdir(), "meta-docs-site-")); + try { + const result = generateExplorerSite({ + explorer, + outDir: scratch, + title: opts?.title ?? (basename(project) || "Metadata"), + stamp: opts?.stamp ?? "", + texts, + ...(opts?.assetBase !== undefined ? { assetBase: opts.assetBase } : {}), + ...(existsSync(ownedTemplates) ? { templatesDir: ownedTemplates } : {}), + ...(existsSync(ownedAssets) ? { assetsDir: ownedAssets } : {}), + }); + return result.files.map((path) => ({ + path: `${prefix}${path}`, + content: readFileSync(join(scratch, path), "utf8"), + })); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index 57464372da..27ad6f8d5b 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 70311f1554..74d9d5dd04 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 f8bc70baa9..96f5343246 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,14 @@ 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; } export interface RunGenResult { @@ -791,6 +800,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 +833,34 @@ 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` adds what the committed manifest records for the generators + // it did not run, so a narrowed run reads what a full run reads. + const runOutputFiles = (ran: readonly Generator[]): 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) { + const ranNames = new Set(ran.map((g) => g.name)); + for (const rec of listGeneratedProvenance(genStateDir)) { + if (ranNames.has(rec.generatedBy)) 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 +950,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(selected) }), + ...(requirementLedger !== undefined && { requirementLedger }), warn: (msg) => warnings.push(`[${generator.name}] ${msg}`), }; @@ -1349,3 +1392,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 0000000000..c237f71afb --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/docs-site-explorer.test.ts @@ -0,0 +1,92 @@ +// 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, no route is listed", async () => { + await runGen({ config: config([entityFile(), docsSiteFile()]), metadata: root, projectRoot: tmp, sourceRoots: [sources] }); + expect(explorerOf().api ?? []).toEqual([]); + }); + + 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 0000000000..d8eaccff1a --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/run-output.test.ts @@ -0,0 +1,71 @@ +// `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 } 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"); + +let tmp: string; +beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), "codegen-run-output-")); }); +afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + +async function load() { + return (await new MetaDataLoader().load([new FileSource(FIXTURE)])).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" }]); + }); +}); diff --git a/server/typescript/packages/docs-site/README.md b/server/typescript/packages/docs-site/README.md index af95f2c5aa..c3938a9f7a 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 0000000000..aae678b023 --- /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 0000000000..ad6ce28c38 --- /dev/null +++ b/server/typescript/packages/docs-site/assets/explorer.js @@ -0,0 +1,313 @@ +// 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; + 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 a559b1c5e1..3c3129bb1c 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 f7918becf0..0acee7c340 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 } : {}), @@ -316,6 +321,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,6 +398,7 @@ 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. @@ -394,6 +412,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 0000000000..ae009d285a --- /dev/null +++ b/server/typescript/packages/docs-site/src/explorer/html.ts @@ -0,0 +1,79 @@ +// 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 { 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}` + : `—`; +} + +/** A gate verdict as an outlined badge: `error` or `warn`, with the finding codes. */ +export function verdictBadge(r: ReqEntry): string { + const row = r.row; + if (row === undefined || row.diagnostics.length === 0) return ""; + const worst = row.diagnostics.some((d) => d.severity === "error") ? "error" : "warn"; + const codes = row.diagnostics.map((d) => d.code).join(", "); + const n = row.diagnostics.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["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 0000000000..6b1014a831 --- /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 0000000000..b2b4afdef8 --- /dev/null +++ b/server/typescript/packages/docs-site/src/explorer/pages.ts @@ -0,0 +1,943 @@ +// 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_PAYLOAD_REF, + 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 { + REQUIREMENT_ATTR_COUNTEREXAMPLE, + REQUIREMENT_ATTR_STATEMENT, + REQUIREMENT_ATTR_TRACKED_BY, + REQUIREMENT_LEVEL_OBJECT, + REQUIREMENT_LEVEL_SERVICE, + REQUIREMENT_STATUS_LIVE, + REQUIREMENT_STATUS_PARTIAL, + REQUIREMENT_STATUS_PLANNED, + 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, + 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 (`#m-`), 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]); + +/** 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] : []); + +function pkgDir(pkg: string): string { + return pkg === "" ? "_root" : pkg.split("::").join("/"); +} + +/** 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)}/${n.name}.html`; + } + + packagePage(pkg: string): string { + return `model/${pkgDir(pkg)}/index.html`; + } + + reqPage(r: ReqEntry): string { + return `requirements/${pkgDir(r.node.package)}/${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}#m-${member.name}` : 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.message.includes(`'${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(`m-${f.name}`), + 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(`m-${c.name}`), + 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(`m-${c.name}`), 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["columns"]); + if (cols.length > 0) parts.push(`columns ${cols.map((c) => `${esc(c)}`).join(", ")}`); + const sort = str(l.attrs["defaultSortField"]); + if (sort !== undefined) parts.push(`sorted by ${esc(sort)}${str(l.attrs["defaultSortOrder"]) !== undefined ? ` ${esc(str(l.attrs["defaultSortOrder"]))}` : ""}`); + if (typeof l.attrs["pageSize"] === "number") parts.push(`${l.attrs["pageSize"]} 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), anchor: esc(`m-${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, "promptStyle", "model", "maxTokens"] + .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 = (ix.doc.ledger?.diagnostics ?? []).filter((d): d is Diag => isDiag(d) && row !== undefined && d.path === row.path); + 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 path." : "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 }); + }; + const diags = (ix.doc.ledger?.diagnostics ?? []).filter(isDiag); + if (ix.doc.ledger !== undefined) { + card( + "Gate findings", + diags.length, + "meta verify", + "Printed by every run of the requirement gate.", + diags.length === 0 + ? `
    None.
    ` + : `${diags.map((d) => { + const req = ix.requirements.find((r) => r.row?.path === d.path && d.path !== ""); + return ``; + }).join("")}
    ${esc(d.code)}${req !== undefined ? this.reqLink(from, req) : ""}
    ${esc(d.message)}
    `, + ); + 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 undecided = ix.requirements.filter((r) => (r.status === REQUIREMENT_STATUS_PARTIAL || r.status === REQUIREMENT_STATUS_PLANNED) && r.node.attrs["disposition"] === undefined); + card("Undecided gaps", undecided.length, "meta verify", "A partial or planned entry with no @disposition: a gap nobody has ruled on. 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; + 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 0000000000..7614cacbbb --- /dev/null +++ b/server/typescript/packages/docs-site/src/explorer/site.ts @@ -0,0 +1,280 @@ +// 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, 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; + for (const d of dirs) { + const p = join(d, ...ref.split("/")) + ".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[] = []; + + const write = (rel: string, content: string): void => { + 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, ""); + const body = loadFrom(BUNDLED_TEMPLATES, template, opts.templatesDir); + 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 9fc5a69ce5..5d3fd373db 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. */ @@ -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 6fdf286c97..98f363b62c 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 14fcd55977..f71405e812 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 e0636338e2..972dbbf205 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 0000000000..62b5695a83 --- /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 0000000000..80980b53c0 --- /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 0000000000..04793fa69f --- /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 0000000000..cddd165931 --- /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 0000000000..c29d481267 --- /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 0000000000..05893b9e7a --- /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 0000000000..655821bd40 --- /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 0000000000..b71705cae9 --- /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 0000000000..24d87b772d --- /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 0000000000..4a96540ac3 --- /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 0000000000..1a7b656913 --- /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 0000000000..913025221f --- /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 0000000000..65b8bc94d7 --- /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 0000000000..de8d0825e6 --- /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 0000000000..b4ec94a992 --- /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 0000000000..f6f5ca414d --- /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 00718de1eb..7f0fc1f0f7 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 0000000000..5eaec55f35 --- /dev/null +++ b/server/typescript/packages/docs-site/test/explorer-site.test.ts @@ -0,0 +1,183 @@ +// 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 { mkdtempSync, readFileSync, readdirSync, rmSync } 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('