@metaobjectsdev/codegen-ts 1.0.0-rc.5 → 1.0.0-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/constants.d.ts +10 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +10 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/generator.d.ts +15 -0
  6. package/dist/generator.d.ts.map +1 -1
  7. package/dist/generator.js.map +1 -1
  8. package/dist/generators/agent-docs-file.d.ts.map +1 -1
  9. package/dist/generators/agent-docs-file.js +12 -3
  10. package/dist/generators/agent-docs-file.js.map +1 -1
  11. package/dist/generators/agent-schema-input.d.ts +11 -0
  12. package/dist/generators/agent-schema-input.d.ts.map +1 -1
  13. package/dist/generators/agent-schema-page.d.ts.map +1 -1
  14. package/dist/generators/agent-schema-page.js +13 -1
  15. package/dist/generators/agent-schema-page.js.map +1 -1
  16. package/dist/generators/names-file.d.ts.map +1 -1
  17. package/dist/generators/names-file.js +2 -1
  18. package/dist/generators/names-file.js.map +1 -1
  19. package/dist/index.d.ts +3 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +6 -2
  22. package/dist/index.js.map +1 -1
  23. package/dist/metaobjects-config.d.ts +10 -0
  24. package/dist/metaobjects-config.d.ts.map +1 -1
  25. package/dist/metaobjects-config.js +14 -1
  26. package/dist/metaobjects-config.js.map +1 -1
  27. package/dist/overwrite-policy.d.ts +15 -3
  28. package/dist/overwrite-policy.d.ts.map +1 -1
  29. package/dist/overwrite-policy.js +26 -0
  30. package/dist/overwrite-policy.js.map +1 -1
  31. package/dist/runner.d.ts.map +1 -1
  32. package/dist/runner.js +47 -5
  33. package/dist/runner.js.map +1 -1
  34. package/dist/ui-tier-gate.d.ts +44 -0
  35. package/dist/ui-tier-gate.d.ts.map +1 -0
  36. package/dist/ui-tier-gate.js +68 -0
  37. package/dist/ui-tier-gate.js.map +1 -0
  38. package/package.json +6 -6
  39. package/src/constants.ts +11 -0
  40. package/src/generator.ts +15 -0
  41. package/src/generators/agent-docs-file.ts +11 -2
  42. package/src/generators/agent-schema-input.ts +11 -0
  43. package/src/generators/agent-schema-page.ts +14 -1
  44. package/src/generators/names-file.ts +2 -1
  45. package/src/index.ts +6 -2
  46. package/src/metaobjects-config.ts +20 -1
  47. package/src/overwrite-policy.ts +43 -2
  48. package/src/reference/names.ts +5 -1
  49. package/src/runner.ts +50 -5
  50. package/src/ui-tier-gate.ts +79 -0
package/src/index.ts CHANGED
@@ -8,6 +8,10 @@ export type { RunGenOpts, RunGenResult } from "./runner.js";
8
8
 
9
9
  export type { Generator, GenContext, EmittedFile, GeneratorFactory } from "./generator.js";
10
10
  export { perEntity, perPackage, perModel, oncePerRun } from "./generator.js";
11
+ // The one aggregation of the UI-tier markers. Exported because `meta docs` is the
12
+ // SECOND door onto `agent/ui.md` and must answer "does this run emit a UI tier?"
13
+ // with the same function the gen runner uses, not a second derivation of it.
14
+ export { runEmitsUiTier, runEmitsHonoRoutes, UI_TIER_GENERATOR_NAMES } from "./ui-tier-gate.js";
11
15
 
12
16
  // SP-1 declarative Mustache template-codegen — scope walks, neutral data dict,
13
17
  // output-pattern, and the JSON template-spec the CLI ports reuse.
@@ -42,7 +46,7 @@ export { defineConfig, normalizeConfig, resolveGenerators, resolveDocsConfig } f
42
46
  // The dialect every generator falls back to when a config declares none. Exported because
43
47
  // `meta docs` must apply the SAME default `normalizeConfig` does — a command that resolved
44
48
  // the dialect differently would document a schema the toolchain does not produce.
45
- export { DEFAULT_DIALECT } from "./metaobjects-config.js";
49
+ export { DEFAULT_DIALECT, DEFAULT_DOCS_DIR } from "./metaobjects-config.js";
46
50
  // The runner's own "does this model need a dialect?" guard. `meta docs` must ask it rather
