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.
- package/README.md +27 -72
- package/package.json +3 -3
- package/src/commands/adapter.js +27 -3
- package/src/commands/analyze.js +16 -212
- package/src/commands/change.js +144 -267
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +65 -21
- package/src/commands/close.js +349 -45
- package/src/commands/completion.js +8 -2
- package/src/commands/context.js +284 -111
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +66 -7
- package/src/commands/doctor.js +293 -130
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +17 -3
- package/src/commands/index-rebuild.js +55 -44
- package/src/commands/init.js +88 -16
- package/src/commands/intake.js +85 -99
- package/src/commands/intent.js +14 -1
- package/src/commands/metrics.js +65 -88
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +23 -91
- package/src/commands/report.js +40 -109
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +23 -39
- package/src/commands/spec.js +38 -11
- package/src/commands/status.js +69 -171
- package/src/commands/templates.js +35 -148
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/upgrade.js +31 -6
- package/src/commands/validate.js +44 -877
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +243 -267
- package/src/index.js +139 -12
- package/src/lib/actions.js +490 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/args.js +14 -0
- package/src/lib/change-ops.js +133 -0
- package/src/lib/commands.js +135 -10
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +431 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +370 -3
- package/src/lib/docs-impact.js +270 -27
- package/src/lib/ears.js +24 -1
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +231 -29
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +42 -24
- package/src/lib/intake-model.js +155 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +228 -0
- package/src/lib/metrics-model.js +189 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +105 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +328 -25
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +231 -0
- package/src/lib/spec-ops.js +72 -8
- package/src/lib/templates-model.js +289 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1381 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +413 -0
- package/src/lib/work-model.js +102 -0
- package/templates/AGENTS.md.template +21 -28
- package/templates/README.md +35 -0
- package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
- package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
- package/templates/change/proposal.md.template +16 -0
- package/templates/change/tasks.md.template +3 -8
- package/templates/contract.md.template +2 -2
- package/templates/hooks/pre-commit.sample +16 -2
- package/templates/playbooks/bootstrap.md.template +47 -0
- package/templates/playbooks/chore.md.template +24 -0
- package/templates/playbooks/work.md.template +83 -0
- package/src/commands/constitution.js +0 -145
package/src/lib/docs-impact.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
},
|
package/src/lib/flag-catalog.js
CHANGED
|
@@ -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
|
-
|
|
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 = [];
|