doctrina-cli 0.15.1 → 0.17.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 (93) hide show
  1. package/README.md +27 -72
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +27 -3
  4. package/src/commands/analyze.js +16 -212
  5. package/src/commands/change.js +144 -267
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +65 -21
  8. package/src/commands/close.js +349 -45
  9. package/src/commands/completion.js +8 -2
  10. package/src/commands/context.js +284 -111
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +66 -7
  14. package/src/commands/doctor.js +293 -130
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +17 -3
  17. package/src/commands/index-rebuild.js +55 -44
  18. package/src/commands/init.js +88 -16
  19. package/src/commands/intake.js +85 -99
  20. package/src/commands/intent.js +14 -1
  21. package/src/commands/metrics.js +65 -88
  22. package/src/commands/next.js +105 -147
  23. package/src/commands/prime.js +23 -91
  24. package/src/commands/report.js +40 -109
  25. package/src/commands/review.js +68 -36
  26. package/src/commands/search.js +18 -4
  27. package/src/commands/show.js +24 -9
  28. package/src/commands/skill.js +23 -39
  29. package/src/commands/spec.js +38 -11
  30. package/src/commands/status.js +69 -171
  31. package/src/commands/templates.js +35 -148
  32. package/src/commands/trace.js +36 -71
  33. package/src/commands/triage.js +4 -82
  34. package/src/commands/upgrade.js +31 -6
  35. package/src/commands/validate.js +44 -877
  36. package/src/commands/verify.js +60 -138
  37. package/src/commands/why.js +2 -2
  38. package/src/commands/work.js +243 -267
  39. package/src/index.js +139 -12
  40. package/src/lib/actions.js +490 -0
  41. package/src/lib/adapters.js +0 -11
  42. package/src/lib/adr-guard.js +19 -0
  43. package/src/lib/agent-changelog.js +133 -0
  44. package/src/lib/analysis.js +264 -0
  45. package/src/lib/args.js +14 -0
  46. package/src/lib/change-ops.js +133 -0
  47. package/src/lib/commands.js +135 -10
  48. package/src/lib/config.js +206 -0
  49. package/src/lib/constitution-model.js +102 -0
  50. package/src/lib/coverage-model.js +431 -0
  51. package/src/lib/criteria.js +23 -0
  52. package/src/lib/doc-model.js +370 -3
  53. package/src/lib/docs-impact.js +270 -27
  54. package/src/lib/ears.js +24 -1
  55. package/src/lib/flag-catalog.js +0 -16
  56. package/src/lib/fs-ops.js +58 -1
  57. package/src/lib/gates.js +231 -29
  58. package/src/lib/git.js +132 -0
  59. package/src/lib/index-json.js +42 -24
  60. package/src/lib/intake-model.js +155 -0
  61. package/src/lib/json-out.js +100 -2
  62. package/src/lib/ledger.js +192 -0
  63. package/src/lib/lexicon.js +228 -0
  64. package/src/lib/metrics-model.js +189 -0
  65. package/src/lib/names.js +42 -0
  66. package/src/lib/playbook.js +97 -0
  67. package/src/lib/project.js +105 -0
  68. package/src/lib/reproducibility.js +137 -0
  69. package/src/lib/runtime.js +191 -5
  70. package/src/lib/scan.js +328 -25
  71. package/src/lib/signoff.js +147 -0
  72. package/src/lib/snapshot.js +231 -0
  73. package/src/lib/spec-ops.js +72 -8
  74. package/src/lib/templates-model.js +289 -0
  75. package/src/lib/trace-model.js +95 -0
  76. package/src/lib/triage-model.js +121 -0
  77. package/src/lib/usage.js +6 -4
  78. package/src/lib/validation-model.js +1381 -0
  79. package/src/lib/version.js +22 -0
  80. package/src/lib/views.js +413 -0
  81. package/src/lib/work-model.js +102 -0
  82. package/templates/AGENTS.md.template +21 -28
  83. package/templates/README.md +35 -0
  84. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  85. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  86. package/templates/change/proposal.md.template +16 -0
  87. package/templates/change/tasks.md.template +3 -8
  88. package/templates/contract.md.template +2 -2
  89. package/templates/hooks/pre-commit.sample +16 -2
  90. package/templates/playbooks/bootstrap.md.template +47 -0
  91. package/templates/playbooks/chore.md.template +24 -0
  92. package/templates/playbooks/work.md.template +83 -0
  93. package/src/commands/constitution.js +0 -145