47
51
  // than defaulting, because DEFAULT_DIALECT is inert and a DB project with no dialect is one
48
52
  // `meta gen` REFUSES — documenting it as sqlite states an answer the toolchain never gave.
@@ -78,7 +82,7 @@ export { decideAndWrite, GitMissingError, WRITE_STATUSES } from "./overwrite-pol
78
82
  export { contentHash, readGeneratedHash, listGeneratedPaths } from "./overwrite-policy.js";
79
83
 
80
84
  export { CodegenError } from "./errors.js";
81
- export { GENERATED_HEADER, DEFAULT_OUT_DIR, RETIRED_CODEGEN_ATTRS, type RetiredCodegenAttr } from "./constants.js";
85
+ export { GENERATED_HEADER, NAMES_FILE_SUFFIX, DEFAULT_OUT_DIR, RETIRED_CODEGEN_ATTRS, type RetiredCodegenAttr } from "./constants.js";
82
86
  export { warnRetiredCodegenAttrs } from "./retired-codegen-attrs.js";
83
87
 
84
88
  export { formatTs } from "./format.js";
@@ -63,6 +63,11 @@ export interface ResolvedGenConfig {
63
63
  * Undefined ⇒ false. Templates read the same fact off `RenderContext.includeNames`,
64
64
  * which the runner sets from this same aggregation. */
65
65
  includeNames?: boolean;
66
+ /** Whether any CLIENT UI generator (form, hook, grid, columns) is active in the
67
+ * run — aggregated by the runner from the suite's `emitsUiTier` markers.
68
+ * `agent/ui.md` reads this to decide whether a UI tier exists to describe;
69
+ * a metadata predicate can only say a UI *could* be generated. Undefined ⇒ false. */
70
+ includeUiTier?: boolean;
66
71
  /**
67
72
  * FR-019 / ADR-0026: the module specifier from which an externally-PROVIDED
68
73
  * shared enum (`@provided: true` on an abstract package-level `field.enum`) is
@@ -289,13 +294,27 @@ export interface ResolvedDocsConfig {
289
294
  /** Merge the config `docs:` block with CLI overrides over documented defaults.
290
295
  * `fallbackLayout` is the project's `outputLayout` so docs default to the same
291
296
  * page placement as codegen when `docs.layout` is unset. */
297
+ /**
298
+ * Where `meta docs` writes when nothing says otherwise. A SUB-directory on purpose: the
299
+ * generated pages are MetaObjects' to own and overwrite, and `docs/` as a whole is not.
300
+ */
301
+ export const DEFAULT_DOCS_DIR = "./docs/generated";
302
+
292
303
  export function resolveDocsConfig(
293
304
  block: DocsConfig | undefined,
294
305
  cli: Partial<ResolvedDocsConfig>,
295
306
  fallbackLayout: OutputLayout,
296
307
  ): ResolvedDocsConfig {
297
308
  return {
298
- outDir: cli.outDir ?? block?.outDir ?? "./docs",
309
+ // F57 NOT "./docs". `docs/` is the human documentation folder in most repos: one
310
+ // estate's held a business-strategy .docx, product screenshots, email drafts and an
311
+ // analytics roadmap, and a bare `meta docs` scattered 29 generated pages through it
312
+ // and wanted to own `docs/README.md`. The gate's own design language is right —
313
+ // "docs.outDir is a directory, not a namespace MetaObjects owns" — and that is
314
+ // precisely the argument for not defaulting into the most-owned directory name in
315
+ // the ecosystem. `meta init` scaffolds this key explicitly so a new project's choice
316
+ // is visible rather than implied.
317
+ outDir: cli.outDir ?? block?.outDir ?? DEFAULT_DOCS_DIR,
299
318
  layout: cli.layout ?? block?.layout ?? fallbackLayout,
300
319
  baseUrl: cli.baseUrl ?? block?.baseUrl ?? "",
301
320
  // `requirements` defaults ON. Safe ONLY because requirementsFile() emits ZERO
@@ -60,6 +60,12 @@ export const WRITE_STATUSES = [
60
60
  "conflict",
61
61
  "refused",
62
62
  "skipped",
63
+ /** `--baseline=adopt` — the file WOULD have been refused, and instead its current
64
+ * content was recorded as the merge base. Nothing was written. Its own status
65
+ * rather than "unchanged" or "skipped" because the run's whole product is the
66
+ * manifest it just created: a summary that reported these as unchanged would
67
+ * hide the one thing the adopter has to commit. */
68
+ "adopted",
63
69
  /** FR-038 §8 — deleted because it was generated by a previous run, is no longer
64
70
  * generated, and was never edited by hand. Reported as a file outcome rather
65
71
  * than a warning because a deletion is exactly as consequential as a write,
@@ -81,8 +87,20 @@ export type MergeStrategy = "overwrite" | "skip-existing";
81
87
  /** "default" — the standard three-way merge flow described in the file header.
82
88
  * "fresh" — opt-in via `meta gen --baseline=fresh`. When .gen-state is absent
83
89
  * but the file exists, OVERWRITE with fresh content and seed .gen-state from
84
- * the fresh content (caveat 3 escape hatch). */
85
- export type BaselineMode = "default" | "fresh";
90
+ * the fresh content (caveat 3 escape hatch).
91
+ * "adopt" opt-in via `meta gen --baseline=adopt`. Same population, opposite
92
+ * half: where the run would REFUSE, record the file's CURRENT content as the
93
+ * merge base and write nothing. It exists because the refusal's leading remedy
94
+ * ("commit .hashes.json") could not be performed by the population it named —
95
+ * nothing writes a manifest until a gen succeeds, and gen refuses until one
96
+ * exists. `adopt` is the run that produces the file to commit.
97
+ *
98
+ * What it is NOT: protection for an edit already inside one of those files. The
99
+ * base becomes the edited text, so base == ours and the next regen's three-way
100
+ * merge takes fresh output wholesale. It protects edits made FROM THEN ON, and
101
+ * it guarantees that establishing the baseline writes nothing — which is what
102
+ * lets the content change land as its own reviewable diff afterwards. */
103
+ export type BaselineMode = "default" | "fresh" | "adopt";
86
104
 
87
105
  export interface DecideAndWriteOpts {
88
106
  strategy?: MergeStrategy;
@@ -468,6 +486,7 @@ type WriteCase =
468
486
  | { kind: "no-snapshot-unchanged" }
469
487
  | { kind: "no-snapshot-pristine-overwrite" }
470
488
  | { kind: "no-snapshot-refused"; hasRecord: boolean }
489
+ | { kind: "no-snapshot-adopt"; currentText: string }
471
490
  | { kind: "snapshot-unchanged" }
472
491
  | { kind: "snapshot-merge-required"; snapshotText: string };
473
492
 
@@ -521,6 +540,18 @@ function classifyWrite(
521
540
 
522
541
  // Either somebody edited it (hash mismatch) or we have no record of writing
523
542
  // it at all (no hash). Both are unprovable, so fail closed.
543
+ //
544
+ // …unless the caller has explicitly adopted what is on disk. `adopt` is placed
545
+ // HERE, at the refusal, rather than at the top of the no-snapshot branch, and
546
+ // that placement is the design: it means adopting can only ever convert a
547
+ // refusal into a recorded baseline. Everything else — unchanged, and the
548
+ // pristine-file overwrite a fresh clone depends on — keeps behaving exactly as
549
+ // it does without the flag, so a project cannot accidentally freeze its
550
+ // regeneration by passing it.
551
+ if ((opts.baseline ?? "default") === "adopt") {
552
+ return { kind: "no-snapshot-adopt", currentText: current };
553
+ }
554
+
524
555
  return {
525
556
  kind: "no-snapshot-refused",
526
557
  hasRecord: readGeneratedHash(genStateDir, relPath) !== undefined,
@@ -570,6 +601,8 @@ export function previewWriteStatus(
570
601
  return "overwrite";
571
602
  case "no-snapshot-refused":
572
603
  return "refused";
604
+ case "no-snapshot-adopt":
605
+ return "adopted";
573
606
  case "snapshot-merge-required":
574
607
  return "overwrite";
575
608
  }
@@ -641,6 +674,14 @@ export function decideAndWrite(
641
674
  "--baseline=fresh to overwrite it and adopt fresh output as the baseline.",
642
675
  };
643
676
 
677
+ case "no-snapshot-adopt":
678
+ // Record what is ON DISK — deliberately `kase.currentText`, never `content`.
679
+ // Recording fresh output would claim we wrote a file we did not, and the next
680
+ // run would then read the adopter's file as edited-since-generated and refuse
681
+ // all over again, which is the loop this mode exists to end.
682
+ advanceSnapshot(genStateDir, relPath, kase.currentText);
683
+ return { path, status: "adopted" };
684
+
644
685
  case "snapshot-unchanged":
645
686
  return { path, status: "unchanged" };
646
687
 
@@ -36,6 +36,7 @@ import {
36
36
  namesArtifactSuperOf,
37
37
  renderNamesDecl,
38
38
  resolveObjectNames,
39
+ NAMES_FILE_SUFFIX,
39
40
  type EmittedFile,
40
41
  type GenContext,
41
42
  type Generator,
@@ -78,7 +79,10 @@ export function namesFile(opts?: NamesFileOpts): Generator {
78
79
  // its entity sits at <pkg>/<Entity>.ts — an unresolvable import, and a hard
79
80
  // conflicting-duplicate-path failure as soon as two packages declare a
80
81
  // same-bare-named entity.
81
- entityOutputPath(layout, effectivePackage(obj), `${obj.name}.names.ts`);
82
+ // NAMES_FILE_SUFFIX, not a literal: `meta verify --codegen` treats this artifact as
83
+ // fail-closed (a hand edit in it IS drift, unlike every other generated file) and
84
+ // recognises it BY THIS SUFFIX. Rename it and you opt out of that protection.
85
+ entityOutputPath(layout, effectivePackage(obj), `${obj.name}${NAMES_FILE_SUFFIX}`);
82
86
 
83
87
  const superSpecifierFor = (obj: MetaObject): string | undefined => {
84
88
  const sup = namesArtifactSuperOf(obj);
package/src/runner.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { join, relative, resolve, isAbsolute, dirname } from "node:path";
2
2
  import { warnMissingPromptGenerators } from "./prompt-generator-gate.js";
3
+ import { runEmitsHonoRoutes, runEmitsUiTier, warnUnmarkedUiGenerators } from "./ui-tier-gate.js";
3
4
  import { warnRetiredCodegenAttrs } from "./retired-codegen-attrs.js";
4
5
  import { tmpdir } from "node:os";
5
6
  import { fileURLToPath } from "node:url";
@@ -469,7 +470,20 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
469
470
  // Auto-detect: is the OPT-IN Hono routes generator in the active suite? If so,
470
471
  // surface it on every generator's ctx.config so api-docs documents the Hono
471
472
  // CRUD surface it actually emits (rather than silently omitting it).
472
- const includeHonoRoutes = config.generators.some((g) => g.emitsHonoRoutes === true);
473
+ // The explicit config key WINS over the marker aggregation, at both doors. `meta docs`
474
+ // already read `loadedConfig?.includeHonoRoutes ?? …`, and the runner did not — so an
475
+ // adopter who set the key got the surface documented and not emitted, or the reverse.
476
+ // The key is settable because `MetaobjectsGenConfig extends Omit<ResolvedGenConfig,…>`,
477
+ // and it is the escape hatch for an owned generator whose name was changed; an escape
478
+ // hatch honoured by one of two doors is the defect these flags exist to close.
479
+ const includeHonoRoutes = config.includeHonoRoutes ?? runEmitsHonoRoutes(config.generators);
480
+
481
+ // Same auto-detection for the CLIENT UI tier. `agent/ui.md` describes forms, grids
482
+ // and the endpoints their hooks call; whether any of that is emitted is a generator
483
+ // fact, and the page's own gate could only see metadata. Run-scoped like
484
+ // includeHonoRoutes — the page asks "is this surface in the run?", never "does it
485
+ // land in my target?".
486
+ const includeUiTier = config.includeUiTier ?? runEmitsUiTier(config.generators);
473
487
 
474
488
  // §A6 — same auto-detection for the OPT-IN names generator. The entity tier may only
475
489
  // REFERENCE `<Entity>Names` when something in this run actually emits it; the names
@@ -493,6 +507,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
493
507
  // this, said nothing — while `meta verify` reported the template "clean". See
494
508
  // prompt-generator-gate.ts. Self-extinguishing; warning only.
495
509
  warnMissingPromptGenerators(root, config.generators, (m) => warnings.push(m));
510
+ warnUnmarkedUiGenerators(config.generators, (m) => warnings.push(m));
496
511
 
497
512
  // <Entity>Names is opt-in on TypeScript and an existing project gets no signal that
498
513
  // it exists. Fires ONCE, on the first gen after crossing the release that made it the
@@ -598,6 +613,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
598
613
  outputLayout: selfTarget.outputLayout,
599
614
  includeHonoRoutes,
600
615
  includeNames: namesTargets.has(selfTarget.name),
616
+ includeUiTier,
601
617
  },
602
618
  renderContext,
603
619
  ...(projectRoot !== undefined && { projectRoot }),
@@ -719,10 +735,15 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
719
735
  `no codegen hash manifest — so 'meta gen' cannot tell your edits from its own ` +
720
736
  `stale output, and it will not guess. This is the expected first run for a ` +
721
737
  `project created before the manifest was committed. ` +
722
- `ONE-TIME FIX: commit '.metaobjects/.gen-state/.hashes.json' (un-ignore it in ` +
723
- `.metaobjects/.gitignore with '.gen-state/*' + '!.gen-state/.hashes.json'), ` +
724
- `then re-run. To adopt fresh output and DISCARD any hand edits in these files ` +
725
- `instead, re-run with --baseline=fresh. Files: ${names.join(", ")}${more}.`,
738
+ `ONE-TIME FIX: re-run with --baseline=adopt — it records the files you have as ` +
739
+ `the merge base and writes NOTHING — then commit ` +
740
+ `'.metaobjects/.gen-state/.hashes.json' (un-ignore it in .metaobjects/.gitignore ` +
741
+ `with '.gen-state/*' + '!.gen-state/.hashes.json') and run 'meta gen' again, where ` +
742
+ `the regeneration arrives as its own reviewable diff. Adopting DECLARES these files ` +
743
+ `to be generated output, so an edit already inside one of them is part of the ` +
744
+ `baseline and that regeneration will replace it — commit before you run it. ` +
745
+ `To write fresh output NOW and DISCARD any hand edits in these files, ` +
746
+ `--baseline=fresh does both in one step. Files: ${names.join(", ")}${more}.`,
726
747
  );
727
748
  return;
728
749
  }
@@ -762,6 +783,28 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
762
783
  );
763
784
  };
764
785
 
786
+ // `--baseline=adopt` — the counterpart of the aggregate above, and aggregated for the
787
+ // same reason: every adopted file has one cause and one next step. Reported even though
788
+ // nothing failed, because a run that writes nothing and exits 0 is otherwise
789
+ // indistinguishable from a no-op, and the file it DID produce (`.hashes.json`) is the
790
+ // one the adopter now has to commit.
791
+ const reportAdoptions = (): void => {
792
+ const adopted = writes.filter((w) => w.status === "adopted");
793
+ if (adopted.length === 0) return;
794
+ const names = adopted.slice(0, MAX_NAMED).map((w) => relativeForDisplay(w.path));
795
+ const more = adopted.length > MAX_NAMED ? `, and ${adopted.length - MAX_NAMED} more` : "";
796
+ warnings.push(
797
+ `Recorded ${adopted.length} existing file(s) as the codegen baseline and wrote ` +
798
+ `nothing. NEXT: commit '.metaobjects/.gen-state/.hashes.json' (un-ignore it in ` +
799
+ `.metaobjects/.gitignore with '.gen-state/*' + '!.gen-state/.hashes.json'), then run ` +
800
+ `'meta gen' — these files regenerate normally from here, as their own reviewable ` +
801
+ `diff. Adopting DECLARED them to be generated output: an edit already inside one is ` +
802
+ `part of the baseline and that regeneration will replace it, so check the diff ` +
803
+ `against git. Edits you make FROM NOW ON merge, because the base now exists. ` +
804
+ `Files: ${names.join(", ")}${more}.`,
805
+ );
806
+ };
807
+
765
808
  const sweep = (dryRun: boolean): void => {
766
809
  if (projectRoot === undefined || orphanJobs.length === 0) return;
767
810
 
@@ -863,6 +906,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
863
906
  });
864
907
  }
865
908
  reportRefusals();
909
+ reportAdoptions();
866
910
  // A preview that hides a pending deletion is worse than no preview at all, so
867
911
  // the sweep still runs — in decide-and-report mode, touching nothing.
868
912
  sweep(true);
@@ -896,6 +940,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
896
940
  }
897
941
 
898
942
  reportRefusals();
943
+ reportAdoptions();
899
944
 
900
945
  // Sweep AFTER the writes: writing is the primary job, and a deletion that runs
901
946
  // first would be unrecoverable if a later write threw. Ordering cannot change
@@ -0,0 +1,79 @@
1
+ import type { Generator } from "./generator.js";
2
+
3
+ /**
4
+ * Generators that emit a CLIENT UI artifact — a form, a hook, a grid or its columns.
5
+ *
6
+ * Names, not identities, for the same reason `PROMPT_GENERATOR_NAMES` uses names: under
7
+ * ADR-0034 scaffold-and-own an adopter runs their OWN copy, and `meta eject` copies the
8
+ * template verbatim, so the name survives the copy while the object identity does not.
9
+ *
10
+ * This set exists ONLY as a compatibility path for a copy ejected before `emitsUiTier`
11
+ * existed. The marker is the mechanism; a name match makes the run behave as it did
12
+ * before AND says the marker is missing (see {@link warnUnmarkedUiGenerators}), because
13
+ * a page silently disappearing is exactly the kind of degradation that must announce
14
+ * itself.
15
+ */
16
+ export const UI_TIER_GENERATOR_NAMES: ReadonlySet<string> = new Set([
17
+ "form-file",
18
+ "tanstack-query",
19
+ "tanstack-grid",
20
+ "tanstack-grid-hook",
21
+ "angular-form",
22
+ "angular-grid",
23
+ "angular-service",
24
+ ]);
25
+
26
+ /** Generators in the suite that emit a UI artifact but carry no `emitsUiTier` marker. */
27
+ function unmarked(generators: readonly Generator[]): Generator[] {
28
+ return generators.filter(
29
+ (g) => g.emitsUiTier !== true && UI_TIER_GENERATOR_NAMES.has(g.name),
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Does this run emit a client UI tier?
35
+ *
36
+ * The declared marker OR the legacy name. `agent/ui.md` gates on the answer: its own
37
+ * predicate was metadata-only (`servesReadApi`), which says a UI *could* be generated
38
+ * for an object and never that this run generates one — so a project with
39
+ * `generators: []` was handed a confident page naming endpoints nothing serves.
40
+ */
41
+ export function runEmitsUiTier(generators: readonly Generator[]): boolean {
42
+ return generators.some((g) => g.emitsUiTier === true) || unmarked(generators).length > 0;
43
+ }
44
+
45
+ /**
46
+ * A generator ejected before `emitsUiTier` existed still emits its tier, so the run is
47
+ * NOT degraded — the name match above keeps `agent/ui.md` emitting. It is warned about
48
+ * anyway: an adopter who renames their copy loses the page with no signal at all, and
49
+ * the one-line fix belongs where the fact is known rather than in a migration note
50
+ * nobody re-reads. Self-extinguishing — adding the marker silences it forever.
51
+ */
52
+ export function warnUnmarkedUiGenerators(
53
+ generators: readonly Generator[],
54
+ warn: (msg: string) => void,
55
+ ): void {
56
+ const stale = unmarked(generators);
57
+ if (stale.length === 0) return;
58
+ const names = stale.map((g) => `'${g.name}'`).join(", ");
59
+ warn(
60
+ `${names} emit${stale.length === 1 ? "s" : ""} a UI artifact but carr${stale.length === 1 ? "ies" : "y"} no ` +
61
+ `'emitsUiTier: true' marker — an owned copy ejected before the marker existed. ` +
62
+ `agent/ui.md still emits (matched by name), but a renamed copy would not: add ` +
63
+ `'emitsUiTier: true' beside 'name:' in your copy, or re-eject it.`,
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Does this run emit the OPT-IN Hono routes surface?
69
+ *
70
+ * Lives beside `runEmitsUiTier` for one reason: both are "aggregate a marker across the
71
+ * suite", and both were being re-derived at the `meta docs` door — which read the RAW
72
+ * config, where a generator wired by stable name (`generators: ["routes-hono"]`, legal
73
+ * under ADR-0021 #1) is a string carrying no marker at all. So `api-docs` omitted the
74
+ * Hono surface for a project that had wired it. One helper, called after the strings are
75
+ * resolved, is what stops the two doors disagreeing again.
76
+ */
77
+ export function runEmitsHonoRoutes(generators: readonly Generator[]): boolean {
78
+ return generators.some((g) => g.emitsHonoRoutes === true);
79
+ }