doctrina-cli 0.15.1 → 0.16.0

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 (84) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -212
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +4 -82
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +60 -138
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +56 -4
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +181 -23
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +191 -5
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/hooks/pre-commit.sample +6 -2
  82. package/templates/playbooks/bootstrap.md.template +43 -0
  83. package/templates/playbooks/chore.md.template +19 -0
  84. package/templates/playbooks/work.md.template +76 -0
@@ -0,0 +1,42 @@
1
+ // @ts-check
2
+ // One grammar for the NAME of a spec, a contract or a skill (change 0110).
3
+ //
4
+ // Three commands carried their own copy of `/^[a-z][a-z0-9-]*$/`, and none
5
+ // of them knew anything about a file system. Measured on Windows: `spec new
6
+ // nul` created `.doctrina/specs/nul/spec.md` — a directory Node could write
7
+ // and git could neither see nor remove (`could not open directory`, `failed
8
+ // to remove`), gone only through a `\\?\` path. `con`, `prn`, `aux`,
9
+ // `com1`-`com9` and `lpt1`-`lpt9` are device names on Windows, and this
10
+ // repository's own tree is Windows/CRLF. A 120-character name passed too
11
+ // (`Filename too long` from git), as did `trail-` and `a--b`.
12
+ //
13
+ // A name is a directory name on every file system the project will touch.
14
+ // The message names the rule that failed, so the fix is the invocation.
15
+
16
+ const RESERVED = new Set([
17
+ "con", "prn", "aux", "nul",
18
+ ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
19
+ ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
20
+ ]);
21
+
22
+ const NAME_MAX = 64;
23
+
24
+ /**
25
+ * null when `name` is a legal artifact name; otherwise the reason, ready to
26
+ * print after "error:".
27
+ *
28
+ * @param {string | undefined} name
29
+ * @param {string} kind "capability" | "contract name" | "skill name"
30
+ * @returns {string | null}
31
+ */
32
+ export function artifactNameError(name, kind) {
33
+ const value = String(name ?? "");
34
+ if (value === "") return `${kind} is required`;
35
+ if (!/^[a-z0-9-]+$/.test(value)) return `${kind} must be lowercase letters, digits, or hyphens (got "${value}")`;
36
+ if (!/^[a-z]/.test(value)) return `${kind} must start with a letter (got "${value}")`;
37
+ if (/-$/.test(value)) return `${kind} must not end with a hyphen (got "${value}")`;
38
+ if (/--/.test(value)) return `${kind} must not contain a doubled hyphen (got "${value}")`;
39
+ if (value.length > NAME_MAX) return `${kind} must be at most ${NAME_MAX} characters (got ${value.length})`;
40
+ if (RESERVED.has(value)) return `${kind} "${value}" is a reserved device name on Windows — a directory git cannot see or remove there; pick another`;
41
+ return null;
42
+ }
@@ -0,0 +1,97 @@
1
+ // @ts-check
2
+ import { c } from "./colors.js";
3
+ import { readTemplate, substitute } from "./templates.js";
4
+
5
+ // Playbooks are TEMPLATES, not code (audit finding F17).
6
+ //
7
+ // `printPlaybook()` was ~100 lines of literal `console.log` — including the
8
+ // documentation of the ops grammar — and the same procedure was written out
9
+ // again in AGENTS.md and twice more in docs/{en,pt}/workflow.md. Four homes
10
+ // for one fact, in the framework whose first design principle is that a fact
11
+ // has exactly one.
12
+ //
13
+ // Worse for adopters: a team whose process differs (an extra review step, a
14
+ // different close) had no way to say so. Every other scaffold resolves
15
+ // project-over-bundled per file (ADR 0019); the procedure the agent actually
16
+ // executes was the one thing hard-coded.
17
+ //
18
+ // So a playbook resolves through the same chain, and this module renders it.
19
+ //
20
+ // ---------------------------------------------------------------------------
21
+ // The markup
22
+ // ---------------------------------------------------------------------------
23
+ //
24
+ // Colour has to survive the move, or the output changes and the migration is
25
+ // not faithful. The template marks spans inline:
26
+ //
27
+ // [[c]]doctrina close 0001[[/c]] cyan — a command to run
28
+ // [[g]]...[[/g]] gray — commentary
29
+ // [[b]]...[[/b]] bold — a heading
30
+ // [[y]]...[[/y]] yellow — a warning
31
+ //
32
+ // Markup is expanded BEFORE token substitution, deliberately: a token's value
33
+ // can carry a user's prompt, and a prompt must never be able to inject colour
34
+ // into the playbook. Blocks the CLI computes (a capability ranking, a warning
35
+ // list) arrive already coloured through `c.*`, so they need no markup.
36
+ //
37
+ // Everything variable is a TOKEN, and the code pre-renders it. That answers
38
+ // the open question the change carried — extend the substituter, or
39
+ // pre-render? — in favour of pre-rendering: `substitute` stays a plain
40
+ // string map, and the template stays a document rather than becoming a
41
+ // programming language with conditionals and loops in it.
42
+
43
+ const MARKS = { c: c.cyan, g: c.gray, b: c.bold, y: c.yellow };
44
+
45
+ /** Expand `[[x]]...[[/x]]` colour spans. Innermost first, so spans nest. */
46
+ export function expandMarkup(text) {
47
+ let out = String(text);
48
+ let previous;
49
+ do {
50
+ previous = out;
51
+ out = out.replace(/\[\[([cgby])\]\]((?:(?!\[\[[cgby]\]\])[\s\S])*?)\[\[\/\1\]\]/g,
52
+ (_, mark, body) => MARKS[mark](body));
53
+ } while (out !== previous);
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Render a named playbook, resolved project-over-bundled.
59
+ *
60
+ * Returns the text; the caller prints it. Keeping the write at the edge is
61
+ * what lets a test assert the rendering without capturing stdout.
62
+ */
63
+ export function renderPlaybook(projectRoot, name, tokens = {}) {
64
+ const tpl = readTemplate(projectRoot, `playbooks/${name}.md.template`);
65
+ // A trailing newline in the file would print an extra blank line; the
66
+ // caller decides its own spacing.
67
+ return substitute(expandMarkup(dropEmptyBlocks(tpl.body, tokens)), tokens).replace(/\n$/, "");
68
+ }
69
+
70
+ /**
71
+ * A line holding NOTHING but one token, whose value is empty or absent, is
72
+ * removed rather than left as a blank line.
73
+ *
74
+ * That single rule is what lets the optional parts of a playbook — the
75
+ * thin-prompt warning, the capability hint, the backfill note — be plain
76
+ * tokens on their own line instead of conditionals in a template language.
77
+ * A block that IS present brings its own spacing in its value.
78
+ */
79
+ function dropEmptyBlocks(body, tokens) {
80
+ return body
81
+ .split("\n")
82
+ .filter((line) => {
83
+ const m = line.match(/^\s*\{\{(\w+)\}\}\s*$/);
84
+ if (!m) return true;
85
+ const value = tokens[m[1]];
86
+ return value !== undefined && String(value) !== "";
87
+ })
88
+ .join("\n");
89
+ }
90
+
91
+ /** Render and print. The one place a playbook reaches stdout. */
92
+ export function printPlaybookTemplate(projectRoot, name, tokens = {}) {
93
+ console.log(renderPlaybook(projectRoot, name, tokens));
94
+ }
95
+
96
+ /** The playbooks the CLI ships and `templates check` expects to resolve. */
97
+ export const PLAYBOOKS = ["work", "chore", "bootstrap"];
@@ -0,0 +1,57 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { exists } from "./fs-ops.js";
4
+ import { notADoctrinaProject } from "./exit-codes.js";
5
+
6
+ // The one precondition every command shares: this is a Doctrina project.
7
+ // Six command modules carried an identical private copy of it; a lib
8
+ // operation that needs it should not have to reach into a command for the
9
+ // check (audit finding F7).
10
+ export function ensureDoctrinaProject(projectRoot) {
11
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
12
+ throw notADoctrinaProject();
13
+ }
14
+ }
15
+
16
+ // The grammar a change id obeys: lowercase letters, digits and hyphens,
17
+ // opening on a letter or a digit — `0042-short-slug` is the shape `work`
18
+ // derives. Deliberately NOT the capability grammar, which additionally
19
+ // requires a leading letter; two rules that look alike are not one rule.
20
+ //
21
+ // It exists because `change new` had NO grammar (third audit, finding 1).
22
+ // Every other authoring command validated its argument; `change new` joined
23
+ // whatever it was given onto a path, so `change new ../../../elsewhere/evil`
24
+ // wrote a change folder OUTSIDE the project — against the `authoring` spec's
25
+ // own "shall not write outside the project working directory" — and ids like
26
+ // `0003-com espaco` reached `validate` and `index rebuild` as legitimate,
27
+ // producing a `next` action whose command could not be run.
28
+ export const CHANGE_ID = /^[a-z0-9][a-z0-9-]*$/;
29
+
30
+ export function isChangeId(id) {
31
+ return typeof id === "string" && CHANGE_ID.test(id);
32
+ }
33
+
34
+ /**
35
+ * Resolve `segments` under `projectRoot` and refuse anything that escapes it.
36
+ *
37
+ * Defence in depth, deliberately separate from the grammar above: the
38
+ * grammar is what tells the USER their id is wrong, and this is what holds
39
+ * even when a caller forgets to ask. A path is inside when it is the root
40
+ * itself or sits beneath it — compared after `path.resolve`, so `..`,
41
+ * absolute arguments and symlink-free trickery all normalise first.
42
+ *
43
+ * Throws rather than returning a code: a caller that reached here with an
44
+ * escaping path has a bug, and the entrypoint's catch turns a thrown error
45
+ * into the gate class with the message intact.
46
+ */
47
+ export function resolveWithinProject(projectRoot, ...segments) {
48
+ const root = path.resolve(projectRoot);
49
+ const target = path.resolve(root, ...segments);
50
+ const rel = path.relative(root, target);
51
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
52
+ throw new Error(
53
+ `refusing to write outside the project: "${path.join(...segments)}" resolves to ${target}`,
54
+ );
55
+ }
56
+ return target;
57
+ }
@@ -0,0 +1,137 @@
1
+ // @ts-check
2
+ // The clean-checkout reproducibility lint, as a collection rather than a
3
+ // printout: "would a fresh clone of this repository actually build?"
4
+ //
5
+ // It lives here because two commands ask it — `verify --clean` renders it,
6
+ // and `doctor` reports it as one row. `doctor` used to answer by spawning
7
+ // the CLI again and reading its exit code, which is a second integration
8
+ // style inside one binary and a process per row (audit finding F4).
9
+ import path from "node:path";
10
+ import { readdirSync } from "node:fs";
11
+ import { read, relPath } from "./fs-ops.js";
12
+
13
+ /**
14
+ * Static reproducibility lint: walk the project's package.json files and
15
+ * report the two "works on my machine" footguns the review hit — a package
16
+ * whose entry points live in a build-output dir with nothing that builds it
17
+ * on install, and a codegen dependency (Prisma) with no install hook that
18
+ * generates. A pure read: it returns what it found and prints nothing, so
19
+ * `verify --clean` renders it and `doctor` reports it as one row without
20
+ * spawning the CLI again to read its exit code (audit finding F4).
21
+ *
22
+ * @param {string} projectRoot
23
+ * @returns {{packages: number, findings: string[]}}
24
+ */
25
+ export function collectReproducibility(projectRoot) {
26
+ const pkgPaths = findPackageJsons(projectRoot);
27
+ const findings = [];
28
+ for (const pkgPath of pkgPaths) {
29
+ const rel = relPath(projectRoot, pkgPath);
30
+ let pkg;
31
+ try {
32
+ pkg = JSON.parse(read(pkgPath));
33
+ } catch (err) {
34
+ findings.push(`${rel}: not valid JSON (${err.message})`);
35
+ continue;
36
+ }
37
+ const scripts = pkg.scripts ?? {};
38
+ const hasInstallBuild = typeof scripts.prepare === "string" || typeof scripts.prepack === "string";
39
+
40
+ // 1. Entry point into a build output with nothing to build it on install.
41
+ const built = entryIntoBuildDir(pkg);
42
+ if (built && !hasInstallBuild) {
43
+ findings.push(
44
+ `${rel}: ${built.field} → \`${built.value}\` is a build output, but no "prepare"/"prepack" ` +
45
+ `script builds it on install — a fresh install/clone won't have it (add a prepare script, or commit the output)`,
46
+ );
47
+ }
48
+
49
+ // 2. Codegen dependency with no install hook that generates.
50
+ const deps = {
51
+ ...pkg.dependencies, ...pkg.devDependencies,
52
+ ...pkg.peerDependencies, ...pkg.optionalDependencies,
53
+ };
54
+ for (const [dep, gen] of Object.entries(CODEGEN_DEPS)) {
55
+ if (!(dep in deps)) continue;
56
+ const installHook = `${scripts.postinstall ?? ""} ${scripts.prepare ?? ""}`;
57
+ if (!gen.test(installHook)) {
58
+ findings.push(
59
+ `${rel}: depends on "${dep}" but no "postinstall"/"prepare" runs its codegen ` +
60
+ `(\`${gen.source.replace(/\\s\+/g, " ")}\`) — a fresh install has no generated output`,
61
+ );
62
+ }
63
+ }
64
+ }
65
+
66
+ return { packages: pkgPaths.length, findings };
67
+ }
68
+
69
+ // Known codegen dependencies → the command an install hook must run so a
70
+ // fresh install produces their generated output.
71
+ const CODEGEN_DEPS = {
72
+ "@prisma/client": /prisma\s+generate/,
73
+ "prisma": /prisma\s+generate/,
74
+ };
75
+
76
+ const BUILD_DIR_RE = /(?:^|\/)(?:dist|build|out)\//;
77
+
78
+ // The first entry-point field that points into a build-output directory, or
79
+ // null. Scans main/module/types/typings, bin (string or map), and exports
80
+ // (recursively, string leaves only).
81
+ function entryIntoBuildDir(pkg) {
82
+ for (const field of ["main", "module", "types", "typings"]) {
83
+ if (typeof pkg[field] === "string" && BUILD_DIR_RE.test(pkg[field])) {
84
+ return { field, value: pkg[field] };
85
+ }
86
+ }
87
+ if (typeof pkg.bin === "string" && BUILD_DIR_RE.test(pkg.bin)) return { field: "bin", value: pkg.bin };
88
+ if (pkg.bin && typeof pkg.bin === "object") {
89
+ for (const v of Object.values(pkg.bin)) {
90
+ if (typeof v === "string" && BUILD_DIR_RE.test(v)) return { field: "bin", value: v };
91
+ }
92
+ }
93
+ const fromExports = scanExports(pkg.exports);
94
+ if (fromExports) return { field: "exports", value: fromExports };
95
+ return null;
96
+ }
97
+
98
+ function scanExports(node) {
99
+ if (typeof node === "string") return BUILD_DIR_RE.test(node) ? node : null;
100
+ if (node && typeof node === "object") {
101
+ for (const v of Object.values(node)) {
102
+ const hit = scanExports(v);
103
+ if (hit) return hit;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ // Bounded walk for package.json files: skip dependency, build, and VCS
110
+ // directories so the lint stays fast and ignores vendored manifests.
111
+ const LINT_SKIP_DIRS = new Set([
112
+ ".git", "node_modules", "dist", "build", "out", "vendor", ".next",
113
+ "coverage", ".venv", "venv", "__pycache__", "target", ".doctrina",
114
+ ]);
115
+
116
+ function findPackageJsons(projectRoot) {
117
+ const found = [];
118
+ const stack = [projectRoot];
119
+ while (stack.length) {
120
+ const dir = stack.pop();
121
+ let entries;
122
+ try {
123
+ entries = readdirSync(dir, { withFileTypes: true });
124
+ } catch {
125
+ continue;
126
+ }
127
+ for (const entry of entries) {
128
+ const full = path.join(dir, entry.name);
129
+ if (entry.isDirectory()) {
130
+ if (!LINT_SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) stack.push(full);
131
+ } else if (entry.name === "package.json") {
132
+ found.push(full);
133
+ }
134
+ }
135
+ }
136
+ return found.sort();
137
+ }
@@ -573,10 +573,77 @@ export function checkSelectors(projectRoot, decl) {
573
573
  return findings;
574
574
  }
575
575
 
576
- // A minimal glob: `**` spans directories, `*` stays inside one segment.
577
- // Enough for the "where do my selectors live" declarations, with no
578
- // dependency and no surprises.
576
+ /**
577
+ * Expand one level of brace alternation: `a/{x,y}.js` -> [`a/x.js`, `a/y.js`],
578
+ * recursively, so several groups in one pattern all expand.
579
+ *
580
+ * Added for the `**Source:**` declarations (change 0077), where a capability
581
+ * that owns eleven sibling modules would otherwise need eleven globs on one
582
+ * header line. Braces have no meaning in a path, so this takes nothing away
583
+ * from the patterns that were already valid.
584
+ */
585
+ export function expandBraces(glob) {
586
+ const text = String(glob);
587
+ const open = text.indexOf("{");
588
+ if (open < 0) return [text];
589
+
590
+ // The MATCHING close, by depth — `indexOf` finds the first one, which for
591
+ // `{a,{b,c}}` is the inner brace: the group was split at the wrong place
592
+ // and expanded to ["a}", "b", "c}"], matching `b`, missing `a` and `c`,
593
+ // and inventing `a}`. Silent, too, because SOMETHING matched, so
594
+ // `validate`'s dead-pattern check stayed quiet over a declaration that
595
+ // covered a third of what it claimed (change 0084).
596
+ let depth = 0;
597
+ let close = -1;
598
+ for (let i = open; i < text.length; i++) {
599
+ if (text[i] === "{") depth += 1;
600
+ else if (text[i] === "}") {
601
+ depth -= 1;
602
+ if (depth === 0) { close = i; break; }
603
+ }
604
+ }
605
+ // An unmatched brace expands to itself: the braces then compile as literal
606
+ // characters, so the pattern matches nothing and `validate` reports it
607
+ // rather than the caller silently covering less than it declared.
608
+ if (close < 0) return [text];
609
+
610
+ const head = text.slice(0, open);
611
+ const tail = text.slice(close + 1);
612
+ const out = [];
613
+ // Split on commas at depth zero, for the same reason: a comma inside a
614
+ // nested group separates that group's alternatives, not this one's.
615
+ const body = text.slice(open + 1, close);
616
+ let cur = "";
617
+ depth = 0;
618
+ const alts = [];
619
+ for (const ch of body) {
620
+ if (ch === "{") depth += 1;
621
+ else if (ch === "}") depth -= 1;
622
+ if (ch === "," && depth === 0) { alts.push(cur); cur = ""; continue; }
623
+ cur += ch;
624
+ }
625
+ alts.push(cur);
626
+ for (const alt of alts) out.push(...expandBraces(head + alt.trim() + tail));
627
+ return out;
628
+ }
629
+
630
+ // A minimal glob: `**` spans directories, `*` stays inside one segment, and
631
+ // `{a,b}` alternates. Enough for the "where do my selectors live" and "which
632
+ // code is mine" declarations, with no dependency and no surprises.
579
633
  export function globToRegExp(glob) {
634
+ // Always compile the EXPANSION, never the original. Using it only when it
635
+ // produced more than one alternative left `src/{a}.js` compiling with its
636
+ // braces as literal characters, so a one-element group matched nothing
637
+ // (change 0084).
638
+ const alternatives = expandBraces(glob);
639
+ if (alternatives.length > 1) {
640
+ return new RegExp(alternatives.map((g) => compileGlob(g).source).join("|"));
641
+ }
642
+ return compileGlob(alternatives[0]);
643
+ }
644
+
645
+ // One expanded, brace-free glob to a RegExp.
646
+ function compileGlob(glob) {
580
647
  const segments = String(glob).split("/");
581
648
  const parts = [];
582
649
  for (let i = 0; i < segments.length; i++) {
@@ -599,7 +666,14 @@ export function globToRegExp(glob) {
599
666
  return new RegExp(`^${parts.join("")}$`);
600
667
  }
601
668
 
602
- function filesMatching(projectRoot, glob) {
669
+ /**
670
+ * Every file under `projectRoot` matching one glob, project-relative.
671
+ *
672
+ * Exported since change 0077: `validate` asks the same question of a spec's
673
+ * `**Source:**` patterns that the selector check asks of a contract's — does
674
+ * this declaration point at anything that exists?
675
+ */
676
+ export function filesMatching(projectRoot, glob) {
603
677
  const re = globToRegExp(glob);
604
678
  // Walk from the deepest literal directory prefix so a narrow glob never
605
679
  // costs a full-tree walk.
@@ -629,6 +703,34 @@ export function parseBudgets(decl) {
629
703
  return out;
630
704
  }
631
705
 
706
+ /**
707
+ * The value this project declares for ONE budget, or `fallback` when it
708
+ * declares none.
709
+ *
710
+ * Every budget in this codebase has had two homes — a literal beside the
711
+ * check and a row in the contract's Budgets table — and every one of them
712
+ * drifted (change 0059, then 0072). The contract is the declaration, so it
713
+ * wins; the literal a caller passes is the shipped default for a project
714
+ * that declares nothing. A malformed contract is its own finding, never a
715
+ * reason for the caller to lose its check.
716
+ *
717
+ * @param {string} projectRoot
718
+ * @param {string} name
719
+ * @param {number} fallback
720
+ * @returns {{ value: number, declared: boolean }}
721
+ */
722
+ export function declaredBudget(projectRoot, name, fallback) {
723
+ try {
724
+ const row = collectBudgets(projectRoot).get(name);
725
+ if (row && Number.isFinite(row.value) && row.value > 0) {
726
+ return { value: row.value, declared: true };
727
+ }
728
+ } catch {
729
+ // fall through to the shipped default
730
+ }
731
+ return { value: fallback, declared: false };
732
+ }
733
+
632
734
  /** Every declared budget across every contract, keyed by limit name. */
633
735
  export function collectBudgets(projectRoot) {
634
736
  const out = new Map();
@@ -644,6 +746,88 @@ export function collectBudgets(projectRoot) {
644
746
  // The whole runtime verdict, for every contract in the project
645
747
  // ---------------------------------------------------------------------------
646
748
 
749
+ /**
750
+ * The STRUCTURAL half of a contract, as findings (change 0103): port
751
+ * collisions, environment drift against `.env.example`, and references to
752
+ * capability specs that do not exist. These three lived inline in
753
+ * `contract check` while RT01-RT05 lived here — so `close`, which drives
754
+ * this collection, ran the runtime half and skipped the structural one: a
755
+ * contract with port 8080 claimed twice and a reference to a spec that did
756
+ * not exist failed `contract check` with 2 errors and passed the close's
757
+ * runtime step as "2 declared rows hold". One collection, every driver.
758
+ *
759
+ * @param {string} projectRoot
760
+ * @param {string} text the contract's Markdown
761
+ * @returns {Finding[]}
762
+ */
763
+ export function checkStructure(projectRoot, text) {
764
+ /** @type {Finding[]} */
765
+ const out = [];
766
+ const col = (headers, name) => {
767
+ const i = headers.findIndex((h) => h.toLowerCase() === name.toLowerCase());
768
+ return i < 0 ? 0 : i;
769
+ };
770
+
771
+ // CT01. Port collisions — two services must not claim the same port.
772
+ const ports = parseTable(getSection(text, "Ports"));
773
+ if (ports) {
774
+ const portCol = col(ports.headers, "port");
775
+ const svcCol = col(ports.headers, "service");
776
+ const seen = new Map();
777
+ for (const row of ports.rows) {
778
+ const port = (row[portCol] ?? "").trim();
779
+ if (!/^\d+$/.test(port)) continue;
780
+ const svc = (row[svcCol] ?? "?").trim();
781
+ if (seen.has(port)) {
782
+ out.push({
783
+ code: "CT01", level: "error",
784
+ message: `port ${port} is claimed by both "${seen.get(port)}" and "${svc}"`,
785
+ remedy: `give "${svc}" a port of its own in the Ports table, or drop the duplicate row`,
786
+ });
787
+ } else {
788
+ seen.set(port, svc);
789
+ }
790
+ }
791
+ }
792
+
793
+ // CT02. Environment drift — every declared variable must exist in
794
+ // .env.example (when the project keeps one).
795
+ const envExamplePath = path.join(projectRoot, ".env.example");
796
+ const env = parseTable(getSection(text, "Environment"));
797
+ if (env && isFile(envExamplePath)) {
798
+ const envExample = read(envExamplePath);
799
+ const varCol = col(env.headers, "variable");
800
+ for (const row of env.rows) {
801
+ const name = (row[varCol] ?? "").trim();
802
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name)) continue;
803
+ const declared = new RegExp(`^\\s*(export\\s+)?${name}\\s*=`, "m").test(envExample);
804
+ if (!declared) {
805
+ out.push({
806
+ code: "CT02", level: "warn",
807
+ message: `env var ${name} is in the contract but absent from .env.example`,
808
+ remedy: `add "${name}=" to .env.example, or drop the row from the Environment table`,
809
+ file: ".env.example",
810
+ });
811
+ }
812
+ }
813
+ }
814
+
815
+ // CT03. Referenced capability specs must exist.
816
+ for (const m of getSection(text, "References").matchAll(/specs\/([a-z][a-z0-9-]*)/g)) {
817
+ const refCap = m[1];
818
+ const specRel = `.doctrina/specs/${refCap}/spec.md`;
819
+ if (isFile(path.join(projectRoot, specRel))) continue;
820
+ if (out.some((f) => f.code === "CT03" && f.file === specRel)) continue;
821
+ out.push({
822
+ code: "CT03", level: "error",
823
+ message: `references spec "${refCap}" but ${specRel} does not exist`,
824
+ remedy: `create it with \`doctrina spec new ${refCap}\`, or correct the reference`,
825
+ file: specRel,
826
+ });
827
+ }
828
+ return out;
829
+ }
830
+
647
831
  export function contractPaths(projectRoot) {
648
832
  const dir = path.join(projectRoot, ".doctrina", "contracts");
649
833
  if (!isDir(dir)) return [];
@@ -660,10 +844,12 @@ export function collectRuntimeFindings(projectRoot) {
660
844
  let declared = 0;
661
845
  const files = contractPaths(projectRoot);
662
846
  for (const file of files) {
663
- const decl = parseRuntimeDeclaration(read(file));
847
+ const text = read(file);
848
+ const decl = parseRuntimeDeclaration(text);
664
849
  declared += decl.wiring.length + decl.selectors.length;
665
850
  const where = relPath(projectRoot, file).replaceAll("\\", "/");
666
851
  for (const f of [
852
+ ...checkStructure(projectRoot, text),
667
853
  ...checkWiring(projectRoot, decl),
668
854
  ...checkEmptySemantics(projectRoot, decl),
669
855
  ...checkEnums(projectRoot, decl),