doctrina-cli 0.15.0 → 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 (85) 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 -199
  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 +7 -75
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +121 -160
  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 +109 -6
  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 +210 -24
  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 +238 -11
  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/contract.md.template +7 -0
  82. package/templates/hooks/pre-commit.sample +6 -2
  83. package/templates/playbooks/bootstrap.md.template +43 -0
  84. package/templates/playbooks/chore.md.template +19 -0
  85. 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
+ }
@@ -38,6 +38,31 @@ import { isDir, isFile, read, relPath, walk } from "./fs-ops.js";
38
38
  /** Origins whose value CI can inject as an EMPTY STRING rather than unset. */
39
39
  const INJECTABLE_ORIGINS = new Set(["vars", "secrets"]);
40
40
 
41
+ /**
42
+ * Split an Origin cell into its origin and the OPTIONAL source name it
43
+ * declares: `secrets` -> {origin: "secrets", source: null}, and
44
+ * `secrets:NPM_TOKEN` -> {origin: "secrets", source: "NPM_TOKEN"}.
45
+ *
46
+ * Exporting a variable under a name that is not the source's is routine and
47
+ * often unavoidable — npm reads its credential from `NODE_AUTH_TOKEN`, so a
48
+ * repository whose secret is `NPM_TOKEN` has no choice but to rename in the
49
+ * `env:` line. RT02 is right to flag that (rename the secret and the
50
+ * expression resolves to the empty string, so the publish fails on a blank
51
+ * credential with nothing pointing at the cause) and was wrong to make it
52
+ * unanswerable: its remedy said "record the intentional rename in the Wiring
53
+ * row" while the row had nowhere to put it. A permanent warning nobody can
54
+ * clear teaches people to ignore warnings.
55
+ */
56
+ export function parseOrigin(cell) {
57
+ const raw = String(cell ?? "").trim();
58
+ const colon = raw.indexOf(":");
59
+ if (colon < 0) return { origin: raw.toLowerCase(), source: null };
60
+ return {
61
+ origin: raw.slice(0, colon).trim().toLowerCase(),
62
+ source: raw.slice(colon + 1).trim() || null,
63
+ };
64
+ }
65
+
41
66
  // ---------------------------------------------------------------------------
42
67
  // Declaration parsing
43
68
  // ---------------------------------------------------------------------------
