From 1d8b1676eafa636458dc9f83f14aa5d634a2893e Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 09:50:03 -0400 Subject: [PATCH 1/3] fix(cli,codegen-ts): report a doc generator skipped by meta gen in every output form ADR-0025 made `meta docs` the door for documentation, so `meta gen` warns about and skips a docsFile() or apiDocsFile() listed under `generators`. The skip is right; the signal was not reaching every reader. - meta gen --format json|toon (the default on a pipe) printed the runner's warnings to stderr only, and the result's `warnings` carried unknown-attribute findings alone. `warnings[]` now carries every warning the run printed: all of the runner's, a generator's ctx.warn, the runtime-boundary advisory, and the stale-agent-context and ignored-manifest advisories. `help[]` gains a `meta docs` next step when a doc generator was skipped. - A config wiring only doc generators was told "no entities to generate" and to author entities. The summary, next step and text line name the real reason. - meta gen --list printed no note on any row (the registry notes stopped being rendered when the catalog was rewritten) and presented api-docs as a generator to wire. The api-docs and docs rows now say they are run by `meta docs`, a wired one reads as skipped, and --probe gives them no file count. The JSON row carries `note` and `door`. - meta verify reports the wiring as an advisory row, rule doc-generator-in-gen. - The unknown-generator error no longer offers api-docs. mermaid-er is stated everywhere as what it is: a generator `meta gen` runs. `meta docs` has never run it; it draws the ER diagram on its overview page, while mermaidErDiagram() writes the same diagram as a file of its own, which only `meta gen` writes. The catalog note said "surfaced via meta docs" and the stable name was refused in `generators` as "neutral (owned by 'meta docs')" while the factory wired beside it ran. The note now names the command that runs it, and `generators: ["mermaid-er"]` resolves. `"docs"` is still refused. One definition of what the runner skips (packaged-docs-generators.ts) is read by the runner, resolveGenerators and the catalog. runGen() returns skippedDocGenerators beside warnings, so a caller decides from the fact instead of matching a sentence. TypeScript only: the C#, Java, Kotlin and Python ports register no doc generator for code generation, so there is nothing to wire and nothing to skip. --- CHANGELOG.md | 41 ++ .../references/typescript-docs.md | 14 + docs/features/cli.md | 17 +- .../references/typescript-docs.md | 14 + .../references/typescript-docs.md | 14 + .../generator-registry-conformance/README.md | 2 +- .../registry.json | 5 +- .../packages/cli/src/commands/gen.ts | 21 +- .../packages/cli/src/commands/verify.ts | 20 + .../packages/cli/src/lib/advisory.ts | 22 + .../cli/src/lib/agent-context-staleness.ts | 11 +- .../packages/cli/src/lib/catalog-listing.ts | 38 +- .../cli/src/lib/doc-generator-advisory.ts | 59 +++ .../cli/src/lib/manifest-ignored-check.ts | 24 +- .../typescript/packages/cli/src/lib/output.ts | 38 +- .../packages/cli/test/catalog-listing.test.ts | 12 +- .../cli/test/gen-docs-door-signals.test.ts | 412 ++++++++++++++++++ .../codegen-ts/src/generator-registry.ts | 7 +- .../packages/codegen-ts/src/index.ts | 4 + .../codegen-ts/src/metaobjects-config.ts | 21 +- .../src/packaged-docs-generators.ts | 42 ++ .../packages/codegen-ts/src/runner.ts | 31 +- .../test/generator-registry.test.ts | 22 + .../test/generator-spec-resolution.test.ts | 21 +- .../codegen-ts/test/runner-docs-shim.test.ts | 98 ++++- 25 files changed, 946 insertions(+), 64 deletions(-) create mode 100644 server/typescript/packages/cli/src/lib/doc-generator-advisory.ts create mode 100644 server/typescript/packages/cli/test/gen-docs-door-signals.test.ts create mode 100644 server/typescript/packages/codegen-ts/src/packaged-docs-generators.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e1643e34a..f10fb1cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,47 @@ here.** ### Fixed +- **A doc generator skipped by `meta gen` is now reported in every output form.** `meta docs` + is the door for documentation (ADR-0025), so `meta gen` warns about and skips a `docsFile()` + or `apiDocsFile()` listed under `generators`. That has not changed. What changed is who hears + about it: + - **`meta gen --format json|toon`** (the default on a pipe) printed the runner's warnings to + stderr only, and the result's `warnings` carried unknown-attribute findings alone, so the + document described a clean run with no API docs and no reason. `warnings[]` now carries + every warning the run printed: all of the runner's (not only this one), a generator's own + `ctx.warn`, the runtime-boundary advisory, and the stale-agent-context and ignored-manifest + advisories. `help[]` gains a `meta docs` next step when a doc generator was skipped. Text + output is unchanged. + - **A config that wires only doc generators** was told `no entities to generate` and to + `author entities in this project's metadata sources`, for a project that has them. The + summary, the next step and the text line now name the real reason. + - **`meta gen --list`** presented `api-docs` as one more generator to wire, and printed no + note on any row: the registry's notes for `docs` and `mermaid-er` stopped being rendered + when the catalog was rewritten. The `api-docs` and `docs` rows now read ``run by `meta + docs` `` with the reason under the row, a wired one reads ``WIRED but skipped by `meta + gen` ``, and `--probe` no longer prints `would emit 0` for them. In `--format json` the row + carries `note` and `door: "meta docs"`, and `project.wouldEmit` is `null` instead of `0`. + - **`meta verify`** said nothing about a config wired this way. It now reports each one as an + advisory row (rule `doc-generator-in-gen`, in `antiPatterns.rows[]`) that names + `meta verify --docs` as the gate that checks documentation. Advisory: the exit code is + unchanged. + - **The unknown-generator error** listed `api-docs` among the names `meta gen` accepts. It no + longer does, and it says documentation comes from `meta docs`. + - **`mermaid-er` is stated everywhere as what it is: a generator `meta gen` runs.** + `meta docs` has never run it. `meta docs` draws the ER diagram on its overview page, and + `mermaidErDiagram()` writes the same diagram as a file of its own (`docs/model.md`), which + only `meta gen` writes. Its catalog note said "surfaced via `meta docs`", and the stable + name was refused in `generators` as `neutral (owned by 'meta docs')` while the factory + wired in the same array ran. The note now says which command runs it and that the + overview page already has the diagram, and `generators: ["mermaid-er"]` resolves to the + factory. `"docs"` is still refused: `meta docs` does own that one. + + `runGen()` returns a new `skippedDocGenerators: string[]` beside `warnings`, and + `@metaobjectsdev/codegen-ts` exports `isPackagedDocsGenerator`, so a caller embedding the + runner can ask rather than match a sentence. TypeScript only: in the C#, Java, Kotlin and Python + ports documentation is a separate command (`dotnet meta docs`, `mvn metaobjects:docs`, + `metaobjects docs`) and no doc generator is registered for code generation, so there is + nothing to wire and nothing to skip. - `meta verify --docs` now reports a generated page in the docs root that no current object produces (for example the `.md` page of an object that was removed) as drift. It was silent about every such page outside `agent/`, so an adopter kept ten orphan pages with the diff --git a/agent-context/skills/metaobjects-codegen/references/typescript-docs.md b/agent-context/skills/metaobjects-codegen/references/typescript-docs.md index 356232ff2..64a2c13b7 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript-docs.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript-docs.md @@ -35,6 +35,20 @@ Other flags: `--layout flat|package`, `--base-url `. Configure defaults in (it documents what the codegen produced); with no config it is skipped with a note, and the model surface still emits from metadata alone. +**Never wire `docsFile()` or `apiDocsFile()` into `generators`.** They are the engines +behind `meta docs`, so `meta gen` warns about each one it finds in the suite and skips it: +no docs are written by that run. The warning is on stderr, and in a structured run +(`--format json|toon`, the default on a pipe) it is in the result's `warnings[]` with a +`meta docs` next step in `help[]`. `meta gen --list` marks both rows ``run by `meta docs` `` +(`door: "meta docs"` in `--format json`) and `--probe` gives them no file count. +`meta verify` reports the wiring as an advisory row, rule `doc-generator-in-gen`. Remove +the entry and run `meta docs`. + +**`mermaid-er` is the one docs-layer generator `meta gen` does run.** `meta docs` already +draws the ER diagram on its overview page with nothing wired, which is what most projects +want. `mermaidErDiagram()` (or the stable name `"mermaid-er"`) in `generators` writes the +same diagram as a file of its own, `docs/model.md`; `meta docs` never writes that file. + **Before calling any generated code, read `./docs/api/AGENT-API.md`** — it has the exact imports, signatures, payload field shapes, and runnable examples for this project's generated API, so you don't have to guess them. diff --git a/docs/features/cli.md b/docs/features/cli.md index a5038b14c..ef90f02e9 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -549,7 +549,8 @@ on both commands. the total: ``` -meta gen --format json → { gen[], summary, help[], antiPatterns: { status, total, rows[] } } +meta gen --format json → { gen[], summary, help[], antiPatterns: { status, total, rows[] }, + warnings[]? } meta verify --format json → { verify[], exitCode, summary, help[], antiPatterns: { status, total, rows[] }, requirements: { status, total, rows[] }, @@ -567,6 +568,20 @@ metadata and is recreated only because the migration alters a table it reads is drift, so it appears in neither. `meta migrate` still emits that drop/create pair and names the views in a note (`notes[]` in its structured output). +`meta gen`'s `warnings[]` carries **every warning the run printed**, in the order it +printed them: the runner's own (a generator that emitted nothing, an unsatisfied +`requires`, a hand-edited file it refused), a generator's `ctx.warn`, an unknown +attribute, and the CLI's advisories about a stale agent context or an ignored hash +manifest. The key is absent on a run that printed none. One of them is worth knowing by +name: a doc generator (`docsFile()`, `apiDocsFile()`) listed under `generators` is +**warned about and skipped**, because `meta docs` is the door for documentation +(ADR-0025). The result then says so in `warnings[]` and adds a `meta docs` next step to +`help[]`; `meta gen --list` marks those rows ``run by `meta docs` `` (`door` in +`--format json`), and `meta verify` reports the same wiring as an advisory row with rule +`doc-generator-in-gen`. `mermaid-er` is the one generator in the `docs` layer this does not +apply to: `meta gen` runs it and writes the ER diagram as a file of its own +(`docs/model.md`), while `meta docs` draws the same diagram on its overview page. + In a structured run every narration line moves to **stderr**, so stdout is one parseable document. `--format` is honored by `gen`, `verify` and `migrate`; any other command prints text and says so if you pass it. There is no `--json` flag — 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 356232ff2..64a2c13b7 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 @@ -35,6 +35,20 @@ Other flags: `--layout flat|package`, `--base-url `. Configure defaults in (it documents what the codegen produced); with no config it is skipped with a note, and the model surface still emits from metadata alone. +**Never wire `docsFile()` or `apiDocsFile()` into `generators`.** They are the engines +behind `meta docs`, so `meta gen` warns about each one it finds in the suite and skips it: +no docs are written by that run. The warning is on stderr, and in a structured run +(`--format json|toon`, the default on a pipe) it is in the result's `warnings[]` with a +`meta docs` next step in `help[]`. `meta gen --list` marks both rows ``run by `meta docs` `` +(`door: "meta docs"` in `--format json`) and `--probe` gives them no file count. +`meta verify` reports the wiring as an advisory row, rule `doc-generator-in-gen`. Remove +the entry and run `meta docs`. + +**`mermaid-er` is the one docs-layer generator `meta gen` does run.** `meta docs` already +draws the ER diagram on its overview page with nothing wired, which is what most projects +want. `mermaidErDiagram()` (or the stable name `"mermaid-er"`) in `generators` writes the +same diagram as a file of its own, `docs/model.md`; `meta docs` never writes that file. + **Before calling any generated code, read `./docs/api/AGENT-API.md`** — it has the exact imports, signatures, payload field shapes, and runnable examples for this project's generated API, so you don't have to guess them. 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 356232ff2..64a2c13b7 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 @@ -35,6 +35,20 @@ Other flags: `--layout flat|package`, `--base-url `. Configure defaults in (it documents what the codegen produced); with no config it is skipped with a note, and the model surface still emits from metadata alone. +**Never wire `docsFile()` or `apiDocsFile()` into `generators`.** They are the engines +behind `meta docs`, so `meta gen` warns about each one it finds in the suite and skips it: +no docs are written by that run. The warning is on stderr, and in a structured run +(`--format json|toon`, the default on a pipe) it is in the result's `warnings[]` with a +`meta docs` next step in `help[]`. `meta gen --list` marks both rows ``run by `meta docs` `` +(`door: "meta docs"` in `--format json`) and `--probe` gives them no file count. +`meta verify` reports the wiring as an advisory row, rule `doc-generator-in-gen`. Remove +the entry and run `meta docs`. + +**`mermaid-er` is the one docs-layer generator `meta gen` does run.** `meta docs` already +draws the ER diagram on its overview page with nothing wired, which is what most projects +want. `mermaidErDiagram()` (or the stable name `"mermaid-er"`) in `generators` writes the +same diagram as a file of its own, `docs/model.md`; `meta docs` never writes that file. + **Before calling any generated code, read `./docs/api/AGENT-API.md`** — it has the exact imports, signatures, payload field shapes, and runnable examples for this project's generated API, so you don't have to guess them. diff --git a/fixtures/generator-registry-conformance/README.md b/fixtures/generator-registry-conformance/README.md index b0e41b7cd..0e43989ae 100644 --- a/fixtures/generator-registry-conformance/README.md +++ b/fixtures/generator-registry-conformance/README.md @@ -35,7 +35,7 @@ to make the choice cheap, and `layer` is the axis it is cheap along. | `persistence` | queries, db-context, repository, exposed-table, relations, stored-proc | app shape | | `api` | routes, routes-hono, filter-allowlist, validator, spring-config | app shape | | `client` | form, hooks, grid, grid-hook | app shape | -| `docs` | docs, mermaid-er, api-docs | on by default (`meta docs`) | +| `docs` | docs, mermaid-er, api-docs | on by default (`meta docs`); `mermaid-er` is the one `meta gen` runs, for the diagram as a file of its own | | `capability` | prompt-render, output-parser, output-prompt, extractor, render-helper, trace-helper, requirement-tests, cube-model, shared-model, template, callable | `meta gen --list --probe` | Six, not ten. An earlier draft split `capability` into `trace` / `requirements` / diff --git a/fixtures/generator-registry-conformance/registry.json b/fixtures/generator-registry-conformance/registry.json index 969899379..2298e1a03 100644 --- a/fixtures/generator-registry-conformance/registry.json +++ b/fixtures/generator-registry-conformance/registry.json @@ -1,5 +1,5 @@ { - "$comment": "CANONICAL generator stable-name manifest (ADR-0021 D3). The single cross-port source of truth for generator stable names. Every port's generator registry is conformance-tested against this file: (1) every generator a port exposes MUST use a name listed here; (2) for each generator, the `ports` array lists exactly the ports expected to expose it (presence is checked both ways); (3) because all ports validate against this one file, a shared concept (e.g. `routes`) is spelled identically everywhere. Port ids: typescript, csharp, java, kotlin, python. `tier`: native (idiomatic per-port code) or neutral (Tier-2, owned by `meta docs`). `layer`: one of model, persistence, api, client, docs, capability — the grouping an adopter SELECTS BY, gated in every port exactly as `tier` is (see README.md). Adding/renaming a generator = edit THIS file + the port registry together; the conformance gate fails otherwise.", + "$comment": "CANONICAL generator stable-name manifest (ADR-0021 D3). The single cross-port source of truth for generator stable names. Every port's generator registry is conformance-tested against this file: (1) every generator a port exposes MUST use a name listed here; (2) for each generator, the `ports` array lists exactly the ports expected to expose it (presence is checked both ways); (3) because all ports validate against this one file, a shared concept (e.g. `routes`) is spelled identically everywhere. Port ids: typescript, csharp, java, kotlin, python. `tier`: native (idiomatic per-port code) or neutral (Tier-2, a language-neutral artifact). Which command runs an entry is a separate fact, stated in its `note`: `docs` and `api-docs` are run by `meta docs`, `mermaid-er` by `meta gen`. `layer`: one of model, persistence, api, client, docs, capability — the grouping an adopter SELECTS BY, gated in every port exactly as `tier` is (see README.md). Adding/renaming a generator = edit THIS file + the port registry together; the conformance gate fails otherwise.", "ports": ["typescript", "csharp", "java", "kotlin", "python"], "generators": { "entity": { @@ -90,6 +90,7 @@ "concept": "Per-entity/template SDK API reference (generated-code API; human + agent forms).", "tier": "native", "layer": "docs", + "note": "Run by `meta docs` as its api surface (ADR-0025), not by `meta gen`: a `meta gen` config that lists it is warned about and skipped.", "ports": ["typescript"] }, "trace-helper": { @@ -207,7 +208,7 @@ "concept": "Neutral Mermaid ER diagram of the entity/relationship model.", "tier": "neutral", "layer": "docs", - "note": "Neutral artifact owned by the docs engine (ADR-0020); surfaced via `meta docs`.", + "note": "Neutral artifact (ADR-0020). Run by `meta gen`, which writes the diagram as a file of its own; `meta docs` draws the same diagram on its overview page and does not run this generator.", "ports": ["typescript"] } } diff --git a/server/typescript/packages/cli/src/commands/gen.ts b/server/typescript/packages/cli/src/commands/gen.ts index 515bb0f69..2cf6e15ef 100644 --- a/server/typescript/packages/cli/src/commands/gen.ts +++ b/server/typescript/packages/cli/src/commands/gen.ts @@ -127,12 +127,20 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat // would find no manifest there and silently skip the nudge. `meta verify` makes // the same call for the same reason; the two commands describe this and the // anti-pattern scan below as one advisory pass, so they must scan one tree. - warnIfAgentContextStale(projectRoot); + // + // EVERY warning this run prints is also collected here, in the order printed, because + // the structured result carries them. They used to reach stderr only — and on a pipe, + // where the default format is the document, a reader of that document saw a clean run: + // a wired `apiDocsFile()` produced no API docs and the result gave no reason (ADR-0025 + // has `meta gen` warn about and skip it). A new warning site adds to this list. + const warnings: string[] = []; + const advise = (line: string | undefined): void => { if (line !== undefined) warnings.push(line); }; + advise(warnIfAgentContextStale(projectRoot)); // Advisory: the committed hash manifest is what makes hand-edit detection work on a // machine that did not generate the output. Silent unless it is ignored. Keyed on // projectRoot, not cwd, for the same reason its neighbour is — the manifest belongs to // whichever directory `resolveCollection` decided the metadata lives in. - warnIfManifestIgnored(projectRoot); + advise(warnIfManifestIgnored(projectRoot)); let forgeConfig; try { @@ -162,7 +170,7 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat const unknownAttrs = unknownAttrFindings.length > 0 ? [...unknownAttrFindings, UNKNOWN_ATTR_GEN_ADVICE] : []; - for (const w of unknownAttrs) log.warn(`warning: ${w}`); + for (const w of unknownAttrs) { log.warn(`warning: ${w}`); warnings.push(w); } // FR-023 §11.1 item 2 — refuse a positional that names ONLY objects imported // from a dependency and excluded from output by the default exclusion rule. @@ -210,7 +218,7 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat return 1; } - for (const w of result.warnings) { log.warn(w); } + for (const w of result.warnings) { log.warn(w); warnings.push(w); } // A first-run POINTER, not a warning: an empty selection is the designed state of a // fresh `meta init`, so calling it a problem would make every new project start with @@ -243,7 +251,7 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat if (!cliConfig.dryRun) { for (const w of runtimeBoundaryWarnings( findRuntimeBoundaryCrossings(projectRoot, result.files.map((f) => f.path)), - )) log.warn(`warning: ${w}`); + )) { log.warn(`warning: ${w}`); warnings.push(w); } } const targetDirs = Array.from(new Set( @@ -277,9 +285,10 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat dialect: forgeConfig.dialect, dryRun: cliConfig.dryRun, // Text mode already printed these to stderr above; the structured forms carry them. - warnings: fmt === "text" ? [] : unknownAttrs, + warnings: fmt === "text" ? [] : warnings, antiPatterns, generatorCount: forgeConfig.generators?.length ?? 0, + skippedDocGenerators: result.skippedDocGenerators, ...(migrateAdvice !== undefined ? { migrateAdvice } : {}), }; const output = diff --git a/server/typescript/packages/cli/src/commands/verify.ts b/server/typescript/packages/cli/src/commands/verify.ts index 600e2e551..49d7573d8 100644 --- a/server/typescript/packages/cli/src/commands/verify.ts +++ b/server/typescript/packages/cli/src/commands/verify.ts @@ -17,8 +17,10 @@ import { libraryPrefixRows, unindexedFkRows, referentialActionConflictRows, + docGeneratorRows, advisoryHelpLines, } from "../lib/advisory.js"; +import { scanForDocGeneratorsInGen, type DocGeneratorFinding } from "../lib/doc-generator-advisory.js"; import { scanForUnprovenancedLibraryPrefix } from "../lib/library-prefix-advisory.js"; import { scanForUnindexedForeignKeys, type UnindexedFkFinding } from "../lib/fk-index-advisory.js"; import { scanForReferentialActionConflicts, type ReferentialActionConflictFinding } from "../lib/referential-action-advisory.js"; @@ -1021,6 +1023,16 @@ export async function verifyCommand( // advisory only — a model the schema builder refuses is migrate's to report. } + // A doc generator wired under `generators`: `meta gen` skips it, and `--codegen` + // regenerates through the same runner without relaying its warnings — so this is + // the only place `verify` can say that the listed generator produces nothing here. + let docGenerators: DocGeneratorFinding[] = []; + try { + docGenerators = scanForDocGeneratorsInGen(forgeConfig); + } catch { + // advisory only — a config whose factory throws is `meta gen`'s to report. + } + antiPatternSection = ranSection([ ...antiPatternRows(findings), ...missingBaseUrlRows(baseUrl), @@ -1028,6 +1040,7 @@ export async function verifyCommand( ...libraryPrefixRows(libraryPrefix), ...unindexedFkRows(unindexedFks), ...referentialActionConflictRows(actionConflicts), + ...docGeneratorRows(docGenerators), ]); if (findings.length > 0) { log.warn( @@ -1071,6 +1084,13 @@ export async function verifyCommand( ); warnCapped(libraryPrefix.map((f) => ` ${f.message}`), flags.limit, { structured }); } + if (docGenerators.length > 0) { + log.warn( + `meta verify — ${docGenerators.length} doc generator(s) listed under \`generators\`, ` + + `which \`meta gen\` skips (advisory — does not fail the build):`, + ); + warnCapped(docGenerators.map((f) => ` ${f.message}`), flags.limit, { structured }); + } } // -- template (prompt / output) drift -------------------------------------- diff --git a/server/typescript/packages/cli/src/lib/advisory.ts b/server/typescript/packages/cli/src/lib/advisory.ts index bc8397d4d..f1b08db98 100644 --- a/server/typescript/packages/cli/src/lib/advisory.ts +++ b/server/typescript/packages/cli/src/lib/advisory.ts @@ -115,6 +115,7 @@ export const RULE_REMOVED_PROP_PREFIX = "removed-prop:"; export const RULE_LIBRARY_PREFIX = "library-prefix-unprovenanced"; export const RULE_UNINDEXED_FK = "unindexed-foreign-key"; export const RULE_REFERENTIAL_ACTION_CONFLICT = "overridden-referential-action"; +export const RULE_DOC_GENERATOR_IN_GEN = "doc-generator-in-gen"; /** The next-step line for `n` rows of one advisory kind, each naming what the rows are. */ const ADVISORY_HELP: ReadonlyArray<{ matches: (rule: string) => boolean; line: (n: number) => string }> = [ @@ -143,6 +144,11 @@ const ADVISORY_HELP: ReadonlyArray<{ matches: (rule: string) => boolean; line: ( line: (n) => `${n} node(s) declared under "metaobjects::" with no ejection provenance — see antiPatterns.rows[] (rule "${RULE_LIBRARY_PREFIX}")`, }, + { + matches: (r) => r === RULE_DOC_GENERATOR_IN_GEN, + line: (n) => + `${n} doc generator(s) listed under \`generators\`, which \`meta gen\` skips — see antiPatterns.rows[] (rule "${RULE_DOC_GENERATOR_IN_GEN}"); \`meta docs\` writes documentation`, + }, ]; /** @@ -262,6 +268,22 @@ export function referentialActionConflictRows( })); } +/** + * Doc generators wired under `generators` in the same row shape. There is no line: the + * finding is about the loaded config, whose entries carry no source position. + */ +export function docGeneratorRows( + findings: readonly { file: string; construct: string; message: string }[], +): AdvisoryFindingRow[] { + return findings.map((f) => ({ + file: f.file, + line: 0, + rule: RULE_DOC_GENERATOR_IN_GEN, + construct: f.construct, + message: f.message, + })); +} + // --------------------------------------------------------------------------- // text output // --------------------------------------------------------------------------- diff --git a/server/typescript/packages/cli/src/lib/agent-context-staleness.ts b/server/typescript/packages/cli/src/lib/agent-context-staleness.ts index 7703da83d..feb941752 100644 --- a/server/typescript/packages/cli/src/lib/agent-context-staleness.ts +++ b/server/typescript/packages/cli/src/lib/agent-context-staleness.ts @@ -14,17 +14,22 @@ import { log } from "./log.js"; * Advisory: if a scaffolded MetaObjects agent context predates this CLI version, * print a one-line nudge to re-scaffold. Never throws, never blocks — an absent or * corrupt manifest is silently ignored (this is a reminder, not a gate). + * + * Returns the line it printed, so a command with a structured result can carry it + * there too; undefined when it said nothing. */ -export function warnIfAgentContextStale(cwd: string): void { +export function warnIfAgentContextStale(cwd: string): string | undefined { const p = join(cwd, AGENT_CONTEXT_MANIFEST_PATH); let manifest: Manifest | undefined; if (existsSync(p)) { try { manifest = JSON.parse(readFileSync(p, "utf8")) as Manifest; } catch { - return; // unreadable/corrupt — say nothing + return undefined; // unreadable/corrupt — say nothing } } const msg = agentContextStaleness({ manifest, currentVersion: cliVersion() }); - if (msg !== null) log.warn(msg); + if (msg === null) return undefined; + log.warn(msg); + return msg; } diff --git a/server/typescript/packages/cli/src/lib/catalog-listing.ts b/server/typescript/packages/cli/src/lib/catalog-listing.ts index a59f9a036..365653b87 100644 --- a/server/typescript/packages/cli/src/lib/catalog-listing.ts +++ b/server/typescript/packages/cli/src/lib/catalog-listing.ts @@ -39,7 +39,7 @@ import { RUNTIME_ENTRIES, resolveRuntimePackage, runtimeClosure, runtimeCopyStatus, runtimeInstall, type RuntimeCopyRow, } from "./runtime-eject.js"; -import { OWNED_RUNTIME_DIR } from "@metaobjectsdev/codegen-ts"; +import { DOCS_DOOR_COMMAND, isPackagedDocsGenerator, OWNED_RUNTIME_DIR } from "@metaobjectsdev/codegen-ts"; import { readPackageManifest, declaredDependencyNames } from "./package-manifest.js"; import { describeError } from "./error-text.js"; @@ -62,6 +62,14 @@ export interface GeneratorCatalogRow { tier: "native" | "neutral"; package: string; description: string; + /** The registry's one line a reader needs before wiring this entry. Printed under the row. */ + note?: string; + /** + * Present iff `meta gen` does NOT run this generator: the command that produces its + * output instead (ADR-0025). A config that lists it under `generators` is warned about + * and skipped, so it is never counted by `--probe`. + */ + door?: string; /** From the reference template's `use-when:` header. Absent for a non-ejectable entry. */ useWhen?: string; /** From the reference template's `emits:` header. Absent for a non-ejectable entry. */ @@ -93,7 +101,8 @@ export interface GeneratorCatalogRow { wired: boolean; /** This generator's framework is a declared dependency. `null` when neutral or unknown. */ frameworkDetected: boolean | null; - /** Files this generator WOULD emit for this model. `null` unless --probe. */ + /** Files this generator WOULD emit for this model. `null` unless --probe, and + * `null` for a row with a `door`: `meta gen` does not run it, so there is no count. */ wouldEmit: number | null; /** Why `wouldEmit` is null despite --probe. Absent when it is a number. */ probeError?: string; @@ -288,6 +297,10 @@ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promis : await ownedRuntimeByGenerator(opts.project.projectRoot, opts.project.ownedNames); for (const entry of listCatalog()) { + // Asked of the runner's own definition, never restated: which generators `meta gen` + // skips is a fact about the runner, and a row that disagreed with it is the defect + // this field closes (`api-docs` was listed as one more generator to wire). + const door = isPackagedDocsGenerator(entry.factory().name) ? DOCS_DOOR_COMMAND : undefined; const template = entry.ejectable ? readTemplate(entry.name) : undefined; // A project that owns this generator's adapter copy installs what the COPY imports, // not the runtime package — the same trade `meta eject` prints. @@ -310,6 +323,8 @@ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promis tier: entry.tier, package: pkg, description: entry.description, + ...(entry.note !== undefined ? { note: entry.note } : {}), + ...(door !== undefined ? { door } : {}), ...(template !== undefined && headerFacet(template, "use-when") !== undefined ? { useWhen: headerFacet(template, "use-when")! } : {}), @@ -339,7 +354,10 @@ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promis let wouldEmit: number | null = null; let probeError: string | undefined; let needsConfig: string[] | undefined; - if (opts.probe !== undefined) { + // A generator `meta gen` skips is not probed: the run would report 0 files, and + // `would emit 0` reads as "nothing in your model needs this" for pages `meta docs` + // writes for every entity. The row's `door` is the answer instead. + if (opts.probe !== undefined && door === undefined) { const outcome = await probeOne( entry, project, @@ -458,6 +476,15 @@ export const PACKAGE_ONLY_NOTE = "Run one as shipped by importing its factory — the import sits under its row, except " + "for the docs layer, whose door is `meta docs`."; +/** + * Printed under every row `meta gen` does not run (a row with a `door`). One sentence, + * here: the registry's note says what an entry IS, and this says what happens to one + * wired into `generators` anyway — the same for every such row, so it is not restated + * per entry. + */ +export const SKIPPED_BY_GEN_NOTE = + "not run by `meta gen`: listed under `generators`, it is warned about and skipped."; + /** The human rendering: grouped by layer, because layer is the axis you select by. */ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string { const lines: string[] = []; @@ -477,7 +504,8 @@ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string { if (r.framework !== undefined) marks.push(r.framework); if (r.tier === "neutral") marks.push("neutral"); if (!r.source.ejectable) marks.push("package-only"); - if (r.project?.wired) marks.push("WIRED"); + if (r.door !== undefined) marks.push(`run by \`${r.door}\``); + if (r.project?.wired) marks.push(r.door !== undefined ? "WIRED but skipped by `meta gen`" : "WIRED"); if (r.source.owned) marks.push("owned"); if (r.source.runtimeCopy !== undefined) { // The adapter source this generator's output imports, compared to the package. @@ -502,6 +530,8 @@ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string { } const suffix = marks.length > 0 ? ` [${marks.join(", ")}]` : ""; lines.push(` ${r.name.padEnd(width)} — ${r.description}${suffix}`); + if (r.note !== undefined) lines.push(` ${" ".repeat(width)} ${r.note}`); + if (r.door !== undefined) lines.push(` ${" ".repeat(width)} ${SKIPPED_BY_GEN_NOTE}`); if (r.requires !== undefined && r.requires.length > 0) { lines.push(` ${" ".repeat(width)} requires: ${r.requires.join(", ")}`); } diff --git a/server/typescript/packages/cli/src/lib/doc-generator-advisory.ts b/server/typescript/packages/cli/src/lib/doc-generator-advisory.ts new file mode 100644 index 000000000..9304a3bf3 --- /dev/null +++ b/server/typescript/packages/cli/src/lib/doc-generator-advisory.ts @@ -0,0 +1,59 @@ +// Advisory: a doc generator listed under `generators` in metaobjects.config.ts. +// +// ADR-0025 made `meta docs` the single docs door, so `meta gen` warns about and skips a +// `docsFile()` / `apiDocsFile()` it finds in the suite. `meta verify --codegen` +// regenerates through that same runner and discards its warnings, so a project wired +// this way verified clean and was never told that the generator it listed produces +// nothing — nor that the gate it was running does not check documentation at all. +// +// Read off the loaded config, not off a regen: the fact is in `generators`, and asking +// the runner's own predicate keeps this from growing a second list of what is skipped. +// +// ADVISORY ONLY, like every row in this tier: nothing here reaches an exit code. + +import { + packagedDocsFactoryCall, + generatorRegistry, + isPackagedDocsGenerator, + DOCS_DOOR_COMMAND, + type MetaobjectsGenConfig, +} from "@metaobjectsdev/codegen-ts"; + +/** The file the finding is about. Not a path lookup: the config has exactly one name. */ +const CONFIG_FILE = "metaobjects.config.ts"; + +export interface DocGeneratorFinding { + file: string; + /** The command that produces what the wired generator would have. */ + construct: string; + message: string; +} + +/** + * One finding per doc generator the config wires, in config order. + * + * A `generators` entry is a constructed generator or a stable-name string (ADR-0021). + * A string resolves through the catalog to the same implementation name the runner + * tests; a name no entry registers is the runner's to refuse, not this scan's. + */ +export function scanForDocGeneratorsInGen( + config: MetaobjectsGenConfig | undefined, +): DocGeneratorFinding[] { + const findings: DocGeneratorFinding[] = []; + for (const spec of config?.generators ?? []) { + // `spec?.name`: the array is typed, but a config is user code, and a hole in it is + // the runner's to report rather than a reason for an advisory to throw. + const implName = typeof spec === "string" ? generatorRegistry[spec]?.factory().name : spec?.name; + if (implName === undefined || !isPackagedDocsGenerator(implName)) continue; + const wired = typeof spec === "string" ? `"${spec}"` : packagedDocsFactoryCall(implName); + findings.push({ + file: CONFIG_FILE, + construct: DOCS_DOOR_COMMAND, + message: + `${CONFIG_FILE}: ${wired} is listed under \`generators\`, and \`meta gen\` skips it — ` + + `\`${DOCS_DOOR_COMMAND}\` writes documentation and \`meta verify --docs\` checks it. ` + + `Remove ${wired} from \`generators\`.`, + }); + } + return findings; +} diff --git a/server/typescript/packages/cli/src/lib/manifest-ignored-check.ts b/server/typescript/packages/cli/src/lib/manifest-ignored-check.ts index 85ac17203..169a0576d 100644 --- a/server/typescript/packages/cli/src/lib/manifest-ignored-check.ts +++ b/server/typescript/packages/cli/src/lib/manifest-ignored-check.ts @@ -30,15 +30,18 @@ export const HASH_MANIFEST_REL = join(".metaobjects", ".gen-state", ".hashes.jso * Silent when: it is tracked correctly, the project is not a git repository, git is * unavailable, or no generated output has ever been produced (nothing to protect yet). * Never throws, never blocks. + * + * Returns the line it printed, so a command with a structured result can carry it + * there too; undefined when it said nothing. */ -export function warnIfManifestIgnored(cwd: string): void { +export function warnIfManifestIgnored(cwd: string): string | undefined { // Nothing generated yet ⇒ nothing at risk ⇒ nothing worth saying. - if (!existsSync(join(cwd, HASH_MANIFEST_REL))) return; + if (!existsSync(join(cwd, HASH_MANIFEST_REL))) return undefined; // honourGlobalExcludes: TRUE here, and that is the opposite call to the docs gate. // The question this warning asks is "will this file reach another machine?", and a // per-user ignore file answers it just as truly as a committed .gitignore does. - if (isGitIgnored(cwd, HASH_MANIFEST_REL, { honourGlobalExcludes: true }) !== true) return; + if (isGitIgnored(cwd, HASH_MANIFEST_REL, { honourGlobalExcludes: true }) !== true) return undefined; // WHERE the rule actually lives, asked of git rather than assumed. This used to name // `.metaobjects/.gitignore` and the literal pattern `.gen-state/`, which is only one of @@ -56,12 +59,13 @@ export function warnIfManifestIgnored(cwd: string): void { : `Find the rule with 'git check-ignore -v ${HASH_MANIFEST_REL}' and replace it with ` + `a '/*' form plus a '!' negation for the manifest`; - log.warn( + const msg = `${HASH_MANIFEST_REL} is git-ignored, so it never reaches another machine — ` + - `on a fresh clone or CI runner 'meta gen' cannot tell your hand edits from its ` + - `own stale output, and will refuse to overwrite rather than guess. ${where} ` + - `(the glob matters — git will not descend into an excluded directory, so a negation ` + - `INSIDE an excluded directory can never apply), then commit the manifest. ` + - `If you deliberately do not commit generated output, ignore this.`, - ); + `on a fresh clone or CI runner 'meta gen' cannot tell your hand edits from its ` + + `own stale output, and will refuse to overwrite rather than guess. ${where} ` + + `(the glob matters — git will not descend into an excluded directory, so a negation ` + + `INSIDE an excluded directory can never apply), then commit the manifest. ` + + `If you deliberately do not commit generated output, ignore this.`; + log.warn(msg); + return msg; } diff --git a/server/typescript/packages/cli/src/lib/output.ts b/server/typescript/packages/cli/src/lib/output.ts index e16167b84..1f2827bc1 100644 --- a/server/typescript/packages/cli/src/lib/output.ts +++ b/server/typescript/packages/cli/src/lib/output.ts @@ -71,6 +71,12 @@ export interface GenResultShape { * already correct. The output's own first line (`gen: []`) already knew. */ generatorCount: number; + /** + * The wired doc generators this run skipped (ADR-0025: their door is `meta docs`). + * Absent means none. Each also has a line in `warnings`; this is the count the + * summary and the next step are worded from. + */ + skippedDocGenerators?: readonly string[]; /** * The "now migrate" next step, worded for this project's dialect — or absent when * this run cannot have changed the database schema (see gen-migrate-advice.ts). @@ -100,13 +106,30 @@ const GEN_WORDS: Record = { removed: "REMOVED", }; +/** + * A third way to emit nothing: every wired generator is a doc generator, and `meta gen` + * skips those. Without this the run read "no entities to generate — author entities in + * this project's metadata sources" for a project that has them. + */ +function onlyDocGeneratorsWired(result: GenResultShape): boolean { + const skipped = result.skippedDocGenerators?.length ?? 0; + return result.files.length === 0 && skipped > 0 && skipped === result.generatorCount; +} + +const ONLY_DOC_GENERATORS_SUMMARY = + "every wired generator is a doc generator, which `meta gen` skips, so nothing was generated"; +const ONLY_DOC_GENERATORS_TEXT = + "Nothing was generated: every wired generator is a doc generator, and `meta gen` skips those. Run `meta docs`."; +const DOCS_DOOR_HELP = + "run `meta docs` for documentation, and remove the doc generators from `generators` in metaobjects.config.ts"; + export function formatGenResult(result: GenResultShape, opts: FormatOptions): string { const symbols = opts.isTTY ? GEN_GLYPHS : GEN_WORDS; // A value-object-only project uses no dialect — show only the outDir in that case. const header = `meta gen${result.dryRun ? " --dry-run" : ""} — ${result.dialect ? `${result.dialect}, ` : ""}${result.outDir}`; if (result.files.length === 0) { - return `${header}\n\n No entities to generate.\n`; + return `${header}\n\n ${onlyDocGeneratorsWired(result) ? ONLY_DOC_GENERATORS_TEXT : "No entities to generate."}\n`; } const lines: string[] = [header, ""]; @@ -297,7 +320,7 @@ export function genResultToData(result: GenResultShape): { // however good the metadata is, and telling its author to go write entities is a // false statement about a project that already has them. const noGenerators = result.generatorCount === 0; - const summary = result.files.length !== 0 + let summary = result.files.length !== 0 ? parts.join(", ") : noGenerators ? "no generators are wired in metaobjects.config.ts, so nothing was generated" @@ -307,6 +330,17 @@ export function genResultToData(result: GenResultShape): { : noGenerators ? ["add generators to the `generators: []` array in metaobjects.config.ts — `meta gen --list` names each one with the import that runs it as shipped, and `meta eject ` copies one into your repo to change what it emits"] : ["author entities in this project's metadata sources then re-run `meta gen`"]; + // Skipped doc generators get a next step of their own, because `help` is what an + // agent acts on. When they are ALL that was wired, they are also the whole summary. + const skippedDocs = result.skippedDocGenerators?.length ?? 0; + if (onlyDocGeneratorsWired(result)) { + summary = ONLY_DOC_GENERATORS_SUMMARY; + help.splice(0, help.length, DOCS_DOOR_HELP); + } else if (skippedDocs > 0) { + help.push( + `\`meta gen\` skipped ${skippedDocs} doc generator(s) — see warnings[]; ${DOCS_DOOR_HELP}`, + ); + } // An absent section is STATED, never omitted: a reader must be able to tell // "the scan found nothing" from "the scan never ran". const antiPatterns = result.antiPatterns diff --git a/server/typescript/packages/cli/test/catalog-listing.test.ts b/server/typescript/packages/cli/test/catalog-listing.test.ts index 6bbe53877..240b1c1ea 100644 --- a/server/typescript/packages/cli/test/catalog-listing.test.ts +++ b/server/typescript/packages/cli/test/catalog-listing.test.ts @@ -229,8 +229,13 @@ describe("--probe — what would this emit for MY model", () => { const rows = await probeRows(); for (const r of rows) { const p = r.project!; - const answered = typeof p.wouldEmit === "number" || typeof p.probeError === "string"; + // A row with a `door` is the third answer, not a silent skip: `meta gen` does not + // run that generator at all, so the row names the command that does instead of a + // count that would read as "nothing in your model needs this". + const answered = + typeof p.wouldEmit === "number" || typeof p.probeError === "string" || r.door !== undefined; expect(answered, `${r.name} reported neither a count nor a reason`).toBe(true); + if (r.door !== undefined) expect(p.wouldEmit, `${r.name} has a door and a count`).toBeNull(); } }); @@ -247,8 +252,9 @@ describe("--probe — what would this emit for MY model", () => { expect(p.wouldEmit, `${name} should not report a count`).toBeNull(); expect(String(p.probeError), `${name} should say why`).toContain(name); } - // ...and everything else still answered. - expect(rows.filter((r) => typeof r.project!.wouldEmit === "number").length) + // ...and everything else still answered: with a count, or with the door `meta gen` + // leaves that generator to (those two are not probed at all). + expect(rows.filter((r) => typeof r.project!.wouldEmit === "number" || r.door !== undefined).length) .toBeGreaterThan(rows.length - 4); }); diff --git a/server/typescript/packages/cli/test/gen-docs-door-signals.test.ts b/server/typescript/packages/cli/test/gen-docs-door-signals.test.ts new file mode 100644 index 000000000..c3af9b360 --- /dev/null +++ b/server/typescript/packages/cli/test/gen-docs-door-signals.test.ts @@ -0,0 +1,412 @@ +// ADR-0025: `meta docs` is the single docs door, so `meta gen` warns about and SKIPS a +// `docsFile()` / `apiDocsFile()` it finds under `generators`. The skip is right; the +// signal was not reaching everybody. In `json` / `toon` output — the default on a pipe, +// so the form an agent reads — the runner's warnings went to stderr only, and the +// document described a clean run that produced no API docs and gave no reason. The +// catalog (`meta gen --list`) presented `api-docs` as something to wire, and +// `meta verify` said nothing at all. +// +// Driven through the real CLI entry (`run`), one project, every output form, asserting +// WHERE the signal lands in each. +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { run } from "../src/index.js"; + +const WORKSPACE_TMP = resolve(import.meta.dirname, "fixtures", "__tmp__"); + +const MODEL = JSON.stringify({ + "metadata.root": { + package: "app", + children: [ + { + "object.entity": { + name: "Talk", + children: [ + { "source.rdb": { "@table": "talks" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "title", "@required": true } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + ], + }, + }, + ], + }, +}); + +/** `generators` is the config's array literal, verbatim. */ +function setupRepo(generators: string, opts: { extra?: string } = {}): string { + mkdirSync(WORKSPACE_TMP, { recursive: true }); + const root = mkdtempSync(join(WORKSPACE_TMP, "gen-docs-door-")); + mkdirSync(join(root, "metaobjects"), { recursive: true }); + writeFileSync(join(root, "metaobjects", "meta.app.json"), MODEL, "utf8"); + mkdirSync(join(root, ".metaobjects"), { recursive: true }); + writeFileSync(join(root, ".metaobjects", "config.json"), JSON.stringify({ schema_version: 1, sources: [] }), "utf8"); + writeFileSync( + join(root, "metaobjects.config.ts"), + ` +import { defineConfig } from "@metaobjectsdev/codegen-ts"; +import { apiDocsFile, docsFile, mermaidErDiagram } from "@metaobjectsdev/codegen-ts/generators"; +${opts.extra ?? ""} +export default defineConfig({ + outDir: "generated", + dialect: "sqlite", + dbImport: "../db", + extStyle: "none", + generators: ${generators}, +}); +`, + ); + return root; +} + +const API_DOCS_SKIP = "remove apiDocsFile() from generators. Skipped."; +const DOCS_SKIP = "remove docsFile() from generators. Skipped."; + +let out: string[]; +let err: string[]; +let origLog: typeof console.log; +let origErr: typeof console.error; +beforeEach(() => { + out = []; + err = []; + origLog = console.log; + origErr = console.error; + console.log = (...a: unknown[]) => { out.push(a.map(String).join(" ")); }; + console.error = (...a: unknown[]) => { err.push(a.map(String).join(" ")); }; +}); +afterEach(() => { + console.log = origLog; + console.error = origErr; +}); + +/** Run the CLI against a fresh project and hand back both streams. */ +async function meta(generators: string, args: string[], opts: { extra?: string } = {}): + Promise<{ code: number; stdout: string; stderr: string; root: string }> { + const root = setupRepo(generators, opts); + try { + const code = await run([...args, "--cwd", root]); + return { code, stdout: out.join("\n"), stderr: err.join("\n"), root }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +interface GenPayload { + gen: { file: string; status: string }[]; + summary: string; + help: string[]; + warnings?: string[]; +} + +interface ListRow { + name: string; + kind: string; + note?: string; + door?: string; + project?: { wired: boolean; wouldEmit: number | null; probeError?: string }; +} + +const WIRED = `["entity", apiDocsFile(), docsFile()]`; + +describe("meta gen — a skipped doc generator is reported in every output form", () => { + test("text: the skip is on stderr, and the file listing on stdout is unchanged", async () => { + const r = await meta(WIRED, ["gen", "--format", "text"]); + expect(r.code).toBe(0); + expect(r.stderr).toContain(API_DOCS_SKIP); + expect(r.stderr).toContain(DOCS_SKIP); + expect(r.stderr).toContain("meta docs"); + expect(r.stdout).toContain("Talk.ts"); + // Nothing under docs/ was written by `meta gen`. + expect(r.stdout).not.toContain("docs/"); + }); + + test("json: the document's own warnings carry the skip", async () => { + const r = await meta(WIRED, ["gen", "--format", "json"]); + expect(r.code).toBe(0); + const payload = JSON.parse(r.stdout) as GenPayload; + expect(payload.warnings?.some((w) => w.includes(API_DOCS_SKIP))).toBe(true); + expect(payload.warnings?.some((w) => w.includes(DOCS_SKIP))).toBe(true); + // ...and a next step, because `help` is what an agent acts on. + expect(payload.help.some((h) => h.includes("meta docs") && h.includes("warnings"))).toBe(true); + // The entity generator still ran. + expect(payload.gen.map((g) => g.file)).toEqual(["generated/Talk.ts"]); + // Still on stderr too, for a person watching the run. + expect(r.stderr).toContain(API_DOCS_SKIP); + }); + + test("toon: the document's own warnings carry the skip", async () => { + const r = await meta(WIRED, ["gen", "--format", "toon"]); + expect(r.code).toBe(0); + expect(r.stdout).toMatch(/^warnings\[2\]:/m); + expect(r.stdout).toContain(API_DOCS_SKIP); + expect(r.stdout).toContain(DOCS_SKIP); + }); + + test("no --format on a pipe (the agent default) is toon, and it carries the skip", async () => { + // The default is chosen from stdout being a TTY, so pin it: this test must mean the + // same thing run from a terminal as it does in CI. + const tty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); + try { + const r = await meta(WIRED, ["gen"]); + expect(r.code).toBe(0); + expect(r.stdout).toMatch(/^gen\[1\]/m); + expect(r.stdout).toContain(API_DOCS_SKIP); + } finally { + if (tty !== undefined) Object.defineProperty(process.stdout, "isTTY", tty); + else delete (process.stdout as { isTTY?: boolean }).isTTY; + } + }); + + test("--dry-run: the preview carries the skip and writes nothing", async () => { + const root = setupRepo(WIRED); + try { + expect(await run(["gen", "--dry-run", "--format", "json", "--cwd", root])).toBe(0); + const payload = JSON.parse(out.join("\n")) as GenPayload; + expect(payload.warnings?.some((w) => w.includes(API_DOCS_SKIP))).toBe(true); + expect(existsSync(join(root, "generated"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("a run that skips nothing has no warnings key and no docs next step", async () => { + const r = await meta(`["entity"]`, ["gen", "--format", "json"]); + expect(r.code).toBe(0); + const payload = JSON.parse(r.stdout) as GenPayload; + expect(payload.warnings).toBeUndefined(); + expect(payload.help.some((h) => h.includes("meta docs"))).toBe(false); + }); +}); + +describe("meta gen — mermaid-er is a generator `meta gen` runs", () => { + // The other half of the same ruling: what `meta docs` runs is skipped here, and what + // it does not run is not. Every surface has to give the same answer. + test("mermaidErDiagram() writes its file and draws no skip warning", async () => { + const r = await meta(`["entity", mermaidErDiagram()]`, ["gen", "--format", "json"]); + expect(r.code).toBe(0); + const payload = JSON.parse(r.stdout) as GenPayload; + expect(payload.gen.map((g) => g.file)).toContain("generated/docs/model.md"); + expect(payload.warnings).toBeUndefined(); + expect(r.stderr).not.toContain("Skipped."); + }); + + test("the stable name selects it, as it does every other generator `meta gen` runs", async () => { + // It was refused as "neutral (owned by 'meta docs')" while the factory beside it ran. + const r = await meta(`["entity", "mermaid-er"]`, ["gen", "--format", "json"]); + expect(r.code).toBe(0); + const payload = JSON.parse(r.stdout) as GenPayload; + expect(payload.gen.map((g) => g.file)).toContain("generated/docs/model.md"); + }); + + test("the stable name `docs` is still refused: `meta docs` owns it", async () => { + const r = await meta(`["entity", "docs"]`, ["gen", "--format", "json"]); + expect(r.code).not.toBe(0); + expect(r.stderr).toContain(`generator "docs" is neutral (owned by 'meta docs')`); + }); +}); + +describe("meta gen — the structured result carries EVERY warning the run printed", () => { + // Not only the doc-generator one: every runner warning went to stderr alone. + test("a generator's own ctx.warn reaches the document", async () => { + const extra = ` +const warner = { + name: "warner", + generate: (ctx: { warn: (m: string) => void }) => { ctx.warn("hello from warner"); return []; }, +};`; + const r = await meta(`["entity", warner]`, ["gen", "--format", "json"], { extra }); + expect(r.code).toBe(0); + const payload = JSON.parse(r.stdout) as GenPayload; + expect(payload.warnings).toContain("[warner] hello from warner"); + // Every line the document carries was also printed, and nothing printed as a + // warning is missing from the document. + for (const w of payload.warnings ?? []) expect(r.stderr).toContain(w); + }); + + test("the CLI's own advisories ride in it too (stale agent context)", async () => { + const root = setupRepo(`["entity"]`); + try { + writeFileSync( + join(root, ".metaobjects", ".agent-context.json"), + JSON.stringify({ version: 1, generatedBy: "0.0.1", servers: [], clients: [], files: {} }), + "utf8", + ); + expect(await run(["gen", "--format", "json", "--cwd", root])).toBe(0); + const payload = JSON.parse(out.join("\n")) as GenPayload; + expect(payload.warnings?.some((w) => w.includes("agent context was generated by 0.0.1"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("meta gen — a config wiring ONLY doc generators is not told to author entities", () => { + // It emitted nothing and reported "no entities to generate … author entities in this + // project's metadata sources", for a project that has them. + const ONLY_DOCS = `[apiDocsFile(), docsFile()]`; + + test("json: the summary and the next step name the real reason", async () => { + const r = await meta(ONLY_DOCS, ["gen", "--format", "json"]); + expect(r.code).toBe(0); + const payload = JSON.parse(r.stdout) as GenPayload; + expect(payload.gen).toEqual([]); + expect(payload.summary).toContain("doc generator"); + expect(payload.summary).not.toContain("no entities"); + expect(payload.help.join("\n")).toContain("meta docs"); + expect(payload.help.join("\n")).not.toContain("author entities"); + expect(payload.warnings).toHaveLength(2); + }); + + test("text: stdout names the real reason", async () => { + const r = await meta(ONLY_DOCS, ["gen", "--format", "text"]); + expect(r.code).toBe(0); + expect(r.stdout).not.toContain("No entities to generate"); + expect(r.stdout).toContain("meta docs"); + }); + + test("a real generator that emits nothing still reads as no entities", async () => { + const extra = `const quiet = { name: "quiet", generate: () => [] };`; + const r = await meta(`[quiet, apiDocsFile()]`, ["gen", "--format", "json"], { extra }); + const payload = JSON.parse(r.stdout) as GenPayload; + expect(payload.summary).toContain("no entities to generate"); + }); +}); + +describe("meta gen --list — a row `meta gen` does not run says so", () => { + const rowsOf = (stdout: string): ListRow[] => JSON.parse(stdout) as ListRow[]; + const row = (rows: ListRow[], name: string): ListRow => { + const found = rows.find((r) => r.kind === "generator" && r.name === name); + if (found === undefined) throw new Error(`no ${name} row`); + return found; + }; + /** The row line for `name`, and the lines indented under it. */ + const block = (text: string, name: string): string => { + const lines = text.split("\n"); + const at = lines.findIndex((l) => l.startsWith(` ${name} `)); + if (at === -1) return ""; + let end = at + 1; + while (end < lines.length && /^ {6,}\S/.test(lines[end] ?? "")) end++; + return lines.slice(at, end).join("\n"); + }; + + test("text: api-docs and docs name `meta docs` as their door, on the row and under it", async () => { + const r = await meta(`["entity"]`, ["gen", "--list", "--format", "text"]); + expect(r.code).toBe(0); + for (const name of ["api-docs", "docs"]) { + const b = block(r.stdout, name); + expect(b, name).toContain("run by `meta docs`"); + expect(b, name).toContain("not run by `meta gen`"); + expect(b, name).toContain("warned about and skipped"); + } + // A generator `meta gen` runs carries neither. + expect(block(r.stdout, "entity")).not.toContain("meta docs"); + }); + + // `meta docs` has never run mermaid-er: `meta gen` writes its file. The row said + // "surfaced via `meta docs`" (in the registry; nothing printed it), so the one + // docs-layer generator that belongs in `generators` read as one that does not. + test("text: mermaid-er is run by `meta gen`, and its row says so", async () => { + const r = await meta(`["entity"]`, ["gen", "--list", "--format", "text"]); + const mermaid = block(r.stdout, "mermaid-er"); + expect(mermaid).toContain("run by `meta gen`"); + expect(mermaid).not.toContain("run by `meta docs`"); + expect(mermaid).not.toContain("not run by `meta gen`"); + // ...while still telling the reader the diagram is already on the overview page. + expect(mermaid).toContain("`meta docs` already draws the same diagram"); + }); + + test("json: the row carries the door and the note", async () => { + const r = await meta(`["entity"]`, ["gen", "--list", "--format", "json"]); + expect(r.code).toBe(0); + const rows = rowsOf(r.stdout); + for (const name of ["api-docs", "docs"]) { + // `door` is the machine-readable half: present means `meta gen` does not run it. + expect(row(rows, name).door, name).toBe("meta docs"); + expect(row(rows, name).note ?? "", name).toContain("meta docs"); + } + expect(row(rows, "mermaid-er").door).toBeUndefined(); + expect(row(rows, "mermaid-er").note ?? "").toContain("run by `meta gen`"); + expect(row(rows, "entity").door).toBeUndefined(); + expect(row(rows, "entity").note).toBeUndefined(); + }); + + test("a wired doc generator reads as skipped, not merely WIRED", async () => { + const r = await meta(WIRED, ["gen", "--list", "--format", "text"]); + expect(block(r.stdout, "api-docs")).toContain("WIRED but skipped by `meta gen`"); + expect(block(r.stdout, "docs")).toContain("WIRED but skipped by `meta gen`"); + expect(block(r.stdout, "entity")).toContain("WIRED"); + expect(block(r.stdout, "entity")).not.toContain("skipped"); + }); + + test("--probe: no file count for a generator `meta gen` does not run", async () => { + // It printed `would emit 0`, which reads as "nothing in your model needs this" for + // a generator whose pages `meta docs` writes for every entity. + const text = await meta(WIRED, ["gen", "--list", "--probe", "--format", "text"]); + expect(text.code).toBe(0); + for (const name of ["api-docs", "docs"]) { + expect(block(text.stdout, name), name).not.toContain("would emit"); + expect(block(text.stdout, name), name).toContain("run by `meta docs`"); + } + expect(block(text.stdout, "entity")).toContain("would emit 1"); + expect(block(text.stdout, "mermaid-er")).toContain("would emit 1"); + }); + + test("--probe --format json: wouldEmit is null beside the door, never 0", async () => { + const r = await meta(WIRED, ["gen", "--list", "--probe", "--format", "json"]); + expect(r.code).toBe(0); + const rows = rowsOf(r.stdout); + for (const name of ["api-docs", "docs"]) { + const p = row(rows, name).project; + expect(p?.wouldEmit, name).toBeNull(); + expect(p?.probeError, name).toBeUndefined(); + expect(p?.wired, name).toBe(true); + expect(row(rows, name).door, name).toBe("meta docs"); + } + expect(row(rows, "entity").project?.wouldEmit).toBe(1); + }); +}); + +describe("meta verify — a doc generator under `generators` is an advisory", () => { + interface VerifyPayload { + exitCode: number; + help: string[]; + antiPatterns: { status: string; total: number; rows: { file: string; rule: string; construct: string; message: string }[] }; + } + + test("json: one advisory row per wired doc generator, and a next step", async () => { + const r = await meta(WIRED, ["verify", "--format", "json"]); + expect(r.code).toBe(0); + const payload = JSON.parse(r.stdout) as VerifyPayload; + const rows = payload.antiPatterns.rows.filter((x) => x.rule === "doc-generator-in-gen"); + expect(rows.map((x) => x.message).join("\n")).toContain("apiDocsFile()"); + expect(rows.map((x) => x.message).join("\n")).toContain("docsFile()"); + expect(rows).toHaveLength(2); + for (const x of rows) { + expect(x.file).toBe("metaobjects.config.ts"); + expect(x.construct).toBe("meta docs"); + } + expect(payload.help.some((h) => h.includes("doc-generator-in-gen"))).toBe(true); + // Advisory: it never reaches the exit code. + expect(payload.exitCode).toBe(0); + }); + + test("text: the advisory is printed, and names the docs gate", async () => { + const r = await meta(WIRED, ["verify", "--format", "text"]); + expect(r.code).toBe(0); + expect(r.stderr).toContain("apiDocsFile()"); + expect(r.stderr).toContain("meta verify --docs"); + }); + + test("a stable-name string is caught too", async () => { + const r = await meta(`["entity", "api-docs"]`, ["verify", "--format", "json"]); + const payload = JSON.parse(r.stdout) as VerifyPayload; + expect(payload.antiPatterns.rows.filter((x) => x.rule === "doc-generator-in-gen")).toHaveLength(1); + }); + + test("a config with no doc generator gets no such row", async () => { + const r = await meta(`["entity", mermaidErDiagram()]`, ["verify", "--format", "json"]); + const payload = JSON.parse(r.stdout) as VerifyPayload; + expect(payload.antiPatterns.rows.filter((x) => x.rule === "doc-generator-in-gen")).toEqual([]); + }); +}); diff --git a/server/typescript/packages/codegen-ts/src/generator-registry.ts b/server/typescript/packages/codegen-ts/src/generator-registry.ts index a0bbb4ea2..f40957a27 100644 --- a/server/typescript/packages/codegen-ts/src/generator-registry.ts +++ b/server/typescript/packages/codegen-ts/src/generator-registry.ts @@ -98,7 +98,9 @@ export interface GeneratorRegistryEntry { layer: Layer; /** One-line (no newline) human description for `--list`. */ description: string; - /** "native" = recommended `meta gen` suite; "neutral" = `meta docs`-owned. */ + /** "native" = idiomatic per-port emission; "neutral" = a language-neutral artifact. + * Which COMMAND runs an entry is a separate fact, and not derivable from this: + * `api-docs` is native and run by `meta docs`, `mermaid-er` neutral and run by `meta gen`. */ tier: GeneratorTier; /** Constructs the generator with sensible defaults. Calling it must not throw. */ factory: () => Generator; @@ -278,6 +280,7 @@ export const generatorRegistry: Record = { layer: "docs", description: "Per-entity/template SDK API reference (the generated code's API, human + agent forms).", + note: "the api surface of `meta docs` (the single docs door, ADR-0025), which runs it with nothing wired.", tier: "native", factory: () => apiDocsFile(), factoryExport: "apiDocsFile", @@ -304,7 +307,7 @@ export const generatorRegistry: Record = { factory: () => mermaidErDiagram(), factoryExport: "mermaidErDiagram", ejectable: ejectable("mermaid-er"), - note: "neutral artifact owned by the docs engine (ADR-0020); surfaced via `meta docs`, not the recommended `meta gen` native suite.", + note: "run by `meta gen`, which writes the diagram as a file of its own (docs/model.md). `meta docs` already draws the same diagram on its overview page with nothing wired, which is what most projects want.", }, // ----- capability ------------------------------------------------------- diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index a06facfc2..99a5e48a5 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -55,6 +55,10 @@ export { stableNameIndex, } from "./catalog-gates.js"; +// ADR-0025 — which generators `meta gen` skips because their door is `meta docs`. +// Public so the CLI catalog says so on the row from the runner's own definition. +export { isPackagedDocsGenerator, packagedDocsFactoryCall, DOCS_DOOR_COMMAND } from "./packaged-docs-generators.js"; + export type { MetaobjectsGenConfig, NormalizedMetaobjectsGenConfig, ResolvedGenConfig, Dialect, ExtStyle, ColumnNamingStrategy, MetaDataTypeProvider, GeneratorSpec, DocsConfig, ResolvedDocsConfig, DocsSurface, ApiSurface, VerifyConfig } from "./metaobjects-config.js"; export { defineConfig, normalizeConfig, resolveGenerators, resolveDocsConfig } from "./metaobjects-config.js"; // The dialect every generator falls back to when a config declares none. Exported because diff --git a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts index 081c02265..6d53d8076 100644 --- a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts +++ b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts @@ -4,6 +4,7 @@ import type { Generator } from "./generator.js"; import type { ExtStyle } from "./render-context.js"; import type { OutputLayout, ResolvedTarget } from "./import-path.js"; import { generatorRegistry } from "./generator-registry.js"; +import { DOCS_DOOR_COMMAND, isPackagedDocsGenerator } from "./packaged-docs-generators.js"; import { normalizeTimestampMode } from "./dialect-module.js"; /** @@ -375,24 +376,30 @@ export function resolveTargets(config: MetaobjectsGenConfig): Record { if (typeof spec !== "string") return spec; const entry = generatorRegistry[spec]; if (entry === undefined) { - const native = Object.values(generatorRegistry) - .filter((e) => e.tier === "native") + // What `meta gen` RUNS, so a generator it skips is not offered: the list named + // `api-docs`, and the run it recommended then warned about and skipped it. + const runnable = Object.values(generatorRegistry) + .filter((e) => !isPackagedDocsGenerator(e.factory().name)) .map((e) => e.name) .sort(); throw new Error( - `unknown generator "${spec}". Available native generators: ${native.join(", ")}.`, + `unknown generator "${spec}". Available generators: ${runnable.join(", ")}. ` + + `Documentation is not a 'meta gen' generator: run '${DOCS_DOOR_COMMAND}'.`, ); } - if (entry.tier !== "native") { + if (entry.tier !== "native" && isPackagedDocsGenerator(entry.factory().name)) { throw new Error( `generator "${spec}" is neutral (owned by 'meta docs'); ` + `not selectable in the gen suite.`, diff --git a/server/typescript/packages/codegen-ts/src/packaged-docs-generators.ts b/server/typescript/packages/codegen-ts/src/packaged-docs-generators.ts new file mode 100644 index 000000000..141b5b125 --- /dev/null +++ b/server/typescript/packages/codegen-ts/src/packaged-docs-generators.ts @@ -0,0 +1,42 @@ +// ADR-0025 — the packaged doc generators `meta docs` runs and `meta gen` therefore skips. +// +// ONE definition, read by everything that has to agree about it: the runner (which +// warns about and skips one found in a `meta gen` config), `resolveGenerators` (which +// must not offer one as a selectable name) and the CLI catalog (whose row says the +// door is `meta docs`). They used to answer separately — the runner skipped +// `api-docs` while the unknown-name error offered it and `meta gen --list` presented +// it as something to wire. +// +// Keyed by the generator's IMPLEMENTATION name, because that is what a constructed +// generator carries. The value is the factory call an adopter wrote, for the message. +// +// `mermaid-er-diagram` is NOT one of them, and that is a ruling rather than an +// omission: `meta docs` has never run it. `meta docs` draws the ER diagram on its +// overview page through `docsFile()`; `mermaidErDiagram()` writes the same diagram as a +// file of its own, and `meta gen` is the only command that writes that file. It sits +// in the `docs` layer by what it produces, not by which command runs it. +const PACKAGED_DOCS_FACTORY_CALLS: ReadonlyMap = new Map([ + ["docs-file", "docsFile()"], + ["api-docs", "apiDocsFile()"], +]); + +/** The command that produces what these generators emit. */ +export const DOCS_DOOR_COMMAND = "meta docs"; + +/** True iff `meta gen` warns about and skips the generator with this implementation name. */ +export function isPackagedDocsGenerator(implName: string): boolean { + return PACKAGED_DOCS_FACTORY_CALLS.has(implName); +} + +/** The factory call an adopter wrote to wire it (`apiDocsFile()`), for a message. */ +export function packagedDocsFactoryCall(implName: string): string { + return PACKAGED_DOCS_FACTORY_CALLS.get(implName) ?? implName; +} + +/** The runner's warning for one skipped doc generator. */ +export function packagedDocsSkipWarning(implName: string): string { + return ( + `[${implName}] docs are produced by '${DOCS_DOOR_COMMAND}' (ADR-0025); ` + + `remove ${packagedDocsFactoryCall(implName)} from generators. Skipped.` + ); +} diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts index 93856bb9a..92c6713e6 100644 --- a/server/typescript/packages/codegen-ts/src/runner.ts +++ b/server/typescript/packages/codegen-ts/src/runner.ts @@ -43,15 +43,12 @@ import { } from "./catalog-gates.js"; import { libraryManifests, splitLayerToken } from "@metaobjectsdev/metadata/library"; import { generatorRegistry, type GeneratorRegistryEntry } from "./generator-registry.js"; +import { packagedDocsSkipWarning, isPackagedDocsGenerator } from "./packaged-docs-generators.js"; /** JS-identifier-shape only. Prevents filesystem traversal when metadata comes * from untrusted sources (e.g. MCP). Mirrors the guard in legacy generate.ts. */ const VALID_ENTITY_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; -/** ADR-0025: doc generators whose single door is `meta docs`. If a `meta gen` - * config still lists one (by its stable `name`), the runner warns + skips it. */ -const DEPRECATED_DOC_GENERATORS = new Set(["docs-file", "api-docs"]); - export interface RunGenOpts { config: MetaobjectsGenConfig; metadata: MetaData; @@ -152,6 +149,13 @@ export interface RunGenResult { /** Subset of `files` with status "conflict" — surfaced separately so the * CLI can print the end-of-run summary. */ conflicts: WriteResult[]; + /** + * Implementation names of the doc generators this run did NOT execute, in config + * order (ADR-0025 — see packaged-docs-generators.ts). Each also has a line in `warnings`; this is + * the same fact for a caller that must decide something from it, so that nobody + * has to pattern-match a sentence to learn that a wired generator produced nothing. + */ + skippedDocGenerators: string[]; } /** @@ -404,7 +408,7 @@ export async function runGen(opts: RunGenOpts): Promise { reason = "no object children match the configured scope"; } warnings.push(`No entities to generate — ${reason}.`); - return { files: [], warnings, conflicts: [] }; + return { files: [], warnings, conflicts: [], skippedDocGenerators: [] }; } // FR-044: a served report (Table A) is generated from its read model, which the @@ -418,7 +422,7 @@ export async function runGen(opts: RunGenOpts): Promise { "served (it is abstract, or its read source is not a `source.rdb @kind: view`), " + "which generates nothing.", ); - return { files: [], warnings, conflicts: [] }; + return { files: [], warnings, conflicts: [], skippedDocGenerators: [] }; } const safeEntities: MetaObject[] = []; @@ -432,7 +436,7 @@ export async function runGen(opts: RunGenOpts): Promise { safeEntities.push(entity); } if (safeEntities.length === 0) { - return { files: [], warnings, conflicts: [] }; + return { files: [], warnings, conflicts: [], skippedDocGenerators: [] }; } // Refuse before any generator runs — the one choke point that sees the WHOLE @@ -677,16 +681,15 @@ 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(); + const skippedDocGenerators: string[] = []; for (const generator of config.generators) { // ADR-0025: `meta docs` is the single docs door. A `meta gen` config that // still lists a deprecated doc generator is warned + skipped, not run — the // generator stays as `meta docs`'s internal engine. - if (DEPRECATED_DOC_GENERATORS.has(generator.name)) { - warnings.push( - `[${generator.name}] docs are produced by 'meta docs' (ADR-0025); ` + - `remove ${generator.name === "api-docs" ? "apiDocsFile()" : "docsFile()"} from generators. Skipped.`, - ); + if (isPackagedDocsGenerator(generator.name)) { + warnings.push(packagedDocsSkipWarning(generator.name)); + skippedDocGenerators.push(generator.name); continue; } const selfTarget = targetOf(generator); @@ -1044,7 +1047,7 @@ export async function runGen(opts: RunGenOpts): Promise { // A preview that hides a pending deletion is worse than no preview at all, so // the sweep still runs — in decide-and-report mode, touching nothing. sweep(true); - return { files: writes, warnings, conflicts }; + return { files: writes, warnings, conflicts, skippedDocGenerators }; } for (const file of emitted) { @@ -1078,5 +1081,5 @@ export async function runGen(opts: RunGenOpts): Promise { // detect an engine change. Written after a successful run only. if (installedEngine !== undefined) saveEngineVersion(genStateDir, installedEngine); - return { files: writes, warnings, conflicts }; + return { files: writes, warnings, conflicts, skippedDocGenerators }; } diff --git a/server/typescript/packages/codegen-ts/test/generator-registry.test.ts b/server/typescript/packages/codegen-ts/test/generator-registry.test.ts index 66dd4cf5d..bb447bad0 100644 --- a/server/typescript/packages/codegen-ts/test/generator-registry.test.ts +++ b/server/typescript/packages/codegen-ts/test/generator-registry.test.ts @@ -7,6 +7,7 @@ import { type GeneratorRegistryEntry, } from "../src/generator-registry.js"; import { REFERENCE_GENERATOR_NAMES } from "../src/reference-templates.js"; +import { isPackagedDocsGenerator } from "../src/packaged-docs-generators.js"; // ADR-0021 D3 — stable-name generator registry. The stable names are the // cross-port contract; this test pins them + asserts each entry carries a @@ -76,6 +77,27 @@ describe("generator-registry (ADR-0021 D3)", () => { expect((generatorRegistry["mermaid-er"]?.note ?? "").length).toBeGreaterThan(0); }); + // Resolved, not listed: the set is whatever the RUNNER skips. `api-docs` is native, + // so the neutral check above never reached it, and its row presented a generator + // `meta gen` refuses to run as something to wire. + test("every entry `meta gen` skips says on its row that the door is `meta docs`", () => { + const skipped = Object.values(generatorRegistry).filter((e) => isPackagedDocsGenerator(e.factory().name)); + expect(skipped.map((e) => e.name).sort()).toEqual(["api-docs", "docs"]); + for (const entry of skipped) { + expect(entry.note ?? "", `${entry.name}.note`).toContain("meta docs"); + expect(entry.layer, `${entry.name}.layer`).toBe("docs"); + } + }); + + // The other direction. `mermaid-er` sits in the docs layer and `meta docs` has never + // run it: `meta gen` writes its file. Its note pointed at `meta docs` all the same. + test("a docs-layer entry `meta gen` runs says so, and does not name `meta docs` as its door", () => { + const run = Object.values(generatorRegistry) + .filter((e) => e.layer === "docs" && !isPackagedDocsGenerator(e.factory().name)); + expect(run.map((e) => e.name)).toEqual(["mermaid-er"]); + expect(run[0]?.note ?? "").toContain("run by `meta gen`"); + }); + test("every factory constructs a Generator with a name without throwing", () => { for (const [id, entry] of Object.entries(generatorRegistry)) { const gen = entry.factory(); diff --git a/server/typescript/packages/codegen-ts/test/generator-spec-resolution.test.ts b/server/typescript/packages/codegen-ts/test/generator-spec-resolution.test.ts index 44865ec9d..181f85a49 100644 --- a/server/typescript/packages/codegen-ts/test/generator-spec-resolution.test.ts +++ b/server/typescript/packages/codegen-ts/test/generator-spec-resolution.test.ts @@ -6,6 +6,7 @@ import { runGen } from "../src/runner.js"; import { defineConfig, resolveGenerators } from "../src/metaobjects-config.js"; import { entityFile } from "../src/generators/entity-file.js"; import { routesFile } from "../src/generators/routes-file.js"; +import { mermaidErDiagram } from "../src/generators/mermaid-er.js"; import { MetaDataLoader } from "@metaobjectsdev/metadata"; import { FileSource } from "@metaobjectsdev/metadata/core"; @@ -48,18 +49,20 @@ describe("resolveGenerators (registry string resolution)", () => { expect(gens[1]).toBe(factory); }); - test("unknown name → error listing available native names", () => { + test("unknown name → error listing the names meta gen runs", () => { expect(() => resolveGenerators(["not-a-generator"])) .toThrow(/unknown generator "not-a-generator"/); - // Lists native names so the user can correct it. + // Lists the selectable names so the user can correct it. try { resolveGenerators(["not-a-generator"]); } catch (err) { const msg = (err as Error).message; expect(msg).toContain("entity"); expect(msg).toContain("routes"); - // neutral names are NOT advertised as selectable - expect(msg).not.toContain("mermaid-er"); + // What `meta docs` runs is NOT advertised as selectable. (Substring-safe: neither + // `api-docs` nor a bare `docs` entry appears in any name `meta gen` runs.) + expect(msg).not.toContain("api-docs"); + expect(msg).not.toMatch(/[ ,]docs[,.]/); } }); @@ -68,9 +71,13 @@ describe("resolveGenerators (registry string resolution)", () => { .toThrow(/generator "docs" is neutral \(owned by 'meta docs'\); not selectable in the gen suite/); }); - test('neutral name "mermaid-er" → neutral error', () => { - expect(() => resolveGenerators(["mermaid-er"])) - .toThrow(/is neutral \(owned by 'meta docs'\); not selectable in the gen suite/); + // It threw "is neutral (owned by 'meta docs')". `meta docs` has never run this + // generator: `meta gen` writes its file, and its factory wired in the same array ran. + // The name now resolves to that factory. + test('neutral name "mermaid-er" resolves: meta gen is the command that runs it', () => { + const gens = resolveGenerators(["mermaid-er"]); + expect(gens).toHaveLength(1); + expect(gens[0]!.name).toBe(mermaidErDiagram().name); }); }); diff --git a/server/typescript/packages/codegen-ts/test/runner-docs-shim.test.ts b/server/typescript/packages/codegen-ts/test/runner-docs-shim.test.ts index b5aad6aa4..daa0f3472 100644 --- a/server/typescript/packages/codegen-ts/test/runner-docs-shim.test.ts +++ b/server/typescript/packages/codegen-ts/test/runner-docs-shim.test.ts @@ -6,9 +6,10 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { runGen } from "../src/runner.js"; -import { defineConfig } from "../src/metaobjects-config.js"; +import { defineConfig, resolveGenerators } from "../src/metaobjects-config.js"; import { perEntity, type Generator } from "../src/generator.js"; -import { apiDocsFile } from "../src/generators/index.js"; +import { apiDocsFile, docsFile, mermaidErDiagram } from "../src/generators/index.js"; +import { isPackagedDocsGenerator } from "../src/packaged-docs-generators.js"; import { MetaDataLoader } from "@metaobjectsdev/metadata"; import { FileSource } from "@metaobjectsdev/metadata/core"; @@ -51,6 +52,99 @@ describe("runGen — deprecated doc generators are warned + skipped (ADR-0025)", // the normal generator still ran. expect(result.files.some((f) => f.path.endsWith("Post.alpha.ts"))).toBe(true); }); + + // The warning is prose. A caller that has to DECIDE something from the skip — the CLI + // wording its "nothing was generated" summary, `verify --codegen`, the catalog probe — + // needs the fact itself, not a regex over a sentence. + test("the result names every skipped doc generator, in config order", async () => { + const loader = new MetaDataLoader(); + const { root } = await loader.load([new FileSource(FIXTURE)]); + + const result = await runGen({ + config: defineConfig({ + outDir: tmp, extStyle: "none", dbImport: "../db", dialect: "sqlite", + generators: [apiDocsFile(), docsFile()], + }), + metadata: root, + }); + + expect(result.skippedDocGenerators).toEqual(["api-docs", "docs-file"]); + expect(result.files).toEqual([]); + // One warning per skipped generator, each naming the factory call to remove. + expect(result.warnings.filter((w) => w.includes("Skipped."))).toHaveLength(2); + expect(result.warnings.some((w) => w.includes("remove docsFile() from generators"))).toBe(true); + expect(result.warnings.some((w) => w.includes("remove apiDocsFile() from generators"))).toBe(true); + }); + + test("a run with no doc generator skips nothing", async () => { + const loader = new MetaDataLoader(); + const { root } = await loader.load([new FileSource(FIXTURE)]); + const normal: Generator = { + name: "alpha", + generate: perEntity((e) => ({ path: `${e.name}.alpha.ts`, content: "// alpha" })), + }; + + const result = await runGen({ + config: defineConfig({ + outDir: tmp, extStyle: "none", dbImport: "../db", dialect: "sqlite", + generators: [normal], + }), + metadata: root, + }); + + expect(result.skippedDocGenerators).toEqual([]); + }); + + // `mermaidErDiagram()` is deprecated under `meta gen` but still RUNS (it is kept for + // configs that already list it), so it is not a skipped generator and must not be + // reported as one. + test("mermaidErDiagram() still runs and is not reported as skipped", async () => { + const loader = new MetaDataLoader(); + const { root } = await loader.load([new FileSource(FIXTURE)]); + + const result = await runGen({ + config: defineConfig({ + outDir: tmp, extStyle: "none", dbImport: "../db", dialect: "sqlite", + generators: [mermaidErDiagram()], + }), + metadata: root, + }); + + expect(result.skippedDocGenerators).toEqual([]); + expect(result.files.map((f) => relPath(tmp, f.path))).toEqual(["docs/model.md"]); + }); +}); + +describe("isPackagedDocsGenerator — the one definition of what `meta gen` skips", () => { + test("is keyed by the generator's implementation name", () => { + expect(isPackagedDocsGenerator(apiDocsFile().name)).toBe(true); + expect(isPackagedDocsGenerator(docsFile().name)).toBe(true); + expect(isPackagedDocsGenerator(mermaidErDiagram().name)).toBe(false); + expect(isPackagedDocsGenerator("entity-file")).toBe(false); + }); +}); + +describe("resolveGenerators — a doc generator is not offered as a `meta gen` name", () => { + // The unknown-name error lists what CAN be selected. It used to list `api-docs`, + // which `meta gen` then warns about and skips — an error message recommending the + // exact wiring the run rejects. + test("the unknown-name listing leaves out every generator `meta gen` skips", () => { + let message = ""; + try { resolveGenerators(["no-such-generator"]); } + catch (err) { message = err instanceof Error ? err.message : String(err); } + + expect(message).toContain(`unknown generator "no-such-generator"`); + const listed = (/Available generators: ([^.]*)\./.exec(message)?.[1] ?? "") + .split(",").map((s) => s.trim()); + expect(listed).toContain("entity"); + expect(listed).not.toContain("api-docs"); + expect(listed).not.toContain("docs"); + // `mermaid-er` is neutral and IS in the list: `meta gen` runs it. + expect(listed).toContain("mermaid-er"); + // ...and says where documentation comes from, so the reader is not left guessing + // why a catalog name is missing from the list. + expect(message).toContain("meta docs"); + }); }); function relPath(base: string, full: string): string { From e7be03ac03a6411f8b37dbb09cfecc2e90ce0dcd Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 10:43:48 -0400 Subject: [PATCH 2/3] no-mistakes(review): restore mermaid-er refusal; report refused names as refused Assisted-by: no-mistakes:claude:claude-opus-5-5 --- CHANGELOG.md | 23 ++- .../references/typescript-docs.md | 9 +- docs/features/cli.md | 7 +- .../references/typescript-docs.md | 9 +- .../references/typescript-docs.md | 9 +- .../generator-registry-conformance/README.md | 2 +- .../registry.json | 4 +- .../packages/cli/src/commands/verify.ts | 9 +- .../packages/cli/src/lib/advisory.ts | 13 +- .../packages/cli/src/lib/catalog-listing.ts | 27 +++- .../cli/src/lib/doc-generator-advisory.ts | 52 +++++-- .../cli/test/gen-docs-door-signals.test.ts | 137 +++++++++++++++--- .../codegen-ts/src/generator-registry.ts | 6 +- .../packages/codegen-ts/src/index.ts | 2 +- .../codegen-ts/src/metaobjects-config.ts | 29 ++-- .../src/packaged-docs-generators.ts | 8 +- .../test/generator-registry.test.ts | 13 +- .../test/generator-spec-resolution.test.ts | 21 +-- .../codegen-ts/test/runner-docs-shim.test.ts | 6 +- 19 files changed, 267 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f10fb1cbb..14942f67d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,24 +32,23 @@ here.** docs` `` with the reason under the row, a wired one reads ``WIRED but skipped by `meta gen` ``, and `--probe` no longer prints `would emit 0` for them. In `--format json` the row carries `note` and `door: "meta docs"`, and `project.wouldEmit` is `null` instead of `0`. + The `mermaid-er` row's note now states the back-compat fact: it is deprecated, a + `mermaidErDiagram()` already wired in `generators` still runs, and `meta docs` draws the + same diagram on its overview page. A neutral name wired as a string (`"docs"`, + `"mermaid-er"`), which `meta gen` refuses, reads ``WIRED by name, which `meta gen` + refuses`` (`project.refused` in `--format json`). - **`meta verify`** said nothing about a config wired this way. It now reports each one as an advisory row (rule `doc-generator-in-gen`, in `antiPatterns.rows[]`) that names - `meta verify --docs` as the gate that checks documentation. Advisory: the exit code is - unchanged. + `meta verify --docs` as the gate that checks documentation. A neutral name wired as a + string is reported under rule `doc-generator-refused-by-gen`, because `meta gen` fails on + it rather than skipping it. Advisory: the exit code is unchanged. - **The unknown-generator error** listed `api-docs` among the names `meta gen` accepts. It no longer does, and it says documentation comes from `meta docs`. - - **`mermaid-er` is stated everywhere as what it is: a generator `meta gen` runs.** - `meta docs` has never run it. `meta docs` draws the ER diagram on its overview page, and - `mermaidErDiagram()` writes the same diagram as a file of its own (`docs/model.md`), which - only `meta gen` writes. Its catalog note said "surfaced via `meta docs`", and the stable - name was refused in `generators` as `neutral (owned by 'meta docs')` while the factory - wired in the same array ran. The note now says which command runs it and that the - overview page already has the diagram, and `generators: ["mermaid-er"]` resolves to the - factory. `"docs"` is still refused: `meta docs` does own that one. `runGen()` returns a new `skippedDocGenerators: string[]` beside `warnings`, and - `@metaobjectsdev/codegen-ts` exports `isPackagedDocsGenerator`, so a caller embedding the - runner can ask rather than match a sentence. TypeScript only: in the C#, Java, Kotlin and Python + `@metaobjectsdev/codegen-ts` exports `isPackagedDocsGenerator` (skipped) and + `isNeutralGeneratorName` (refused), so a caller embedding the runner can ask rather than + match a sentence. TypeScript only: in the C#, Java, Kotlin and Python ports documentation is a separate command (`dotnet meta docs`, `mvn metaobjects:docs`, `metaobjects docs`) and no doc generator is registered for code generation, so there is nothing to wire and nothing to skip. diff --git a/agent-context/skills/metaobjects-codegen/references/typescript-docs.md b/agent-context/skills/metaobjects-codegen/references/typescript-docs.md index 64a2c13b7..41dff507a 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript-docs.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript-docs.md @@ -44,10 +44,11 @@ no docs are written by that run. The warning is on stderr, and in a structured r `meta verify` reports the wiring as an advisory row, rule `doc-generator-in-gen`. Remove the entry and run `meta docs`. -**`mermaid-er` is the one docs-layer generator `meta gen` does run.** `meta docs` already -draws the ER diagram on its overview page with nothing wired, which is what most projects -want. `mermaidErDiagram()` (or the stable name `"mermaid-er"`) in `generators` writes the -same diagram as a file of its own, `docs/model.md`; `meta docs` never writes that file. +**Do not add `mermaidErDiagram()` to a new config.** It is deprecated: `meta docs` already +draws the ER diagram on its overview page with nothing wired. A `mermaidErDiagram()` +already wired in `generators` still runs under `meta gen`. The stable names `"docs"` and +`"mermaid-er"` written as strings in `generators` are refused, so `meta gen` fails on +them; `meta verify` reports that as rule `doc-generator-refused-by-gen`. **Before calling any generated code, read `./docs/api/AGENT-API.md`** — it has the exact imports, signatures, payload field shapes, and runnable examples for this diff --git a/docs/features/cli.md b/docs/features/cli.md index ef90f02e9..77edfb434 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -578,9 +578,10 @@ name: a doc generator (`docsFile()`, `apiDocsFile()`) listed under `generators` (ADR-0025). The result then says so in `warnings[]` and adds a `meta docs` next step to `help[]`; `meta gen --list` marks those rows ``run by `meta docs` `` (`door` in `--format json`), and `meta verify` reports the same wiring as an advisory row with rule -`doc-generator-in-gen`. `mermaid-er` is the one generator in the `docs` layer this does not -apply to: `meta gen` runs it and writes the ER diagram as a file of its own -(`docs/model.md`), while `meta docs` draws the same diagram on its overview page. +`doc-generator-in-gen`. A neutral stable name written as a string (`"docs"`, +`"mermaid-er"`) is refused rather than skipped, so `meta gen` fails on it: `meta gen --list` +marks that row ``WIRED by name, which `meta gen` refuses`` (`project.refused` in +`--format json`), and the advisory row's rule is `doc-generator-refused-by-gen`. In a structured run every narration line moves to **stderr**, so stdout is one parseable document. `--format` is honored by `gen`, `verify` and `migrate`; any 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 64a2c13b7..41dff507a 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 @@ -44,10 +44,11 @@ no docs are written by that run. The warning is on stderr, and in a structured r `meta verify` reports the wiring as an advisory row, rule `doc-generator-in-gen`. Remove the entry and run `meta docs`. -**`mermaid-er` is the one docs-layer generator `meta gen` does run.** `meta docs` already -draws the ER diagram on its overview page with nothing wired, which is what most projects -want. `mermaidErDiagram()` (or the stable name `"mermaid-er"`) in `generators` writes the -same diagram as a file of its own, `docs/model.md`; `meta docs` never writes that file. +**Do not add `mermaidErDiagram()` to a new config.** It is deprecated: `meta docs` already +draws the ER diagram on its overview page with nothing wired. A `mermaidErDiagram()` +already wired in `generators` still runs under `meta gen`. The stable names `"docs"` and +`"mermaid-er"` written as strings in `generators` are refused, so `meta gen` fails on +them; `meta verify` reports that as rule `doc-generator-refused-by-gen`. **Before calling any generated code, read `./docs/api/AGENT-API.md`** — it has the exact imports, signatures, payload field shapes, and runnable examples for this 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 64a2c13b7..41dff507a 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 @@ -44,10 +44,11 @@ no docs are written by that run. The warning is on stderr, and in a structured r `meta verify` reports the wiring as an advisory row, rule `doc-generator-in-gen`. Remove the entry and run `meta docs`. -**`mermaid-er` is the one docs-layer generator `meta gen` does run.** `meta docs` already -draws the ER diagram on its overview page with nothing wired, which is what most projects -want. `mermaidErDiagram()` (or the stable name `"mermaid-er"`) in `generators` writes the -same diagram as a file of its own, `docs/model.md`; `meta docs` never writes that file. +**Do not add `mermaidErDiagram()` to a new config.** It is deprecated: `meta docs` already +draws the ER diagram on its overview page with nothing wired. A `mermaidErDiagram()` +already wired in `generators` still runs under `meta gen`. The stable names `"docs"` and +`"mermaid-er"` written as strings in `generators` are refused, so `meta gen` fails on +them; `meta verify` reports that as rule `doc-generator-refused-by-gen`. **Before calling any generated code, read `./docs/api/AGENT-API.md`** — it has the exact imports, signatures, payload field shapes, and runnable examples for this diff --git a/fixtures/generator-registry-conformance/README.md b/fixtures/generator-registry-conformance/README.md index 0e43989ae..b0e41b7cd 100644 --- a/fixtures/generator-registry-conformance/README.md +++ b/fixtures/generator-registry-conformance/README.md @@ -35,7 +35,7 @@ to make the choice cheap, and `layer` is the axis it is cheap along. | `persistence` | queries, db-context, repository, exposed-table, relations, stored-proc | app shape | | `api` | routes, routes-hono, filter-allowlist, validator, spring-config | app shape | | `client` | form, hooks, grid, grid-hook | app shape | -| `docs` | docs, mermaid-er, api-docs | on by default (`meta docs`); `mermaid-er` is the one `meta gen` runs, for the diagram as a file of its own | +| `docs` | docs, mermaid-er, api-docs | on by default (`meta docs`) | | `capability` | prompt-render, output-parser, output-prompt, extractor, render-helper, trace-helper, requirement-tests, cube-model, shared-model, template, callable | `meta gen --list --probe` | Six, not ten. An earlier draft split `capability` into `trace` / `requirements` / diff --git a/fixtures/generator-registry-conformance/registry.json b/fixtures/generator-registry-conformance/registry.json index 2298e1a03..a318ef529 100644 --- a/fixtures/generator-registry-conformance/registry.json +++ b/fixtures/generator-registry-conformance/registry.json @@ -1,5 +1,5 @@ { - "$comment": "CANONICAL generator stable-name manifest (ADR-0021 D3). The single cross-port source of truth for generator stable names. Every port's generator registry is conformance-tested against this file: (1) every generator a port exposes MUST use a name listed here; (2) for each generator, the `ports` array lists exactly the ports expected to expose it (presence is checked both ways); (3) because all ports validate against this one file, a shared concept (e.g. `routes`) is spelled identically everywhere. Port ids: typescript, csharp, java, kotlin, python. `tier`: native (idiomatic per-port code) or neutral (Tier-2, a language-neutral artifact). Which command runs an entry is a separate fact, stated in its `note`: `docs` and `api-docs` are run by `meta docs`, `mermaid-er` by `meta gen`. `layer`: one of model, persistence, api, client, docs, capability — the grouping an adopter SELECTS BY, gated in every port exactly as `tier` is (see README.md). Adding/renaming a generator = edit THIS file + the port registry together; the conformance gate fails otherwise.", + "$comment": "CANONICAL generator stable-name manifest (ADR-0021 D3). The single cross-port source of truth for generator stable names. Every port's generator registry is conformance-tested against this file: (1) every generator a port exposes MUST use a name listed here; (2) for each generator, the `ports` array lists exactly the ports expected to expose it (presence is checked both ways); (3) because all ports validate against this one file, a shared concept (e.g. `routes`) is spelled identically everywhere. Port ids: typescript, csharp, java, kotlin, python. `tier`: native (idiomatic per-port code) or neutral (Tier-2, owned by `meta docs`). `layer`: one of model, persistence, api, client, docs, capability — the grouping an adopter SELECTS BY, gated in every port exactly as `tier` is (see README.md). Adding/renaming a generator = edit THIS file + the port registry together; the conformance gate fails otherwise.", "ports": ["typescript", "csharp", "java", "kotlin", "python"], "generators": { "entity": { @@ -208,7 +208,7 @@ "concept": "Neutral Mermaid ER diagram of the entity/relationship model.", "tier": "neutral", "layer": "docs", - "note": "Neutral artifact (ADR-0020). Run by `meta gen`, which writes the diagram as a file of its own; `meta docs` draws the same diagram on its overview page and does not run this generator.", + "note": "Neutral artifact owned by the docs engine (ADR-0020). Deprecated under `meta gen`: a `mermaidErDiagram()` already wired in `generators` still runs; `meta docs` draws the same diagram on its overview page.", "ports": ["typescript"] } } diff --git a/server/typescript/packages/cli/src/commands/verify.ts b/server/typescript/packages/cli/src/commands/verify.ts index 49d7573d8..f6f5fec03 100644 --- a/server/typescript/packages/cli/src/commands/verify.ts +++ b/server/typescript/packages/cli/src/commands/verify.ts @@ -1023,9 +1023,10 @@ export async function verifyCommand( // advisory only — a model the schema builder refuses is migrate's to report. } - // A doc generator wired under `generators`: `meta gen` skips it, and `--codegen` - // regenerates through the same runner without relaying its warnings — so this is - // the only place `verify` can say that the listed generator produces nothing here. + // A doc generator wired under `generators`: `meta gen` skips it (or refuses its + // neutral name), and `--codegen` regenerates through the same runner without relaying + // its warnings — so this is the only place `verify` can say that the listed generator + // produces nothing here. let docGenerators: DocGeneratorFinding[] = []; try { docGenerators = scanForDocGeneratorsInGen(forgeConfig); @@ -1087,7 +1088,7 @@ export async function verifyCommand( if (docGenerators.length > 0) { log.warn( `meta verify — ${docGenerators.length} doc generator(s) listed under \`generators\`, ` + - `which \`meta gen\` skips (advisory — does not fail the build):`, + `which \`meta gen\` does not run (advisory — does not fail the build):`, ); warnCapped(docGenerators.map((f) => ` ${f.message}`), flags.limit, { structured }); } diff --git a/server/typescript/packages/cli/src/lib/advisory.ts b/server/typescript/packages/cli/src/lib/advisory.ts index f1b08db98..21c7591c5 100644 --- a/server/typescript/packages/cli/src/lib/advisory.ts +++ b/server/typescript/packages/cli/src/lib/advisory.ts @@ -116,6 +116,7 @@ export const RULE_LIBRARY_PREFIX = "library-prefix-unprovenanced"; export const RULE_UNINDEXED_FK = "unindexed-foreign-key"; export const RULE_REFERENTIAL_ACTION_CONFLICT = "overridden-referential-action"; export const RULE_DOC_GENERATOR_IN_GEN = "doc-generator-in-gen"; +export const RULE_DOC_GENERATOR_REFUSED = "doc-generator-refused-by-gen"; /** The next-step line for `n` rows of one advisory kind, each naming what the rows are. */ const ADVISORY_HELP: ReadonlyArray<{ matches: (rule: string) => boolean; line: (n: number) => string }> = [ @@ -149,6 +150,11 @@ const ADVISORY_HELP: ReadonlyArray<{ matches: (rule: string) => boolean; line: ( line: (n) => `${n} doc generator(s) listed under \`generators\`, which \`meta gen\` skips — see antiPatterns.rows[] (rule "${RULE_DOC_GENERATOR_IN_GEN}"); \`meta docs\` writes documentation`, }, + { + matches: (r) => r === RULE_DOC_GENERATOR_REFUSED, + line: (n) => + `${n} doc generator name(s) listed under \`generators\`, which \`meta gen\` refuses, so the run fails — see antiPatterns.rows[] (rule "${RULE_DOC_GENERATOR_REFUSED}"); \`meta docs\` writes documentation`, + }, ]; /** @@ -270,15 +276,16 @@ export function referentialActionConflictRows( /** * Doc generators wired under `generators` in the same row shape. There is no line: the - * finding is about the loaded config, whose entries carry no source position. + * finding is about the loaded config, whose entries carry no source position. The rule + * says which outcome `meta gen` gives the entry: skipped, or refused (the run fails). */ export function docGeneratorRows( - findings: readonly { file: string; construct: string; message: string }[], + findings: readonly { file: string; construct: string; refused: boolean; message: string }[], ): AdvisoryFindingRow[] { return findings.map((f) => ({ file: f.file, line: 0, - rule: RULE_DOC_GENERATOR_IN_GEN, + rule: f.refused ? RULE_DOC_GENERATOR_REFUSED : RULE_DOC_GENERATOR_IN_GEN, construct: f.construct, message: f.message, })); diff --git a/server/typescript/packages/cli/src/lib/catalog-listing.ts b/server/typescript/packages/cli/src/lib/catalog-listing.ts index 365653b87..ed3a1b343 100644 --- a/server/typescript/packages/cli/src/lib/catalog-listing.ts +++ b/server/typescript/packages/cli/src/lib/catalog-listing.ts @@ -39,7 +39,7 @@ import { RUNTIME_ENTRIES, resolveRuntimePackage, runtimeClosure, runtimeCopyStatus, runtimeInstall, type RuntimeCopyRow, } from "./runtime-eject.js"; -import { DOCS_DOOR_COMMAND, isPackagedDocsGenerator, OWNED_RUNTIME_DIR } from "@metaobjectsdev/codegen-ts"; +import { DOCS_DOOR_COMMAND, isNeutralGeneratorName, isPackagedDocsGenerator, OWNED_RUNTIME_DIR } from "@metaobjectsdev/codegen-ts"; import { readPackageManifest, declaredDependencyNames } from "./package-manifest.js"; import { describeError } from "./error-text.js"; @@ -99,6 +99,9 @@ export interface GeneratorCatalogRow { project?: { /** Wired in this project's `generators: [...]`. */ wired: boolean; + /** Present iff it is wired as a stable-name STRING that `meta gen` refuses (a neutral + * name): the run fails on it, rather than running or skipping the generator. */ + refused?: true; /** This generator's framework is a declared dependency. `null` when neutral or unknown. */ frameworkDetected: boolean | null; /** Files this generator WOULD emit for this model. `null` unless --probe, and @@ -295,6 +298,10 @@ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promis const runtimeCopies = opts.project === undefined ? new Map() : await ownedRuntimeByGenerator(opts.project.projectRoot, opts.project.ownedNames); + const refusedNames = new Set( + (opts.project?.config.generators ?? []) + .filter((spec): spec is string => typeof spec === "string" && isNeutralGeneratorName(spec)), + ); for (const entry of listCatalog()) { // Asked of the runner's own definition, never restated: which generators `meta gen` @@ -373,6 +380,7 @@ export async function buildCatalogListing(opts: CatalogListingOpts = {}): Promis row.project = { wired: project.wiredNames.has(entry.name), + ...(refusedNames.has(entry.name) ? { refused: true as const } : {}), frameworkDetected, wouldEmit, ...(probeError !== undefined ? { probeError } : {}), @@ -485,6 +493,14 @@ export const PACKAGE_ONLY_NOTE = export const SKIPPED_BY_GEN_NOTE = "not run by `meta gen`: listed under `generators`, it is warned about and skipped."; +/** + * Printed under every neutral row. The other outcome for a `generators` entry: the + * resolver refuses a neutral stable name, so the string fails the run where a + * constructed generator is skipped or (the deprecated `mermaidErDiagram()`) still runs. + */ +export const NAME_REFUSED_BY_GEN_NOTE = + "its name as a string under `generators` is refused instead: `meta gen` fails on it."; + /** The human rendering: grouped by layer, because layer is the axis you select by. */ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string { const lines: string[] = []; @@ -505,7 +521,13 @@ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string { if (r.tier === "neutral") marks.push("neutral"); if (!r.source.ejectable) marks.push("package-only"); if (r.door !== undefined) marks.push(`run by \`${r.door}\``); - if (r.project?.wired) marks.push(r.door !== undefined ? "WIRED but skipped by `meta gen`" : "WIRED"); + if (r.project?.wired) { + marks.push( + r.project.refused + ? "WIRED by name, which `meta gen` refuses" + : r.door !== undefined ? "WIRED but skipped by `meta gen`" : "WIRED", + ); + } if (r.source.owned) marks.push("owned"); if (r.source.runtimeCopy !== undefined) { // The adapter source this generator's output imports, compared to the package. @@ -532,6 +554,7 @@ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string { lines.push(` ${r.name.padEnd(width)} — ${r.description}${suffix}`); if (r.note !== undefined) lines.push(` ${" ".repeat(width)} ${r.note}`); if (r.door !== undefined) lines.push(` ${" ".repeat(width)} ${SKIPPED_BY_GEN_NOTE}`); + if (isNeutralGeneratorName(r.name)) lines.push(` ${" ".repeat(width)} ${NAME_REFUSED_BY_GEN_NOTE}`); if (r.requires !== undefined && r.requires.length > 0) { lines.push(` ${" ".repeat(width)} requires: ${r.requires.join(", ")}`); } diff --git a/server/typescript/packages/cli/src/lib/doc-generator-advisory.ts b/server/typescript/packages/cli/src/lib/doc-generator-advisory.ts index 9304a3bf3..530057a84 100644 --- a/server/typescript/packages/cli/src/lib/doc-generator-advisory.ts +++ b/server/typescript/packages/cli/src/lib/doc-generator-advisory.ts @@ -6,14 +6,19 @@ // this way verified clean and was never told that the generator it listed produces // nothing — nor that the gate it was running does not check documentation at all. // +// A neutral stable NAME (`"docs"`, `"mermaid-er"`) is a different outcome: `meta gen` +// refuses the string and the run fails, so the finding says refused, not skipped. +// // Read off the loaded config, not off a regen: the fact is in `generators`, and asking -// the runner's own predicate keeps this from growing a second list of what is skipped. +// the runner's and the resolver's own predicates keeps this from growing a second list +// of what is skipped or refused. // // ADVISORY ONLY, like every row in this tier: nothing here reaches an exit code. import { packagedDocsFactoryCall, generatorRegistry, + isNeutralGeneratorName, isPackagedDocsGenerator, DOCS_DOOR_COMMAND, type MetaobjectsGenConfig, @@ -26,34 +31,51 @@ export interface DocGeneratorFinding { file: string; /** The command that produces what the wired generator would have. */ construct: string; + /** True when `meta gen` REFUSES the entry (a neutral stable-name string), so the run + * fails; false when the runner warns about and skips it. */ + refused: boolean; message: string; } +function finding(wired: string, refused: boolean): DocGeneratorFinding { + return { + file: CONFIG_FILE, + construct: DOCS_DOOR_COMMAND, + refused, + message: + `${CONFIG_FILE}: ${wired} is listed under \`generators\`, and \`meta gen\` ` + + `${refused ? "refuses it, so the run fails" : "skips it"} — ` + + `\`${DOCS_DOOR_COMMAND}\` writes documentation and \`meta verify --docs\` checks it. ` + + `Remove ${wired} from \`generators\`.`, + }; +} + /** - * One finding per doc generator the config wires, in config order. + * One finding per `generators` entry `meta gen` skips or refuses, in config order. * * A `generators` entry is a constructed generator or a stable-name string (ADR-0021). - * A string resolves through the catalog to the same implementation name the runner - * tests; a name no entry registers is the runner's to refuse, not this scan's. + * A neutral name is refused by the resolver. Any other string resolves through the + * catalog to the same implementation name the runner tests; a name no entry registers + * is the runner's to refuse, not this scan's. */ export function scanForDocGeneratorsInGen( config: MetaobjectsGenConfig | undefined, ): DocGeneratorFinding[] { const findings: DocGeneratorFinding[] = []; for (const spec of config?.generators ?? []) { + if (typeof spec === "string") { + if (isNeutralGeneratorName(spec)) findings.push(finding(`"${spec}"`, true)); + else if (isPackagedDocsGenerator(generatorRegistry[spec]?.factory().name ?? "")) { + findings.push(finding(`"${spec}"`, false)); + } + continue; + } // `spec?.name`: the array is typed, but a config is user code, and a hole in it is // the runner's to report rather than a reason for an advisory to throw. - const implName = typeof spec === "string" ? generatorRegistry[spec]?.factory().name : spec?.name; - if (implName === undefined || !isPackagedDocsGenerator(implName)) continue; - const wired = typeof spec === "string" ? `"${spec}"` : packagedDocsFactoryCall(implName); - findings.push({ - file: CONFIG_FILE, - construct: DOCS_DOOR_COMMAND, - message: - `${CONFIG_FILE}: ${wired} is listed under \`generators\`, and \`meta gen\` skips it — ` + - `\`${DOCS_DOOR_COMMAND}\` writes documentation and \`meta verify --docs\` checks it. ` + - `Remove ${wired} from \`generators\`.`, - }); + const implName = spec?.name; + if (implName !== undefined && isPackagedDocsGenerator(implName)) { + findings.push(finding(packagedDocsFactoryCall(implName), false)); + } } return findings; } diff --git a/server/typescript/packages/cli/test/gen-docs-door-signals.test.ts b/server/typescript/packages/cli/test/gen-docs-door-signals.test.ts index c3af9b360..e58d77bbd 100644 --- a/server/typescript/packages/cli/test/gen-docs-door-signals.test.ts +++ b/server/typescript/packages/cli/test/gen-docs-door-signals.test.ts @@ -104,7 +104,7 @@ interface ListRow { kind: string; note?: string; door?: string; - project?: { wired: boolean; wouldEmit: number | null; probeError?: string }; + project?: { wired: boolean; refused?: true; wouldEmit: number | null; probeError?: string }; } const WIRED = `["entity", apiDocsFile(), docsFile()]`; @@ -180,9 +180,12 @@ describe("meta gen — a skipped doc generator is reported in every output form" }); }); -describe("meta gen — mermaid-er is a generator `meta gen` runs", () => { - // The other half of the same ruling: what `meta docs` runs is skipped here, and what - // it does not run is not. Every surface has to give the same answer. +const NEUTRAL_REFUSAL = (name: string): string => + `generator "${name}" is neutral (owned by 'meta docs'); not selectable in the gen suite.`; + +describe("meta gen — mermaid-er is deprecated: a wired factory runs, the name is refused", () => { + // ADR-0022 Part 2 D1 keeps it deprecated from the gen suite. A config that already + // lists `mermaidErDiagram()` keeps working; the stable name is not selectable. test("mermaidErDiagram() writes its file and draws no skip warning", async () => { const r = await meta(`["entity", mermaidErDiagram()]`, ["gen", "--format", "json"]); expect(r.code).toBe(0); @@ -192,18 +195,31 @@ describe("meta gen — mermaid-er is a generator `meta gen` runs", () => { expect(r.stderr).not.toContain("Skipped."); }); - test("the stable name selects it, as it does every other generator `meta gen` runs", async () => { - // It was refused as "neutral (owned by 'meta docs')" while the factory beside it ran. - const r = await meta(`["entity", "mermaid-er"]`, ["gen", "--format", "json"]); - expect(r.code).toBe(0); - const payload = JSON.parse(r.stdout) as GenPayload; - expect(payload.gen.map((g) => g.file)).toContain("generated/docs/model.md"); + test("the stable name `mermaid-er` is refused as neutral, and nothing is written", async () => { + const root = setupRepo(`["entity", "mermaid-er"]`); + try { + expect(await run(["gen", "--format", "json", "--cwd", root])).not.toBe(0); + expect(err.join("\n")).toContain(NEUTRAL_REFUSAL("mermaid-er")); + expect(existsSync(join(root, "generated"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); - test("the stable name `docs` is still refused: `meta docs` owns it", async () => { + test("the stable name `docs` is refused the same way: `meta docs` owns it", async () => { const r = await meta(`["entity", "docs"]`, ["gen", "--format", "json"]); expect(r.code).not.toBe(0); - expect(r.stderr).toContain(`generator "docs" is neutral (owned by 'meta docs')`); + expect(r.stderr).toContain(NEUTRAL_REFUSAL("docs")); + }); + + test("an unknown name is not offered a neutral name or a skipped one", async () => { + const r = await meta(`["entity", "no-such-generator"]`, ["gen", "--format", "json"]); + expect(r.code).not.toBe(0); + const offered = (/Available native generators: ([^.]*)\./.exec(r.stderr)?.[1] ?? "") + .split(",").map((s) => s.trim()); + expect(offered).toContain("entity"); + for (const name of ["mermaid-er", "docs", "api-docs"]) expect(offered, name).not.toContain(name); + expect(r.stderr).toContain("run 'meta docs'"); }); }); @@ -303,17 +319,18 @@ describe("meta gen --list — a row `meta gen` does not run says so", () => { expect(block(r.stdout, "entity")).not.toContain("meta docs"); }); - // `meta docs` has never run mermaid-er: `meta gen` writes its file. The row said - // "surfaced via `meta docs`" (in the registry; nothing printed it), so the one - // docs-layer generator that belongs in `generators` read as one that does not. - test("text: mermaid-er is run by `meta gen`, and its row says so", async () => { + // The registry note said "surfaced via `meta docs`", which `meta docs` never did. The + // row states the back-compat fact, and does not present the generator as a choice. + test("text: the mermaid-er row says it is deprecated, and has no door", async () => { const r = await meta(`["entity"]`, ["gen", "--list", "--format", "text"]); const mermaid = block(r.stdout, "mermaid-er"); - expect(mermaid).toContain("run by `meta gen`"); + expect(mermaid).toContain("deprecated"); + expect(mermaid).toContain("do not add it to a new config"); + expect(mermaid).toContain("A `mermaidErDiagram()` already wired in `generators` still runs"); + expect(mermaid).toContain("`meta docs` draws the same diagram on its overview page"); + expect(mermaid).not.toContain("surfaced via"); expect(mermaid).not.toContain("run by `meta docs`"); expect(mermaid).not.toContain("not run by `meta gen`"); - // ...while still telling the reader the diagram is already on the overview page. - expect(mermaid).toContain("`meta docs` already draws the same diagram"); }); test("json: the row carries the door and the note", async () => { @@ -326,7 +343,8 @@ describe("meta gen --list — a row `meta gen` does not run says so", () => { expect(row(rows, name).note ?? "", name).toContain("meta docs"); } expect(row(rows, "mermaid-er").door).toBeUndefined(); - expect(row(rows, "mermaid-er").note ?? "").toContain("run by `meta gen`"); + expect(row(rows, "mermaid-er").note ?? "").toContain("deprecated"); + expect(row(rows, "mermaid-er").note ?? "").toContain("still runs"); expect(row(rows, "entity").door).toBeUndefined(); expect(row(rows, "entity").note).toBeUndefined(); }); @@ -339,6 +357,46 @@ describe("meta gen --list — a row `meta gen` does not run says so", () => { expect(block(r.stdout, "entity")).not.toContain("skipped"); }); + // A neutral name written as a string is not skipped: `meta gen` refuses it and exits + // non-zero (asserted above). The mark has to say that, for `docs` and `mermaid-er` both. + const BY_NAME = `["entity", "docs", "mermaid-er", "api-docs"]`; + + test("text: a neutral name wired as a string reads as refused, not skipped or run", async () => { + const r = await meta(BY_NAME, ["gen", "--list", "--format", "text"]); + expect(r.code).toBe(0); + for (const name of ["docs", "mermaid-er"]) { + const b = block(r.stdout, name); + expect(b, name).toContain("WIRED by name, which `meta gen` refuses"); + expect(b, name).not.toContain("WIRED but skipped"); + expect(b, name).not.toMatch(/WIRED[,\]]/); + expect(b, name).toContain("its name as a string under `generators` is refused"); + } + // `"api-docs"` resolves, and the runner skips it: the skip wording stands. + expect(block(r.stdout, "api-docs")).toContain("WIRED but skipped by `meta gen`"); + expect(block(r.stdout, "api-docs")).not.toContain("refuse"); + expect(block(r.stdout, "entity")).not.toContain("refuse"); + }); + + test("json: the refusal is on the row's project facts", async () => { + const r = await meta(BY_NAME, ["gen", "--list", "--format", "json"]); + const rows = rowsOf(r.stdout); + for (const name of ["docs", "mermaid-er"]) { + expect(row(rows, name).project?.wired, name).toBe(true); + expect(row(rows, name).project?.refused, name).toBe(true); + } + expect(row(rows, "api-docs").project?.wired).toBe(true); + expect(row(rows, "api-docs").project?.refused).toBeUndefined(); + expect(row(rows, "entity").project?.refused).toBeUndefined(); + }); + + test("a constructed mermaidErDiagram() reads as WIRED: it still runs", async () => { + const r = await meta(`["entity", mermaidErDiagram(), docsFile()]`, ["gen", "--list", "--format", "json"]); + const rows = rowsOf(r.stdout); + expect(row(rows, "mermaid-er").project?.wired).toBe(true); + expect(row(rows, "mermaid-er").project?.refused).toBeUndefined(); + expect(row(rows, "docs").project?.refused).toBeUndefined(); + }); + test("--probe: no file count for a generator `meta gen` does not run", async () => { // It printed `would emit 0`, which reads as "nothing in your model needs this" for // a generator whose pages `meta docs` writes for every entity. @@ -398,15 +456,46 @@ describe("meta verify — a doc generator under `generators` is an advisory", () expect(r.stderr).toContain("meta verify --docs"); }); - test("a stable-name string is caught too", async () => { + test("a stable-name string the runner skips is caught too, with the skip wording", async () => { const r = await meta(`["entity", "api-docs"]`, ["verify", "--format", "json"]); const payload = JSON.parse(r.stdout) as VerifyPayload; - expect(payload.antiPatterns.rows.filter((x) => x.rule === "doc-generator-in-gen")).toHaveLength(1); + const rows = payload.antiPatterns.rows.filter((x) => x.rule === "doc-generator-in-gen"); + expect(rows).toHaveLength(1); + expect(rows[0]?.message).toContain("`meta gen` skips it"); + expect(payload.antiPatterns.rows.filter((x) => x.rule === "doc-generator-refused-by-gen")).toEqual([]); + expect(payload.help.join("\n")).not.toContain("refuses"); }); - test("a config with no doc generator gets no such row", async () => { - const r = await meta(`["entity", mermaidErDiagram()]`, ["verify", "--format", "json"]); + // `meta gen` exits non-zero on these two strings (asserted in the mermaid-er block + // above), so calling them skipped told the reader the run goes on without them. + test("json: a neutral name wired as a string is reported as refused, not skipped", async () => { + const r = await meta(`["entity", "docs", "mermaid-er"]`, ["verify", "--format", "json"]); const payload = JSON.parse(r.stdout) as VerifyPayload; + const rows = payload.antiPatterns.rows.filter((x) => x.rule === "doc-generator-refused-by-gen"); + expect(rows).toHaveLength(2); + expect(rows[0]?.message).toContain(`"docs" is listed under \`generators\`, and \`meta gen\` refuses it`); + expect(rows[1]?.message).toContain(`"mermaid-er" is listed under \`generators\`, and \`meta gen\` refuses it`); + for (const x of rows) { + expect(x.message).not.toContain("skips"); + expect(x.message).toContain("Remove"); + expect(x.file).toBe("metaobjects.config.ts"); + } expect(payload.antiPatterns.rows.filter((x) => x.rule === "doc-generator-in-gen")).toEqual([]); + const help = payload.help.filter((h) => h.includes("doc-generator-refused-by-gen")); + expect(help).toHaveLength(1); + expect(help[0]).toContain("which `meta gen` refuses"); + expect(help[0]).not.toContain("skips"); + }); + + test("text: the refused name is printed as refused", async () => { + const r = await meta(`["entity", "docs"]`, ["verify", "--format", "text"]); + expect(r.stderr).toContain(`"docs" is listed under \`generators\`, and \`meta gen\` refuses it`); + expect(r.stderr).not.toContain("`meta gen` skips"); + }); + + test("a constructed mermaidErDiagram() gets no row: it still runs", async () => { + const r = await meta(`["entity", mermaidErDiagram()]`, ["verify", "--format", "json"]); + const payload = JSON.parse(r.stdout) as VerifyPayload; + expect(payload.antiPatterns.rows.filter((x) => x.rule.startsWith("doc-generator-"))).toEqual([]); }); }); diff --git a/server/typescript/packages/codegen-ts/src/generator-registry.ts b/server/typescript/packages/codegen-ts/src/generator-registry.ts index f40957a27..b6d9b7cf4 100644 --- a/server/typescript/packages/codegen-ts/src/generator-registry.ts +++ b/server/typescript/packages/codegen-ts/src/generator-registry.ts @@ -98,9 +98,7 @@ export interface GeneratorRegistryEntry { layer: Layer; /** One-line (no newline) human description for `--list`. */ description: string; - /** "native" = idiomatic per-port emission; "neutral" = a language-neutral artifact. - * Which COMMAND runs an entry is a separate fact, and not derivable from this: - * `api-docs` is native and run by `meta docs`, `mermaid-er` neutral and run by `meta gen`. */ + /** "native" = recommended `meta gen` suite; "neutral" = `meta docs`-owned. */ tier: GeneratorTier; /** Constructs the generator with sensible defaults. Calling it must not throw. */ factory: () => Generator; @@ -307,7 +305,7 @@ export const generatorRegistry: Record = { factory: () => mermaidErDiagram(), factoryExport: "mermaidErDiagram", ejectable: ejectable("mermaid-er"), - note: "run by `meta gen`, which writes the diagram as a file of its own (docs/model.md). `meta docs` already draws the same diagram on its overview page with nothing wired, which is what most projects want.", + note: "neutral artifact owned by the docs engine (ADR-0020), deprecated under `meta gen`: do not add it to a new config. A `mermaidErDiagram()` already wired in `generators` still runs; `meta docs` draws the same diagram on its overview page.", }, // ----- capability ------------------------------------------------------- diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index 99a5e48a5..fc229e275 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -60,7 +60,7 @@ export { export { isPackagedDocsGenerator, packagedDocsFactoryCall, DOCS_DOOR_COMMAND } from "./packaged-docs-generators.js"; export type { MetaobjectsGenConfig, NormalizedMetaobjectsGenConfig, ResolvedGenConfig, Dialect, ExtStyle, ColumnNamingStrategy, MetaDataTypeProvider, GeneratorSpec, DocsConfig, ResolvedDocsConfig, DocsSurface, ApiSurface, VerifyConfig } from "./metaobjects-config.js"; -export { defineConfig, normalizeConfig, resolveGenerators, resolveDocsConfig } from "./metaobjects-config.js"; +export { defineConfig, normalizeConfig, resolveGenerators, isNeutralGeneratorName, resolveDocsConfig } from "./metaobjects-config.js"; // The dialect every generator falls back to when a config declares none. Exported because // `meta docs` must apply the SAME default `normalizeConfig` does — a command that resolved // the dialect differently would document a schema the toolchain does not produce. diff --git a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts index 6d53d8076..41b75d687 100644 --- a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts +++ b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts @@ -370,17 +370,24 @@ export function resolveTargets(config: MetaobjectsGenConfig): Record !isPackagedDocsGenerator(e.factory().name)) + // A native generator the runner skips is not offered: the list named `api-docs`, + // and the run it recommended then warned about and skipped it. + const native = Object.values(generatorRegistry) + .filter((e) => e.tier === "native" && !isPackagedDocsGenerator(e.factory().name)) .map((e) => e.name) .sort(); throw new Error( - `unknown generator "${spec}". Available generators: ${runnable.join(", ")}. ` + + `unknown generator "${spec}". Available native generators: ${native.join(", ")}. ` + `Documentation is not a 'meta gen' generator: run '${DOCS_DOOR_COMMAND}'.`, ); } - if (entry.tier !== "native" && isPackagedDocsGenerator(entry.factory().name)) { + if (isNeutralGeneratorName(spec)) { throw new Error( `generator "${spec}" is neutral (owned by 'meta docs'); ` + `not selectable in the gen suite.`, diff --git a/server/typescript/packages/codegen-ts/src/packaged-docs-generators.ts b/server/typescript/packages/codegen-ts/src/packaged-docs-generators.ts index 141b5b125..5e16aff21 100644 --- a/server/typescript/packages/codegen-ts/src/packaged-docs-generators.ts +++ b/server/typescript/packages/codegen-ts/src/packaged-docs-generators.ts @@ -10,11 +10,9 @@ // Keyed by the generator's IMPLEMENTATION name, because that is what a constructed // generator carries. The value is the factory call an adopter wrote, for the message. // -// `mermaid-er-diagram` is NOT one of them, and that is a ruling rather than an -// omission: `meta docs` has never run it. `meta docs` draws the ER diagram on its -// overview page through `docsFile()`; `mermaidErDiagram()` writes the same diagram as a -// file of its own, and `meta gen` is the only command that writes that file. It sits -// in the `docs` layer by what it produces, not by which command runs it. +// `mermaid-er-diagram` is not one of them. `mermaidErDiagram()` is deprecated and kept +// running for a config that already lists it, so the runner does not skip it. +// `meta docs` draws the same diagram on its overview page through `docsFile()`. const PACKAGED_DOCS_FACTORY_CALLS: ReadonlyMap = new Map([ ["docs-file", "docsFile()"], ["api-docs", "apiDocsFile()"], diff --git a/server/typescript/packages/codegen-ts/test/generator-registry.test.ts b/server/typescript/packages/codegen-ts/test/generator-registry.test.ts index bb447bad0..46e0e89d0 100644 --- a/server/typescript/packages/codegen-ts/test/generator-registry.test.ts +++ b/server/typescript/packages/codegen-ts/test/generator-registry.test.ts @@ -89,13 +89,18 @@ describe("generator-registry (ADR-0021 D3)", () => { } }); - // The other direction. `mermaid-er` sits in the docs layer and `meta docs` has never - // run it: `meta gen` writes its file. Its note pointed at `meta docs` all the same. - test("a docs-layer entry `meta gen` runs says so, and does not name `meta docs` as its door", () => { + // The other direction. `mermaid-er` is deprecated and the runner still runs a wired + // `mermaidErDiagram()`. Its note said "surfaced via `meta docs`", which `meta docs` + // never did: it states the back-compat fact, and does not offer it as a normal choice. + test("the docs-layer entry `meta gen` still runs states that it is deprecated", () => { const run = Object.values(generatorRegistry) .filter((e) => e.layer === "docs" && !isPackagedDocsGenerator(e.factory().name)); expect(run.map((e) => e.name)).toEqual(["mermaid-er"]); - expect(run[0]?.note ?? "").toContain("run by `meta gen`"); + const note = run[0]?.note ?? ""; + expect(note).toContain("deprecated"); + expect(note).toContain("do not add it to a new config"); + expect(note).toContain("still runs"); + expect(note).not.toContain("surfaced via"); }); test("every factory constructs a Generator with a name without throwing", () => { diff --git a/server/typescript/packages/codegen-ts/test/generator-spec-resolution.test.ts b/server/typescript/packages/codegen-ts/test/generator-spec-resolution.test.ts index 181f85a49..952b60af0 100644 --- a/server/typescript/packages/codegen-ts/test/generator-spec-resolution.test.ts +++ b/server/typescript/packages/codegen-ts/test/generator-spec-resolution.test.ts @@ -6,7 +6,6 @@ import { runGen } from "../src/runner.js"; import { defineConfig, resolveGenerators } from "../src/metaobjects-config.js"; import { entityFile } from "../src/generators/entity-file.js"; import { routesFile } from "../src/generators/routes-file.js"; -import { mermaidErDiagram } from "../src/generators/mermaid-er.js"; import { MetaDataLoader } from "@metaobjectsdev/metadata"; import { FileSource } from "@metaobjectsdev/metadata/core"; @@ -49,20 +48,20 @@ describe("resolveGenerators (registry string resolution)", () => { expect(gens[1]).toBe(factory); }); - test("unknown name → error listing the names meta gen runs", () => { + test("unknown name → error listing available native names", () => { expect(() => resolveGenerators(["not-a-generator"])) .toThrow(/unknown generator "not-a-generator"/); - // Lists the selectable names so the user can correct it. + // Lists native names so the user can correct it. try { resolveGenerators(["not-a-generator"]); } catch (err) { const msg = (err as Error).message; expect(msg).toContain("entity"); expect(msg).toContain("routes"); - // What `meta docs` runs is NOT advertised as selectable. (Substring-safe: neither - // `api-docs` nor a bare `docs` entry appears in any name `meta gen` runs.) + // neutral names are NOT advertised as selectable + expect(msg).not.toContain("mermaid-er"); + // ...and neither is the native generator `meta gen` skips. expect(msg).not.toContain("api-docs"); - expect(msg).not.toMatch(/[ ,]docs[,.]/); } }); @@ -71,13 +70,9 @@ describe("resolveGenerators (registry string resolution)", () => { .toThrow(/generator "docs" is neutral \(owned by 'meta docs'\); not selectable in the gen suite/); }); - // It threw "is neutral (owned by 'meta docs')". `meta docs` has never run this - // generator: `meta gen` writes its file, and its factory wired in the same array ran. - // The name now resolves to that factory. - test('neutral name "mermaid-er" resolves: meta gen is the command that runs it', () => { - const gens = resolveGenerators(["mermaid-er"]); - expect(gens).toHaveLength(1); - expect(gens[0]!.name).toBe(mermaidErDiagram().name); + test('neutral name "mermaid-er" → neutral error', () => { + expect(() => resolveGenerators(["mermaid-er"])) + .toThrow(/is neutral \(owned by 'meta docs'\); not selectable in the gen suite/); }); }); diff --git a/server/typescript/packages/codegen-ts/test/runner-docs-shim.test.ts b/server/typescript/packages/codegen-ts/test/runner-docs-shim.test.ts index daa0f3472..fa00c8b5d 100644 --- a/server/typescript/packages/codegen-ts/test/runner-docs-shim.test.ts +++ b/server/typescript/packages/codegen-ts/test/runner-docs-shim.test.ts @@ -134,13 +134,13 @@ describe("resolveGenerators — a doc generator is not offered as a `meta gen` n catch (err) { message = err instanceof Error ? err.message : String(err); } expect(message).toContain(`unknown generator "no-such-generator"`); - const listed = (/Available generators: ([^.]*)\./.exec(message)?.[1] ?? "") + const listed = (/Available native generators: ([^.]*)\./.exec(message)?.[1] ?? "") .split(",").map((s) => s.trim()); expect(listed).toContain("entity"); expect(listed).not.toContain("api-docs"); expect(listed).not.toContain("docs"); - // `mermaid-er` is neutral and IS in the list: `meta gen` runs it. - expect(listed).toContain("mermaid-er"); + // `mermaid-er` is neutral, so its name is refused and not offered either. + expect(listed).not.toContain("mermaid-er"); // ...and says where documentation comes from, so the reader is not left guessing // why a catalog name is missing from the list. expect(message).toContain("meta docs"); From f7b84306a56591d7664ab43a9a37bee8437a1486 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 11 Oct 2026 12:41:36 -0400 Subject: [PATCH 3/3] docs(changelog): record the doc-generator signal fix under 1.1.0 1.1.0 has not shipped, so the entry belongs in its Fixed list, not under Unreleased. --- CHANGELOG.md | 80 ++++++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14942f67d..5b61aad1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,46 +12,6 @@ here.** ### Fixed -- **A doc generator skipped by `meta gen` is now reported in every output form.** `meta docs` - is the door for documentation (ADR-0025), so `meta gen` warns about and skips a `docsFile()` - or `apiDocsFile()` listed under `generators`. That has not changed. What changed is who hears - about it: - - **`meta gen --format json|toon`** (the default on a pipe) printed the runner's warnings to - stderr only, and the result's `warnings` carried unknown-attribute findings alone, so the - document described a clean run with no API docs and no reason. `warnings[]` now carries - every warning the run printed: all of the runner's (not only this one), a generator's own - `ctx.warn`, the runtime-boundary advisory, and the stale-agent-context and ignored-manifest - advisories. `help[]` gains a `meta docs` next step when a doc generator was skipped. Text - output is unchanged. - - **A config that wires only doc generators** was told `no entities to generate` and to - `author entities in this project's metadata sources`, for a project that has them. The - summary, the next step and the text line now name the real reason. - - **`meta gen --list`** presented `api-docs` as one more generator to wire, and printed no - note on any row: the registry's notes for `docs` and `mermaid-er` stopped being rendered - when the catalog was rewritten. The `api-docs` and `docs` rows now read ``run by `meta - docs` `` with the reason under the row, a wired one reads ``WIRED but skipped by `meta - gen` ``, and `--probe` no longer prints `would emit 0` for them. In `--format json` the row - carries `note` and `door: "meta docs"`, and `project.wouldEmit` is `null` instead of `0`. - The `mermaid-er` row's note now states the back-compat fact: it is deprecated, a - `mermaidErDiagram()` already wired in `generators` still runs, and `meta docs` draws the - same diagram on its overview page. A neutral name wired as a string (`"docs"`, - `"mermaid-er"`), which `meta gen` refuses, reads ``WIRED by name, which `meta gen` - refuses`` (`project.refused` in `--format json`). - - **`meta verify`** said nothing about a config wired this way. It now reports each one as an - advisory row (rule `doc-generator-in-gen`, in `antiPatterns.rows[]`) that names - `meta verify --docs` as the gate that checks documentation. A neutral name wired as a - string is reported under rule `doc-generator-refused-by-gen`, because `meta gen` fails on - it rather than skipping it. Advisory: the exit code is unchanged. - - **The unknown-generator error** listed `api-docs` among the names `meta gen` accepts. It no - longer does, and it says documentation comes from `meta docs`. - - `runGen()` returns a new `skippedDocGenerators: string[]` beside `warnings`, and - `@metaobjectsdev/codegen-ts` exports `isPackagedDocsGenerator` (skipped) and - `isNeutralGeneratorName` (refused), so a caller embedding the runner can ask rather than - match a sentence. TypeScript only: in the C#, Java, Kotlin and Python - ports documentation is a separate command (`dotnet meta docs`, `mvn metaobjects:docs`, - `metaobjects docs`) and no doc generator is registered for code generation, so there is - nothing to wire and nothing to skip. - `meta verify --docs` now reports a generated page in the docs root that no current object produces (for example the `.md` page of an object that was removed) as drift. It was silent about every such page outside `agent/`, so an adopter kept ten orphan pages with the @@ -712,6 +672,46 @@ until you regenerate. ### Fixed +- **A doc generator skipped by `meta gen` is now reported in every output form.** `meta docs` + is the door for documentation (ADR-0025), so `meta gen` warns about and skips a `docsFile()` + or `apiDocsFile()` listed under `generators`. That has not changed. What changed is who hears + about it: + - **`meta gen --format json|toon`** (the default on a pipe) printed the runner's warnings to + stderr only, and the result's `warnings` carried unknown-attribute findings alone, so the + document described a clean run with no API docs and no reason. `warnings[]` now carries + every warning the run printed: all of the runner's (not only this one), a generator's own + `ctx.warn`, the runtime-boundary advisory, and the stale-agent-context and ignored-manifest + advisories. `help[]` gains a `meta docs` next step when a doc generator was skipped. Text + output is unchanged. + - **A config that wires only doc generators** was told `no entities to generate` and to + `author entities in this project's metadata sources`, for a project that has them. The + summary, the next step and the text line now name the real reason. + - **`meta gen --list`** presented `api-docs` as one more generator to wire, and printed no + note on any row: the registry's notes for `docs` and `mermaid-er` stopped being rendered + when the catalog was rewritten. The `api-docs` and `docs` rows now read ``run by `meta + docs` `` with the reason under the row, a wired one reads ``WIRED but skipped by `meta + gen` ``, and `--probe` no longer prints `would emit 0` for them. In `--format json` the row + carries `note` and `door: "meta docs"`, and `project.wouldEmit` is `null` instead of `0`. + The `mermaid-er` row's note now states the back-compat fact: it is deprecated, a + `mermaidErDiagram()` already wired in `generators` still runs, and `meta docs` draws the + same diagram on its overview page. A neutral name wired as a string (`"docs"`, + `"mermaid-er"`), which `meta gen` refuses, reads ``WIRED by name, which `meta gen` + refuses`` (`project.refused` in `--format json`). + - **`meta verify`** said nothing about a config wired this way. It now reports each one as an + advisory row (rule `doc-generator-in-gen`, in `antiPatterns.rows[]`) that names + `meta verify --docs` as the gate that checks documentation. A neutral name wired as a + string is reported under rule `doc-generator-refused-by-gen`, because `meta gen` fails on + it rather than skipping it. Advisory: the exit code is unchanged. + - **The unknown-generator error** listed `api-docs` among the names `meta gen` accepts. It no + longer does, and it says documentation comes from `meta docs`. + + `runGen()` returns a new `skippedDocGenerators: string[]` beside `warnings`, and + `@metaobjectsdev/codegen-ts` exports `isPackagedDocsGenerator` (skipped) and + `isNeutralGeneratorName` (refused), so a caller embedding the runner can ask rather than + match a sentence. TypeScript only: in the C#, Java, Kotlin and Python + ports documentation is a separate command (`dotnet meta docs`, `mvn metaobjects:docs`, + `metaobjects docs`) and no doc generator is registered for code generation, so there is + nothing to wire and nothing to skip. - **One shared-enum rule in all five ports: a field keeps the shared type through any number of `extends` hops.** An enum field that extends another field which itself reaches a shared (package-level abstract) enum was resolved only when it extended the abstract directly, so a