@@ -1,9 +1,12 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
- import { spawnSync } from "node:child_process";
4
- import { isFile, read, walk } from "./fs-ops.js";
3
+ import { readdirSync } from "node:fs";
4
+ import { isDir, isFile, read, walk } from "./fs-ops.js";
5
5
  import { COMMAND_NAMES } from "./commands.js";
6
6
  import { locateTemplatesDir } from "./templates.js";
7
+ import { changedFiles, isRepo } from "./git.js";
8
+ import { maskComments, getSection } from "./doc-model.js";
9
+ import { listHeader } from "./scan.js";
7
10
 
8
11
  // "Docs ship inside the change, never after it" (audit item D2).
9
12
  //
@@ -46,11 +49,81 @@ function scaffoldLines() {
46
49
  return lines;
47
50
  }
48
51
 
52
+ function escapeRe(x) {
53
+ return x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54
+ }
55
+
56
+ // The names THIS project declares as its integration surface, read from its
57
+ // own contracts: the variables of the Environment and Wiring tables, the
58
+ // services of the Ports table, and the backticked tokens of Interfaces.
59
+ //
60
+ // A contract is the right home for this. It is where a project already states
61
+ // what an external consumer integrates against, it is versioned beside the
62
+ // code, and `contract check` already holds parts of it to the implementation
63
+ // (ADR 0023 — the runtime surface is declared, never inferred). The same
64
+ // principle, applied to documentation: declared, never guessed.
65
+ //
66
+ // Returns [] for a project with no contracts, which is what keeps the change
67
+ // backward compatible: that project's gate behaves exactly as it did.
68
+ export function declaredSurfaceNames(projectRoot) {
69
+ const dir = path.join(projectRoot, ".doctrina", "contracts");
70
+ if (!isDir(dir)) return [];
71
+ const names = new Set();
72
+ for (const file of walk(dir)) {
73
+ if (!file.endsWith(".md")) continue;
74
+ const text = maskComments(read(file));
75
+
76
+ // First column of a table, for the sections whose first column IS the name.
77
+ for (const section of ["Ports", "Environment", "Wiring", "Selectors", "Budgets"]) {
78
+ const body = getSection(text, section);
79
+ if (!body) continue;
80
+ for (const line of body.split(/\r?\n/)) {
81
+ const m = /^\s*\|\s*([A-Za-z][\w.-]*)\s*\|/.exec(line);
82
+ if (!m) continue;
83
+ const cell = m[1];
84
+ // Skip the header row and the template's own placeholder rows.
85
+ if (/^(service|variable|selector|limit|name)$/i.test(cell)) continue;
86
+ if (/^-+$/.test(cell)) continue;
87
+ names.add(cell);
88
+ }
89
+ }
90
+
91
+ // Interfaces is prose with backticked tokens: endpoints, flags, shapes.
92
+ const interfaces = getSection(text, "Interfaces");
93
+ if (interfaces) {
94
+ for (const m of interfaces.matchAll(/`([^`\n]{2,60})`/g)) {
95
+ const token = m[1].trim();
96
+ // A command with a subcommand ("ledgerly reconcile") is exactly the
97
+ // shape worth declaring, so single internal spaces are allowed —
98
+ // anything with punctuation or prose in it is not a name.
99
+ if (/^[\w./:@+-]+(?: [\w./:@+-]+)*$/.test(token)) names.add(token);
100
+ }
101
+ }
102
+ }
103
+ return [...names];
104
+ }
105
+
49
106
  // Does this change alter something the documentation makes promises about?
50
107
  // Read from the change's own artifacts — the deltas it will merge into
51
108
  // specs, plus the proposal that states its shape. Returns the list of
52
109
  // signals found (empty when the change touches no documented surface).
53
- export function documentedSurfaceSignals(changeDir) {
110
+ // Drop the proposal's `## Verification` section before reading it for
111
+ // surface signals. That section answers "how will you know this landed", so
112
+ // the commands it names are the ones the author will RUN — the same reason
113
+ // the template's own checklist lines are subtracted below. Running a gate is
114
+ // not changing it (change 0088).
115
+ function withoutVerification(text) {
116
+ const lines = text.split(/\r?\n/);
117
+ const out = [];
118
+ let skipping = false;
119
+ for (const line of lines) {
120
+ if (/^##\s+/.test(line)) skipping = /^##\s+Verification\b/i.test(line);
121
+ if (!skipping) out.push(line);
122
+ }
123
+ return out.join("\n");
124
+ }
125
+
126
+ export function documentedSurfaceSignals(changeDir, projectRoot = null) {
54
127
  const signals = [];
55
128
  const known = new Set(COMMAND_NAMES);
56
129
 
@@ -59,15 +132,73 @@ export function documentedSurfaceSignals(changeDir) {
59
132
  // and `doctrina coverage` — so scanning the raw file made every change look
60
133
  // like it touched a documented surface. A gate that fires on everything is
61
134
  // a gate that gets ignored, so the template's lines are subtracted first.
135
+ // An HTML comment is annotation, not authored surface. The guessed delta
136
+ // change 0044 scaffolds carries a `RANKED GUESS` note that names
137
+ // `doctrina work` — not template text, so the subtraction below never
138
+ // reaches it, and every change on the default path arrived at the docs
139
+ // gate carrying a phantom `commands: work`. Comments are blanked FIRST,
140
+ // by the document model, which is the one owner of that rule.
62
141
  const boilerplate = scaffoldLines();
63
- const authored = (text) => text
142
+ const authored = (text) => maskComments(text)
64
143
  .split(/\r?\n/)
65
144
  .filter((line) => !boilerplate.has(normaliseScaffoldLine(line)))
66
145
  .join("\n");
67
146
 
68
147
  const sources = [];
69
148
  const proposal = path.join(changeDir, "proposal.md");
70
- if (isFile(proposal)) sources.push(authored(read(proposal)));
149
+ if (isFile(proposal)) {
150
+ const text = read(proposal);
151
+
152
+ // A CHORE declares that no behaviour changes and no spec moves — that is
153
+ // what the lane means, and `analyze` already reads it that way ("0 spec
154
+ // deltas, metadata-only change"). Asking such a change to document a
155
+ // surface it declared it does not touch contradicts its own lane. The
156
+ // declaration is the author's, recorded in the proposal header and the
157
+ // index (change 0042), so it is auditable rather than invisible.
158
+ //
159
+ // This is the case that mattered: of the archived changes, every chore
160
+ // names a command in code context and none of them changes one. Change
161
+ // 0085 reorganised headings in AGENTS.md and was refused for "commands:
162
+ // close, templates" — the two commands its own proposal cited to describe
163
+ // the finding — and had to close with --force (change 0088).
164
+ if (/chore/i.test(listHeader(text, "Lane") ?? "")) return signals;
165
+
166
+ // THE AUTHOR MAY DECLARE THAT A NAME IS A MENTION, NOT A CHANGE.
167
+ //
168
+ // Everything below reads NAMES out of the authored text, and a name is
169
+ // all it can see: a change that says "coverage no longer knows this
170
+ // test exists" is describing an effect, and one whose Scope boundaries
171
+ // say "does not touch `verify`" is describing an absence, but both read
172
+ // to this gate exactly like a change that alters the command. Three
173
+ // closes in one session were forced for that reason, and a gate that is
174
+ // routinely forced stops being a gate — the ledger fills with gaps that
175
+ // were never gaps, and a real one stops standing out.
176
+ //
177
+ // No smarter extraction settles it, because the difference is semantic
178
+ // and ADR 0005 puts semantics outside a deterministic gate. So the
179
+ // author declares it, in the grammar the tree already uses for exactly
180
+ // this — `Realizes: n/a — <why>`: a bare `n/a` is an assertion nobody
181
+ // wrote, and only `n/a` WITH a reason is a decision.
182
+ // Read it from AUTHORED text only. An HTML comment is annotation — the
183
+ // rule this module already applies everywhere else, and the one this
184
+ // header shipped without: a `Documented surface:` line inside a comment
185
+ // counted, so the example in the proposal template silenced the gate for
186
+ // every change scaffolded from it. A declaration nobody wrote is not a
187
+ // declaration, exactly as a bare `n/a` is not one.
188
+ const declaredNone = listHeader(maskComments(text), "Documented surface");
189
+ if (declaredNone !== null) {
190
+ // The separator is punctuation, not a reason: "n/a —" says no more than
191
+ // "n/a". Strip the word, then the dash, and require words to remain.
192
+ const rest = declaredNone.trim()
193
+ .replace(/^(n\/a|none)\b/i, (m) => (m ? "" : m))
194
+ .replace(/^\s*[—–-]+\s*/, "")
195
+ .trim();
196
+ const word = declaredNone.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
197
+ if ((word === "n/a" || word === "none") && rest.length > 0) return signals;
198
+ }
199
+
200
+ sources.push(authored(withoutVerification(text)));
201
+ }
71
202
  for (const p of walk(path.join(changeDir, "specs"))) {
72
203
  if (p.endsWith("delta.md")) sources.push(authored(read(p)));
73
204
  }
@@ -89,12 +220,52 @@ export function documentedSurfaceSignals(changeDir) {
89
220
  signals.push(`commands: ${[...commands].sort().join(", ")}`);
90
221
  }
91
222
 
223
+ // The surface the PROJECT declares, matched by name (change 0075's sibling
224
+ // problem, found in the second audit). Matching commands against Doctrina's
225
+ // own catalog made the gate maximally sensitive inside this repository and
226
+ // inert everywhere else: in an adopting project a new command, a public HTTP
227
+ // endpoint, a renamed environment variable and a changed config key all
228
+ // produced ZERO signals, and the change closed with no documentation and no
229
+ // complaint. The vocabulary was already in the right place — the contract
230
+ // declares Ports, Environment, Wiring, Selectors and Interfaces — so the gate
231
+ // reads it instead of carrying a catalog that only fits its author.
232
+ const declared = projectRoot === null ? [] : declaredSurfaceNames(projectRoot);
233
+ const hit = declared.filter((name) => new RegExp(`\\b${escapeRe(name)}\\b`).test(code));
234
+ // Report the most specific declaration only: "ledgerly reconcile" says more
235
+ // than the bare service name it starts with.
236
+ const touchedDeclared = hit.filter((a) => !hit.some((b) => b !== a && b.includes(a)));
237
+ if (touchedDeclared.length > 0) {
238
+ signals.push(`declared surface: ${touchedDeclared.sort().slice(0, 8).join(", ")}`);
239
+ }
240
+
92
241
  const flags = new Set();
93
242
  for (const m of code.matchAll(/--([a-z][a-z0-9-]{2,})/g)) flags.add(`--${m[1]}`);
94
243
  if (flags.size > 0) {
95
244
  signals.push(`flags: ${[...flags].sort().slice(0, 8).join(", ")}`);
96
245
  }
97
246
 
247
+ // Surface SHAPES, for the surface a change is ADDING — which by definition
248
+ // is not in the contract yet, and is the case the declared-name match cannot
249
+ // reach. Deterministic and language-agnostic (ADR 0005): a route path, an
250
+ // HTTP method in front of one, an environment-variable identifier. Read from
251
+ // code spans only, so prose that happens to contain a slash is not a signal.
252
+ const routes = new Set();
253
+ for (const m of code.matchAll(/(?:^|[\s`"'(])((?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+)?(\/[a-z][\w.-]*(?:\/[\w.:{}$<>-]+)+)/gi)) {
254
+ routes.add(((m[1] ?? "").toUpperCase().trim() + " " + m[2]).trim());
255
+ }
256
+ if (routes.size > 0) {
257
+ signals.push(`endpoints: ${[...routes].sort().slice(0, 6).join(", ")}`);
258
+ }
259
+
260
+ const envVars = new Set();
261
+ for (const m of code.matchAll(/\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b/g)) {
262
+ // Not twice: a declared variable is already reported by name above.
263
+ if (!touchedDeclared.includes(m[1])) envVars.add(m[1]);
264
+ }
265
+ if (envVars.size > 0) {
266
+ signals.push(`environment: ${[...envVars].sort().slice(0, 8).join(", ")}`);
267
+ }
268
+
98
269
  if (/\bexit\s+code|\bexits?\s+(?:with\s+)?[0-4]\b/i.test(text)) {
99
270
  signals.push("exit codes");
100
271
  }
@@ -116,38 +287,110 @@ export function documentedSurfaceSignals(changeDir) {
116
287
  // all, and attributing a hunk to a change would need guesswork the rest of
117
288
  // the framework refuses to do (ADR 0005).
118
289
  export function docsTouched(projectRoot) {
119
- const git = (args) => {
120
- const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
121
- if (r.error || r.status !== 0) return [];
122
- return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
123
- };
124
- const set = new Set();
125
- for (const f of git(["diff", "--name-only", "HEAD"])) set.add(f);
126
- for (const f of git(["ls-files", "--others", "--exclude-standard"])) set.add(f);
127
- for (const base of ["main", "master"]) {
128
- const mb = git(["merge-base", "HEAD", base]);
129
- if (mb.length > 0) {
130
- for (const f of git(["diff", "--name-only", `${mb[0]}..HEAD`])) set.add(f);
131
- break;
132
- }
133
- }
134
- return [...set]
135
- .map((f) => f.replace(/\\/g, "/"))
290
+ // mergeBase: a branch's EARLIER commits count as documentation that moved
291
+ // with the change — the gate asks "did docs ship with this work", not "did
292
+ // docs change since the last commit".
293
+ return changedFiles(projectRoot, { mergeBase: true })
294
+ .files
136
295
  .filter((f) => DOC_PATHS.some((re) => re.test(f)));
137
296
  }
138
297
 
139
298
  export function isGitRepo(projectRoot) {
140
- const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
141
- cwd: projectRoot, encoding: "utf8",
142
- });
143
- return !r.error && r.status === 0;
299
+ return isRepo(projectRoot);
300
+ }
301
+
302
+ // A CHANGELOG IS A DIFFERENT OBLIGATION FROM DOCUMENTATION.
303
+ //
304
+ // The docs gate asks "can a reader learn how this works". The changelog asks
305
+ // "can a reader learn that it changed" — and the second is not answered by
306
+ // the first, because prose describing the new behaviour reads exactly like
307
+ // prose that always described it.
308
+ //
309
+ // Nothing asked for it, so it drifted immediately: thirteen changes landed in
310
+ // one session, every one of them through `close`, and `## [Unreleased]` was
311
+ // still empty at the end of it. The file's own first line says every notable
312
+ // change is recorded there.
313
+ //
314
+ // Silent for a project that keeps no CHANGELOG.md — this gate reports a
315
+ // promise the project made, and never invents one it did not.
316
+ export function checkChangelogImpact(projectRoot, changeDir) {
317
+ const signals = documentedSurfaceSignals(changeDir, projectRoot);
318
+ if (signals.length === 0) {
319
+ return { ok: true, signals, touched: [], reason: "touches no documented surface" };
320
+ }
321
+ if (!isFile(path.join(projectRoot, CHANGELOG))) {
322
+ return { ok: true, signals, touched: [], reason: `no ${CHANGELOG} in this project` };
323
+ }
324
+ if (!isRepo(projectRoot)) {
325
+ return { ok: true, signals, touched: [], reason: "not a git repository — cannot tell what moved" };
326
+ }
327
+ const touched = changedFiles(projectRoot, { mergeBase: true })
328
+ .files.filter((f) => f === CHANGELOG);
329
+ if (touched.length > 0) {
330
+ return { ok: true, signals, touched, reason: `recorded in ${CHANGELOG}` };
331
+ }
332
+ return {
333
+ ok: false,
334
+ signals,
335
+ touched,
336
+ reason: `alters a documented surface but ${CHANGELOG} does not say so`,
337
+ };
338
+ }
339
+
340
+ const CHANGELOG = "CHANGELOG.md";
341
+
342
+ // Where THIS project keeps its documentation, read off its own tree.
343
+ //
344
+ // The gate is portable; the instruction it printed was not. It named
345
+ // `docs/en/` AND `docs/pt/` and a skill that exists only in Doctrina's own
346
+ // repository, so an adopting project with neither read that it had to
347
+ // translate (change 0058). The gate itself never asked for any of that — it
348
+ // accepts anything under `docs/` or a README — so the remedy is derived from
349
+ // what the checked project actually has.
350
+ //
351
+ // Returns the documentation homes in reading order, or [] for a project that
352
+ // has none yet.
353
+ export function documentationHomes(projectRoot) {
354
+ const homes = [];
355
+ const docsDir = path.join(projectRoot, "docs");
356
+ // Only a directory that holds prose is somewhere to write prose: naming
357
+ // `docs/assets/` — an SVG and nothing else — turned change 0058's portable
358
+ // hint into a five-item list with a wrong item in it.
359
+ const holdsMarkdown = (dir) => walk(dir).some((f) => f.endsWith(".md"));
360
+ if (isDir(docsDir)) {
361
+ // A per-language or per-audience split is a convention, not a rule: name
362
+ // the subdirectories this project HAS, and fall back to `docs/` itself.
363
+ const subs = readdirSync(docsDir, { withFileTypes: true })
364
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
365
+ .filter((e) => holdsMarkdown(path.join(docsDir, e.name)))
366
+ .map((e) => `docs/${e.name}/`)
367
+ .sort();
368
+ if (subs.length > 0) homes.push(...subs);
369
+ else if (holdsMarkdown(docsDir)) homes.push("docs/");
370
+ }
371
+ for (const readme of ["README.md", "README.pt.md"]) {
372
+ if (isFile(path.join(projectRoot, readme))) homes.push(readme);
373
+ }
374
+ return homes;
375
+ }
376
+
377
+ // The hint the docs gate prints when it refuses. Names the places this
378
+ // project documents in, and says "a README" when it documents nowhere yet —
379
+ // never a path the checked project does not have.
380
+ export function docsRemedy(projectRoot) {
381
+ const homes = documentationHomes(projectRoot);
382
+ if (homes.length === 0) return "document it in a README, or under docs/";
383
+ if (homes.length === 1) return `document it in ${homes[0]}`;
384
+ const last = homes[homes.length - 1];
385
+ return `document it in ${homes.slice(0, -1).join(", ")} or ${last}` +
386
+ (homes.length > 2 ? " — whichever this change belongs in" : "");
144
387
  }
145
388
 
146
389
  // The gate itself: { ok, signals, touched, reason }. `ok` is true when the
147
390
  // change touches no documented surface, when documentation moved with it,
148
391
  // or when git cannot answer.
149
392
  export function checkDocsImpact(projectRoot, changeDir) {
150
- const signals = documentedSurfaceSignals(changeDir);
393
+ const signals = documentedSurfaceSignals(changeDir, projectRoot);
151
394
  if (signals.length === 0) {
152
395
  return { ok: true, signals, touched: [], reason: "touches no documented surface" };
153
396
  }
package/src/lib/ears.js CHANGED
@@ -3,6 +3,16 @@
3
3
  // word-level smells; this catches grammar-shape problems per EARS section.
4
4
  // All findings are warnings: specs evolve, validation should not block.
5
5
 
6
+ // A prohibition negates its own modal. Either the modal directly
7
+ // ("shall not", "shall never") or the object the verb takes, immediately
8
+ // ("shall do no work", "shall carry neither X nor Y").
9
+ //
10
+ // The gap is ONE word wide on purpose. Allowing two lets "shall report when
11
+ // no contracts are declared" back in — a report, not a prohibition, and the
12
+ // exact shape this check exists to keep out of the must-not section.
13
+ const UNWANTED_NEGATION =
14
+ /\bshall\s+(?:not|never)\b|\bshall\s+\w+\s+(?:no|neither|nothing)\b/i;
15
+
6
16
  const SECTION_RULES = [
7
17
  {
8
18
  match: /^###\s+Ubiquitous/i,
@@ -38,7 +48,20 @@ const SECTION_RULES = [
38
48
  name: "Unwanted-behavior",
39
49
  check(req) {
40
50
  if (!/\bshall\b/i.test(req)) return "missing \"shall\" — unwanted-behavior form is \"The system shall not ...\"";
41
- if (!/\b(not|no|never)\b/i.test(req)) return "missing a negation — unwanted-behavior requirements forbid something";
51
+ // The negation has to hang off the MODAL, not merely appear somewhere in
52
+ // the sentence. Searching the whole line let "no" the determiner stand
53
+ // in for a prohibition: "the system shall report it as a finding, because
54
+ // a claim over code that is not there..." forbids nothing and passed.
55
+ // Two requirements of this repository's own specs were positive
56
+ // obligations filed under must-not for exactly that reason.
57
+ //
58
+ // Both attachments count: "shall not/never <verb>", and the form that
59
+ // negates the object instead — "shall do no work", "shall carry neither".
60
+ if (!UNWANTED_NEGATION.test(req)) {
61
+ return "missing a negation on \"shall\" — unwanted-behavior forbids something "
62
+ + "(\"shall not ...\", \"shall never ...\", \"shall <verb> no ...\"); a "
63
+ + "requirement that only reports or accepts belongs in another section";
64
+ }
42
65
  return null;
43
66
  },
44
67
  },
@@ -2,7 +2,6 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { COMMAND_NAMES } from "./commands.js";
6
5
 
7
6
  // One catalog of "which flags does command X accept", with two consumers:
8
7
  //
@@ -64,12 +63,6 @@ export async function declaredFlags(commandName) {
64
63
  ]);
65
64
  }
66
65
 
67
- export async function loadAllFlagSpecs() {
68
- const out = new Map();
69
- for (const name of COMMAND_NAMES) out.set(name, await loadFlagSpec(name));
70
- return out;
71
- }
72
-
73
66
  // Flag names a source file READS, by scanning its call sites:
74
67
  // flagBool(flags, "x") flagString(flags, "x") flags.has("x") flags.get("x")
75
68
  // Deterministic text scan, not execution — it sees every branch, including
@@ -92,15 +85,6 @@ export function readCommandSource(commandName) {
92
85
  return readFileSync(modulePathFor(commandName), "utf8");
93
86
  }
94
87
 
95
- // Flags a command's `help` string documents: every `--flag` token in it.
96
- // The docs gate compares prose against this too, so help and reference
97
- // cannot drift apart from the declaration.
98
- export function scanHelpFlags(helpText) {
99
- const out = new Set();
100
- for (const m of String(helpText ?? "").matchAll(/--([a-z][a-z0-9-]*)/g)) out.add(m[1]);
101
- return out;
102
- }
103
-
104
88
  function pathToUrl(p) {
105
89
  return "file:///" + p.replace(/\\/g, "/");
106
90
  }
package/src/lib/fs-ops.js CHANGED
@@ -2,8 +2,26 @@
2
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, renameSync, unlinkSync, rmdirSync } from "node:fs";
3
3
  import path from "node:path";
4
4
 
5
+ // Every module reads a file through here, so the byte-order mark is removed
6
+ // here — once, at the door (third audit, finding 5).
7
+ //
8
+ // A spec saved by a Windows editor opens `# Spec — …`, and `validate`
9
+ // reported "carries no title" while `show`, `spec list` and `coverage` read
10
+ // the same file without complaint: the surfaces disagreed about a file none
11
+ // of them had a problem with. The mark is an encoding artifact of how the
12
+ // bytes were stored, not something the Doctrina grammar has an opinion
13
+ // about, so no parser downstream should have to know it exists.
14
+ //
15
+ // It matters beyond Markdown: `JSON.parse` throws on a leading BOM, so an
16
+ // `index.json` written by the wrong editor was unreadable with an error
17
+ // that named neither the cause nor the file.
18
+ //
19
+ // A file that had one loses it when a command rewrites it. That is a
20
+ // normalisation, and the right one — nothing in this tree is served by
21
+ // keeping it.
5
22
  export function read(p) {
6
- return readFileSync(p, "utf8");
23
+ const text = readFileSync(p, "utf8");
24
+ return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
7
25
  }
8
26
 
9
27
  export function write(p, content, { force = false } = {}) {
@@ -53,6 +71,45 @@ export function remove(p) {
53
71
  }
54
72
  }
55
73
 
74
+ /**
75
+ * Remove `dir` and each ancestor of it, walking up, for as long as each one
76
+ * is EMPTY — stopping at the first directory that still holds something, and
77
+ * never touching `stopAt` itself or anything outside it.
78
+ *
79
+ * The asymmetry this closes: a command that creates a directory with
80
+ * `mkdirp` on the way in has to unmake it on the way out, or "remove" leaves
81
+ * a configuration root behind. An EMPTY directory is the safe case to
82
+ * delete — there is nothing of anyone else's in it — which is why this
83
+ * stops at the first non-empty one rather than recursing.
84
+ *
85
+ * @param {string} dir The innermost directory to consider.
86
+ * @param {string} stopAt Boundary, never removed and never escaped.
87
+ * @returns {string[]} The directories removed, innermost first.
88
+ */
89
+ export function pruneEmptyDirs(dir, stopAt) {
90
+ const removed = [];
91
+ const boundary = path.resolve(stopAt);
92
+ let cur = path.resolve(dir);
93
+ while (cur !== boundary && cur.startsWith(boundary + path.sep)) {
94
+ if (!isDir(cur)) break;
95
+ let entries;
96
+ try {
97
+ entries = readdirSync(cur);
98
+ } catch {
99
+ break;
100
+ }
101
+ if (entries.length > 0) break;
102
+ try {
103
+ rmdirSync(cur);
104
+ } catch {
105
+ break;
106
+ }
107
+ removed.push(cur);
108
+ cur = path.dirname(cur);
109
+ }
110
+ return removed;
111
+ }
112
+
56
113
  // Recursive file walk. Returns absolute paths of every regular file.
57
114
  export function walk(root) {
58
115
  const out = [];