@@ -255,7 +280,10 @@ export function checkWiring(projectRoot, decl) {
255
280
  const cache = new Map();
256
281
 
257
282
  for (const row of decl.wiring) {
258
- const origin = row.origin.toLowerCase();
283
+ const { origin, source } = parseOrigin(row.origin);
284
+ // The name the workflow is expected to read FROM: the declared source
285
+ // when the row gives one, otherwise the variable's own name.
286
+ const expectedSource = source ?? row.name;
259
287
  if (!INJECTABLE_ORIGINS.has(origin)) continue;
260
288
  if (!row.workflow) {
261
289
  findings.push({
@@ -303,7 +331,9 @@ export function checkWiring(projectRoot, decl) {
303
331
  code: "RT01",
304
332
  level: "error",
305
333
  message: `${row.name} is declared with origin "${origin}" but no env: block in ${row.workflow} exports it${where} — the value exists in CI and never reaches the process`,
306
- remedy: `add "${row.name}: \${{ ${origin}.${row.name} }}" to the env: block of the ${row.job || "relevant"} job in ${row.workflow}`,
334
+ // Quote the DECLARED source, so the line this tells you to paste is
335
+ // the one that actually works when the names differ.
336
+ remedy: `add "${row.name}: \${{ ${origin}.${expectedSource} }}" to the env: block of the ${row.job || "relevant"} job in ${row.workflow}`,
307
337
  file: row.workflow,
308
338
  });
309
339
  continue;
@@ -320,12 +350,19 @@ export function checkWiring(projectRoot, decl) {
320
350
  remedy: `align the Origin column with ${row.workflow}:${e.line} (a value set as ${origin} is not visible as ${ref.origin})`,
321
351
  file: row.workflow,
322
352
  });
323
- } else if (ref.name !== row.name && ref.origin === origin) {
353
+ } else if (ref.name !== expectedSource && ref.origin === origin) {
354
+ // Silent when the workflow reads exactly the source the row
355
+ // declares: the rename is documented, and documentation is the
356
+ // answer this warning was asking for.
324
357
  findings.push({
325
358
  code: "RT02",
326
359
  level: "warn",
327
- message: `${row.workflow}:${e.line} exports ${row.name} from ${ref.origin}.${ref.name} — the names differ, so renaming one silently empties the other`,
328
- remedy: `use \${{ ${origin}.${row.name} }}, or record the intentional rename in the Wiring row`,
360
+ message: source
361
+ ? `${row.workflow}:${e.line} exports ${row.name} from ${ref.origin}.${ref.name}, but the contract declares it comes from ${origin}.${source} — one of the two moved`
362
+ : `${row.workflow}:${e.line} exports ${row.name} from ${ref.origin}.${ref.name} — the names differ, so renaming one silently empties the other`,
363
+ remedy: source
364
+ ? `align them: either export \${{ ${origin}.${source} }}, or update the Origin cell to "${origin}:${ref.name}"`
365
+ : `use \${{ ${origin}.${row.name} }}, or declare the rename in the Origin cell as "${origin}:${ref.name}"`,
329
366
  file: row.workflow,
330
367
  });
331
368
  }
@@ -371,7 +408,11 @@ export function checkEmptySemantics(projectRoot, decl) {
371
408
  const cache = new Map();
372
409
 
373
410
  for (const row of decl.wiring) {
374
- const origin = row.origin.toLowerCase();
411
+ // Through parseOrigin, so a row declaring its source (`secrets:NPM_TOKEN`)
412
+ // is still recognised as CI-injectable. Reading the cell raw here would
413
+ // make declaring a source silently switch this check off — the same
414
+ // class of defect being fixed, wearing a different hat.
415
+ const { origin } = parseOrigin(row.origin);
375
416
  if (!INJECTABLE_ORIGINS.has(origin) || !row.consumer) continue;
376
417
  const full = path.join(projectRoot, row.consumer);
377
418
  if (!isFile(full)) {
@@ -532,10 +573,77 @@ export function checkSelectors(projectRoot, decl) {
532
573
  return findings;
533
574
  }
534
575
 
535
- // A minimal glob: `**` spans directories, `*` stays inside one segment.
536
- // Enough for the "where do my selectors live" declarations, with no
537
- // 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.
538
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) {
539
647
  const segments = String(glob).split("/");
540
648
  const parts = [];
541
649
  for (let i = 0; i < segments.length; i++) {
@@ -558,7 +666,14 @@ export function globToRegExp(glob) {
558
666
  return new RegExp(`^${parts.join("")}$`);
559
667
  }
560
668
 
561
- 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) {
562
677
  const re = globToRegExp(glob);
563
678
  // Walk from the deepest literal directory prefix so a narrow glob never
564
679
  // costs a full-tree walk.
@@ -588,6 +703,34 @@ export function parseBudgets(decl) {
588
703
  return out;
589
704
  }
590
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
+
591
734
  /** Every declared budget across every contract, keyed by limit name. */
592
735
  export function collectBudgets(projectRoot) {
593
736
  const out = new Map();
@@ -603,6 +746,88 @@ export function collectBudgets(projectRoot) {
603
746
  // The whole runtime verdict, for every contract in the project
604
747
  // ---------------------------------------------------------------------------
605
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
+
606
831
  export function contractPaths(projectRoot) {
607
832
  const dir = path.join(projectRoot, ".doctrina", "contracts");
608
833
  if (!isDir(dir)) return [];
@@ -619,10 +844,12 @@ export function collectRuntimeFindings(projectRoot) {
619
844
  let declared = 0;
620
845
  const files = contractPaths(projectRoot);
621
846
  for (const file of files) {
622
- const decl = parseRuntimeDeclaration(read(file));
847
+ const text = read(file);
848
+ const decl = parseRuntimeDeclaration(text);
623
849
  declared += decl.wiring.length + decl.selectors.length;
624
850
  const where = relPath(projectRoot, file).replaceAll("\\", "/");
625
851
  for (const f of [
852
+ ...checkStructure(projectRoot, text),
626
853
  ...checkWiring(projectRoot, decl),
627
854
  ...checkEmptySemantics(projectRoot, decl),
628
855
  ...checkEnums(projectRoot, decl),