diff --git a/CHANGELOG.md b/CHANGELOG.md index e1643e34a..5b61aad1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -672,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 diff --git a/agent-context/skills/metaobjects-codegen/references/typescript-docs.md b/agent-context/skills/metaobjects-codegen/references/typescript-docs.md index 356232ff2..41dff507a 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript-docs.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript-docs.md @@ -35,6 +35,21 @@ 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`. + +**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 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..77edfb434 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,21 @@ 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`. 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 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..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 @@ -35,6 +35,21 @@ 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`. + +**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 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..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 @@ -35,6 +35,21 @@ 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`. + +**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 project's generated API, so you don't have to guess them. diff --git a/fixtures/generator-registry-conformance/registry.json b/fixtures/generator-registry-conformance/registry.json index 969899379..a318ef529 100644 --- a/fixtures/generator-registry-conformance/registry.json +++ b/fixtures/generator-registry-conformance/registry.json @@ -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 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/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..f6f5fec03 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,17 @@ 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 (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); + } catch { + // advisory only — a config whose factory throws is `meta gen`'s to report. + } + antiPatternSection = ranSection([ ...antiPatternRows(findings), ...missingBaseUrlRows(baseUrl), @@ -1028,6 +1041,7 @@ export async function verifyCommand( ...libraryPrefixRows(libraryPrefix), ...unindexedFkRows(unindexedFks), ...referentialActionConflictRows(actionConflicts), + ...docGeneratorRows(docGenerators), ]); if (findings.length > 0) { log.warn( @@ -1071,6 +1085,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\` does not run (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..21c7591c5 100644 --- a/server/typescript/packages/cli/src/lib/advisory.ts +++ b/server/typescript/packages/cli/src/lib/advisory.ts @@ -115,6 +115,8 @@ 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"; +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 }> = [ @@ -143,6 +145,16 @@ 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`, + }, + { + 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`, + }, ]; /** @@ -262,6 +274,23 @@ 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. 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; refused: boolean; message: string }[], +): AdvisoryFindingRow[] { + return findings.map((f) => ({ + file: f.file, + line: 0, + rule: f.refused ? RULE_DOC_GENERATOR_REFUSED : 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..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 { 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"; @@ -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. */ @@ -91,9 +99,13 @@ 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. */ + /** 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; @@ -286,8 +298,16 @@ 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` + // 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 +330,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 +361,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, @@ -355,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 } : {}), @@ -458,6 +484,23 @@ 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."; + +/** + * 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[] = []; @@ -477,7 +520,14 @@ 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.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. @@ -502,6 +552,9 @@ 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 (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 new file mode 100644 index 000000000..530057a84 --- /dev/null +++ b/server/typescript/packages/cli/src/lib/doc-generator-advisory.ts @@ -0,0 +1,81 @@ +// 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. +// +// 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 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, +} 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; + /** 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 `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 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 = spec?.name; + if (implName !== undefined && isPackagedDocsGenerator(implName)) { + findings.push(finding(packagedDocsFactoryCall(implName), false)); + } + } + 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..e58d77bbd --- /dev/null +++ b/server/typescript/packages/cli/test/gen-docs-door-signals.test.ts @@ -0,0 +1,501 @@ +// 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; refused?: true; 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); + }); +}); + +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); + 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 `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 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(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'"); + }); +}); + +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"); + }); + + // 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("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`"); + }); + + 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("deprecated"); + expect(row(rows, "mermaid-er").note ?? "").toContain("still runs"); + 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"); + }); + + // 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. + 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 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; + 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"); + }); + + // `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 a0bbb4ea2..b6d9b7cf4 100644 --- a/server/typescript/packages/codegen-ts/src/generator-registry.ts +++ b/server/typescript/packages/codegen-ts/src/generator-registry.ts @@ -278,6 +278,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 +305,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: "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 a06facfc2..fc229e275 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -55,8 +55,12 @@ 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"; +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 081c02265..41b75d687 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"; /** @@ -369,6 +370,15 @@ export function resolveTargets(config: MetaobjectsGenConfig): Record { if (typeof spec !== "string") return spec; const entry = generatorRegistry[spec]; if (entry === undefined) { + // 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") + .filter((e) => e.tier === "native" && !isPackagedDocsGenerator(e.factory().name)) .map((e) => e.name) .sort(); throw new Error( - `unknown generator "${spec}". Available native generators: ${native.join(", ")}.`, + `unknown generator "${spec}". Available native generators: ${native.join(", ")}. ` + + `Documentation is not a 'meta gen' generator: run '${DOCS_DOOR_COMMAND}'.`, ); } - if (entry.tier !== "native") { + 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 new file mode 100644 index 000000000..5e16aff21 --- /dev/null +++ b/server/typescript/packages/codegen-ts/src/packaged-docs-generators.ts @@ -0,0 +1,40 @@ +// 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. `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()"], +]); + +/** 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..46e0e89d0 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,32 @@ 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` 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"]); + 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", () => { 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..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 @@ -60,6 +60,8 @@ describe("resolveGenerators (registry string resolution)", () => { expect(msg).toContain("routes"); // 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"); } }); 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..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 @@ -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 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, 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"); + }); }); function relPath(base: string, full: string): string {