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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/adapter.js +24 -1
- package/src/commands/analyze.js +8 -199
- package/src/commands/change.js +143 -217
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +58 -21
- package/src/commands/close.js +291 -42
- package/src/commands/constitution.js +14 -91
- package/src/commands/context.js +278 -107
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +54 -7
- package/src/commands/doctor.js +292 -129
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +11 -2
- package/src/commands/index-rebuild.js +9 -48
- package/src/commands/init.js +70 -13
- package/src/commands/intake.js +30 -92
- package/src/commands/metrics.js +53 -84
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +24 -91
- package/src/commands/report.js +50 -108
- 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 +16 -25
- package/src/commands/spec.js +26 -5
- package/src/commands/status.js +51 -172
- package/src/commands/templates.js +31 -138
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +7 -75
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +121 -160
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +234 -263
- package/src/index.js +106 -7
- package/src/lib/actions.js +484 -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/change-ops.js +131 -0
- package/src/lib/commands.js +109 -6
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +392 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +326 -0
- package/src/lib/docs-impact.js +196 -27
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +210 -24
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +2 -19
- package/src/lib/intake-model.js +86 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +191 -0
- package/src/lib/metrics-model.js +159 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +57 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +238 -11
- package/src/lib/scan.js +204 -13
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +209 -0
- package/src/lib/spec-ops.js +50 -5
- package/src/lib/templates-model.js +262 -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 +1299 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +410 -0
- package/src/lib/work-model.js +77 -0
- package/templates/AGENTS.md.template +20 -26
- package/templates/README.md +35 -0
- package/templates/change/proposal.md.template +1 -0
- package/templates/contract.md.template +7 -0
- package/templates/hooks/pre-commit.sample +6 -2
- package/templates/playbooks/bootstrap.md.template +43 -0
- package/templates/playbooks/chore.md.template +19 -0
- package/templates/playbooks/work.md.template +76 -0
package/src/commands/validate.js
CHANGED
|
@@ -1,29 +1,17 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import {
|
|
5
|
-
import { exists, isDir, isFile, lineCount, read, relPath, walk , write} from "../lib/fs-ops.js";
|
|
6
|
-
import * as idx from "../lib/index-json.js";
|
|
7
|
-
import { SCHEMA_VERSION } from "../lib/index-json.js";
|
|
8
|
-
import { cliVersion } from "../lib/version.js";
|
|
9
|
-
import { today } from "../lib/dates.js";
|
|
4
|
+
import { isDir } from "../lib/fs-ops.js";
|
|
10
5
|
import { flagBool } from "../lib/args.js";
|
|
11
6
|
import { c } from "../lib/colors.js";
|
|
12
|
-
import { kindFromPath, nonConformingHeaders, repairHeaders } from "../lib/doc-model.js";
|
|
13
7
|
import { emitJson } from "../lib/json-out.js";
|
|
14
8
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
15
|
-
import {
|
|
16
|
-
import { checkEars, isEarsSpec } from "../lib/ears.js";
|
|
17
|
-
import { specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
|
|
18
|
-
import { COMMAND_NAMES, referencedCommands } from "../lib/commands.js";
|
|
19
|
-
import { parseAcceptanceCriteria, isVerified } from "../lib/criteria.js";
|
|
20
|
-
import { parsePipeline, checkPipeline } from "../lib/pipeline.js";
|
|
21
|
-
import { collectRuntimeFindings } from "../lib/runtime.js";
|
|
9
|
+
import { collectValidation } from "../lib/validation-model.js";
|
|
22
10
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
11
|
+
// The structural gate. The checks themselves live in lib/validation-model.js
|
|
12
|
+
// so a driver can ASK for them instead of spawning this binary and parsing
|
|
13
|
+
// its own JSON back (ADR 0025); what stays here is the rendering and the
|
|
14
|
+
// exit code.
|
|
27
15
|
|
|
28
16
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
29
17
|
// adding a command never requires editing the entrypoint — the gap that
|
|
@@ -40,644 +28,20 @@ export async function run(_positional, flags) {
|
|
|
40
28
|
// gate failures about missing files: the remedy is `doctrina init`,
|
|
41
29
|
// not editing artifacts that do not exist yet (C7).
|
|
42
30
|
if (!isDir(path.join(projectRoot, ".doctrina"))) throw notADoctrinaProject();
|
|
43
|
-
const errors = [];
|
|
44
|
-
const warnings = [];
|
|
45
31
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
} else {
|
|
51
|
-
const lines = lineCount(agentsMd);
|
|
52
|
-
if (lines > 200) errors.push(`AGENTS.md is ${lines} lines (>200, hard limit)`);
|
|
53
|
-
else if (lines > 150) warnings.push(`AGENTS.md is ${lines} lines (>150 soft limit)`);
|
|
54
|
-
|
|
55
|
-
// 1c. AGENTS.md command-surface drift. AGENTS.md is the hub the agent reads
|
|
56
|
-
// first, so the doctrina commands it documents must match the real CLI.
|
|
57
|
-
// Two drifts are flagged: a reference to a command that does not exist
|
|
58
|
-
// (a typo or a removed command — the agent will try to run it and fail),
|
|
59
|
-
// and a maintained catalog that has fallen behind the CLI (commands the
|
|
60
|
-
// agent never discovers because the hub never names them). validate only
|
|
61
|
-
// checked AGENTS.md *size* before, so the hub could rot while staying
|
|
62
|
-
// green; this closes that gap.
|
|
63
|
-
const agentsText = read(agentsMd);
|
|
64
|
-
const referenced = referencedCommands(agentsText);
|
|
65
|
-
const known = new Set(COMMAND_NAMES);
|
|
66
|
-
// Reverse drift is always flagged — a dangling command reference misleads
|
|
67
|
-
// the agent regardless of how the file is structured.
|
|
68
|
-
for (const cmd of [...referenced].sort()) {
|
|
69
|
-
if (!known.has(cmd)) {
|
|
70
|
-
warnings.push(
|
|
71
|
-
`AGENTS.md references \`doctrina ${cmd}\` which is not a CLI command ` +
|
|
72
|
-
`(typo or removed command — agents reading the hub will try to run it)`,
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
// Forward drift (omitted commands) is flagged only when the file presents
|
|
77
|
-
// itself as an exhaustive catalog: it documents many commands AND does not
|
|
78
|
-
// defer to `doctrina --help`. A file that defers is declaring its list
|
|
79
|
-
// illustrative (the shipped template does this), so its omissions are
|
|
80
|
-
// intentional and stay silent.
|
|
81
|
-
const documented = [...referenced].filter((cmd) => known.has(cmd));
|
|
82
|
-
const defersToHelp = /doctrina(?:-cli)?\s+--help/.test(agentsText);
|
|
83
|
-
if (documented.length >= CATALOG_THRESHOLD && !defersToHelp) {
|
|
84
|
-
const missing = COMMAND_NAMES.filter((cmd) => !referenced.has(cmd));
|
|
85
|
-
if (missing.length > 0) {
|
|
86
|
-
warnings.push(
|
|
87
|
-
`AGENTS.md documents the doctrina command surface but omits ` +
|
|
88
|
-
`${missing.length} command${missing.length === 1 ? "" : "s"} ` +
|
|
89
|
-
`(${missing.join(", ")}) — agents reading the hub will not discover ` +
|
|
90
|
-
`them (add them, or defer to \`doctrina --help\` for the full list)`,
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// 1b. Nested AGENTS.md files ("nearest wins" hierarchy) obey the same
|
|
97
|
-
// size caps as the root file.
|
|
98
|
-
for (const nested of findNestedAgentsMd(projectRoot)) {
|
|
99
|
-
const rel = relPath(projectRoot, nested);
|
|
100
|
-
const lines = lineCount(nested);
|
|
101
|
-
if (lines > 200) errors.push(`${rel} is ${lines} lines (>200, hard limit)`);
|
|
102
|
-
else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft limit)`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// 2. product.md
|
|
106
|
-
const productMd = path.join(projectRoot, ".doctrina", "product.md");
|
|
107
|
-
if (!isFile(productMd)) errors.push(".doctrina/product.md missing");
|
|
108
|
-
|
|
109
|
-
// Header repair (M3). One grammar means a non-canonical header can be
|
|
110
|
-
// REWRITTEN, not just reported: `- **Status**: x` becomes
|
|
111
|
-
// `- **Status:** x`, and a spec written with list-style headers is
|
|
112
|
-
// normalised to the bare-bold form its kind uses. Content is never
|
|
113
|
-
// touched — only the header's own punctuation and style.
|
|
114
|
-
const doFix = flagBool(flags, "fix", false);
|
|
115
|
-
{
|
|
116
|
-
const artifacts = [];
|
|
117
|
-
const dot = path.join(projectRoot, ".doctrina");
|
|
118
|
-
for (const f of walk(dot)) {
|
|
119
|
-
if (!f.endsWith(".md")) continue;
|
|
120
|
-
const rel = relPath(projectRoot, f).replace(/\\/g, "/");
|
|
121
|
-
if (rel.includes("/changes/archive/")) continue; // history is immutable
|
|
122
|
-
artifacts.push({ file: f, rel, kind: kindFromPath(rel) });
|
|
123
|
-
}
|
|
124
|
-
let repairedFiles = 0;
|
|
125
|
-
for (const a of artifacts) {
|
|
126
|
-
const text = read(a.file);
|
|
127
|
-
const bad = nonConformingHeaders(text, a.kind);
|
|
128
|
-
if (bad.length === 0) continue;
|
|
129
|
-
if (doFix) {
|
|
130
|
-
const { text: fixed, repaired } = repairHeaders(text, a.kind);
|
|
131
|
-
if (repaired > 0 && fixed !== text) {
|
|
132
|
-
write(a.file, fixed, { force: true });
|
|
133
|
-
repairedFiles += 1;
|
|
134
|
-
}
|
|
135
|
-
} else {
|
|
136
|
-
for (const h of bad) {
|
|
137
|
-
warnings.push(
|
|
138
|
-
`${a.rel}:${h.line} header "${h.name}" is ${h.wrongStyle ? `in ${h.style} style but a ${a.kind} uses ${h.expectedStyle} style` : "not in canonical form"} — run \`doctrina validate --fix\``,
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
if (doFix && repairedFiles > 0) {
|
|
144
|
-
console.log(c.green("fixed") + ` normalised headers in ${repairedFiles} artifact${repairedFiles === 1 ? "" : "s"}`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// 3. index.json
|
|
149
|
-
let index = null;
|
|
150
|
-
try {
|
|
151
|
-
index = idx.load(projectRoot);
|
|
152
|
-
} catch (err) {
|
|
153
|
-
errors.push(err.message);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (index) {
|
|
157
|
-
// --fix (F5): regenerate the index from the tree before validating, so a
|
|
158
|
-
// drifted index is repaired rather than reported. A full rebuild — it also
|
|
159
|
-
// absorbs orphans and migrates the framework stamp, matching
|
|
160
|
-
// `index rebuild`. Subsequent checks then see the repaired index.
|
|
161
|
-
if (flagBool(flags, "fix", false)) {
|
|
162
|
-
const derived = deriveIndex(projectRoot, index);
|
|
163
|
-
const staleStamp = (index.framework_version ?? null) !== cliVersion();
|
|
164
|
-
if (!indexesMatch(derived, index) || staleStamp) {
|
|
165
|
-
derived.framework_version = cliVersion();
|
|
166
|
-
derived.last_updated = today();
|
|
167
|
-
idx.save(projectRoot, derived);
|
|
168
|
-
console.log(c.green("fixed") + " rebuilt .doctrina/index.json from the tree");
|
|
169
|
-
index = derived;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (index.$schema_version !== SCHEMA_VERSION) {
|
|
174
|
-
warnings.push(`index.json $schema_version is "${index.$schema_version}" (expected "${SCHEMA_VERSION}")`);
|
|
175
|
-
}
|
|
176
|
-
// framework_version stamp divergence (3.6). The index records which CLI
|
|
177
|
-
// manages it; a stamp behind (or absent vs.) the running CLI means the
|
|
178
|
-
// tree may predate the current schema. `index rebuild` migrates it.
|
|
179
|
-
const runningVersion = cliVersion();
|
|
180
|
-
if (!index.framework_version) {
|
|
181
|
-
warnings.push(`index.json has no framework_version (run \`doctrina index rebuild\` to stamp it)`);
|
|
182
|
-
} else if (index.framework_version !== runningVersion) {
|
|
183
|
-
warnings.push(
|
|
184
|
-
`index.json framework_version is "${index.framework_version}" but the running CLI is ` +
|
|
185
|
-
`${runningVersion} (run \`doctrina index rebuild\` to stamp/migrate)`,
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
if (!index.artifacts) {
|
|
189
|
-
errors.push("index.json missing artifacts object");
|
|
190
|
-
} else {
|
|
191
|
-
// 4. Every referenced artifact path must exist
|
|
192
|
-
const allArtifactPaths = [];
|
|
193
|
-
if (index.artifacts.product?.path) allArtifactPaths.push(index.artifacts.product.path);
|
|
194
|
-
for (const s of index.artifacts.specs ?? []) allArtifactPaths.push(s.path);
|
|
195
|
-
for (const d of index.artifacts.decisions ?? []) allArtifactPaths.push(d.path);
|
|
196
|
-
for (const ch of index.artifacts.changes ?? []) allArtifactPaths.push(ch.path);
|
|
197
|
-
for (const ch of index.artifacts.changes_archive ?? []) allArtifactPaths.push(ch.path);
|
|
198
|
-
for (const ct of index.artifacts.contracts ?? []) allArtifactPaths.push(ct.path);
|
|
199
|
-
for (const rel of allArtifactPaths) {
|
|
200
|
-
const full = path.join(projectRoot, rel);
|
|
201
|
-
if (!exists(full)) errors.push(`index.json references missing artifact: ${rel}`);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// 4b. Index metadata drift (F5 / review G5 / G7). validate is the single
|
|
205
|
-
// source of truth about whether the project is OK, so an indexed
|
|
206
|
-
// artifact whose recorded metadata (version, implementation, status,
|
|
207
|
-
// ...) no longer matches its file is an ERROR here — not a
|
|
208
|
-
// pass-with-warning that only `index rebuild --check` would catch
|
|
209
|
-
// (the "validate says OK while next says drift" footgun). Presence
|
|
210
|
-
// drift (orphan on disk / missing file) stays the orphan warning and
|
|
211
|
-
// the missing-artifact error above; this targets the metadata that
|
|
212
|
-
// silently lies. `validate --fix` (above) regenerates instead.
|
|
213
|
-
const derived = deriveIndex(projectRoot, index);
|
|
214
|
-
for (const line of metadataDrift(index, derived)) {
|
|
215
|
-
errors.push(`${line} — run \`doctrina validate --fix\` or \`doctrina index rebuild\``);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// 5. ADRs have parseable Status: header
|
|
221
|
-
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
222
|
-
// Track which files claim each NNNN so a merge-time collision (two branches
|
|
223
|
-
// both allocating the same number) becomes a loud error rather than one ADR
|
|
224
|
-
// silently shadowing the other (C2).
|
|
225
|
-
const adrByNumber = new Map();
|
|
226
|
-
if (isDir(adrDir)) {
|
|
227
|
-
for (const f of walk(adrDir)) {
|
|
228
|
-
if (!f.endsWith(".md")) continue;
|
|
229
|
-
const base = path.basename(f);
|
|
230
|
-
const numMatch = base.match(/^(\d{4})-/);
|
|
231
|
-
const num = numMatch ? numMatch[1] : null;
|
|
232
|
-
if (num) {
|
|
233
|
-
if (!adrByNumber.has(num)) adrByNumber.set(num, []);
|
|
234
|
-
adrByNumber.get(num).push(relPath(projectRoot, f));
|
|
235
|
-
}
|
|
236
|
-
// ADR filenames must be NNNN-slug.md (four digits). The entire ADR
|
|
237
|
-
// toolchain — decision new/accept/supersede, index derivation, and
|
|
238
|
-
// the orphan check below — keys off this shape; a file like
|
|
239
|
-
// "ADR-001-foo.md" is silently invisible to all of them.
|
|
240
|
-
if (base !== "README.md" && !/^\d{4}-.+\.md$/.test(base)) {
|
|
241
|
-
errors.push(
|
|
242
|
-
`${relPath(projectRoot, f)} has a non-canonical ADR filename ` +
|
|
243
|
-
`(expected four digits + slug, e.g. "0001-jwt-algorithm.md"; ` +
|
|
244
|
-
`the decision and index commands silently ignore other shapes)`,
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
const text = read(f);
|
|
248
|
-
if (!/^-\s+\*\*Status:\*\*\s+\S+/m.test(text)) {
|
|
249
|
-
errors.push(
|
|
250
|
-
`${relPath(projectRoot, f)} missing or malformed Status: header ` +
|
|
251
|
-
`(expected a Markdown list item exactly like "- **Status:** accepted")`,
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// 5b. ADR evidence drift. A decision is only true if reality reflects
|
|
256
|
-
// it; an accepted ADR that points at nothing — or at a file that
|
|
257
|
-
// no longer exists — is the classic "ADR decrees X, the code
|
|
258
|
-
// never did X". Two header fields anchor a decision to reality:
|
|
259
|
-
// **Evidence:** (cited at any point) and **Landed:** (the
|
|
260
|
-
// non-mutating "this is now implemented and verified here" stamp,
|
|
261
|
-
// written by `doctrina decision land`; 3.4). Both are checked for
|
|
262
|
-
// dangling citations; an accepted ADR is only flagged bare when
|
|
263
|
-
// BOTH are empty. ADRs that adopt neither header opt out entirely.
|
|
264
|
-
const evidence = listHeader(text, "Evidence");
|
|
265
|
-
const landed = listHeader(text, "Landed");
|
|
266
|
-
const isBare = (v) => {
|
|
267
|
-
const t = (v ?? "").trim();
|
|
268
|
-
return t === "" || t === "—" || t === "-";
|
|
269
|
-
};
|
|
270
|
-
for (const [field, value] of [["Evidence", evidence], ["Landed", landed]]) {
|
|
271
|
-
if (value === null || isBare(value)) continue;
|
|
272
|
-
for (const token of extractBacktickPaths(value)) {
|
|
273
|
-
const candidates = [path.resolve(path.dirname(f), token), path.resolve(projectRoot, token)];
|
|
274
|
-
if (!candidates.some(exists)) {
|
|
275
|
-
warnings.push(
|
|
276
|
-
`${relPath(projectRoot, f)}: ${field} cites \`${token}\` which is missing on disk — ` +
|
|
277
|
-
`the decision may have drifted from the code (update it or supersede the ADR)`,
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
const adrStatus = (listHeader(text, "Status") ?? "").toLowerCase();
|
|
283
|
-
if (adrStatus === "accepted" && evidence !== null && isBare(evidence) && isBare(landed)) {
|
|
284
|
-
warnings.push(
|
|
285
|
-
`${relPath(projectRoot, f)}: accepted ADR cites no implementation evidence ` +
|
|
286
|
-
`(link the file(s) that prove it, run \`doctrina decision land ${num ?? "NNNN"}\` once ` +
|
|
287
|
-
`it ships, or note "n/a — <why>"); a decision with nothing behind it may be drift ` +
|
|
288
|
-
`waiting to be superseded`,
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// 5c. Duplicate ADR numbers (C2).
|
|
294
|
-
for (const [num, files] of adrByNumber) {
|
|
295
|
-
if (files.length > 1) {
|
|
296
|
-
errors.push(
|
|
297
|
-
`ADR number ${num} is claimed by ${files.length} files (${files.join(", ")}) — ` +
|
|
298
|
-
`two branches likely allocated it concurrently; renumber all but one ` +
|
|
299
|
-
`(the index keys decisions by number, so duplicates silently shadow each other)`,
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// 6. Adapter templates under 30 lines
|
|
306
|
-
const adaptersDir = path.join(projectRoot, ".doctrina", "templates", "adapters");
|
|
307
|
-
if (isDir(adaptersDir)) {
|
|
308
|
-
for (const f of walk(adaptersDir)) {
|
|
309
|
-
if (!f.endsWith(".template")) continue;
|
|
310
|
-
const lines = lineCount(f);
|
|
311
|
-
if (lines > 30) errors.push(`${relPath(projectRoot, f)} is ${lines} lines (>30, adapter cap)`);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// 7. Every open change has a proposal, and its delta files carry a
|
|
316
|
-
// well-formed **Operation:** header. The delta used to be the one
|
|
317
|
-
// 100% hand-authored file with no early check: a missing header passed
|
|
318
|
-
// silently at creation and exploded days later in the closing analyze
|
|
319
|
-
// (operator review 2026-07-19 §3.2). Warning-level — the change is
|
|
320
|
-
// still a draft — but now every `validate` run names it.
|
|
321
|
-
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
322
|
-
if (isDir(changesDir)) {
|
|
323
|
-
const { readdirSync } = await import("node:fs");
|
|
324
|
-
for (const entry of readdirSync(changesDir)) {
|
|
325
|
-
if (entry === "archive" || entry.startsWith(".")) continue;
|
|
326
|
-
const proposal = path.join(changesDir, entry, "proposal.md");
|
|
327
|
-
if (!isFile(proposal)) {
|
|
328
|
-
errors.push(
|
|
329
|
-
`open change "${entry}" missing proposal.md ` +
|
|
330
|
-
`(every dir under .doctrina/changes/ needs a proposal.md — that exact filename)`,
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
// A change whose tasks are still the scaffold placeholders was opened
|
|
334
|
-
// but never planned — the agent is (or will be) implementing with no
|
|
335
|
-
// recorded plan. Early, every-run signal; analyze/close hard-fail it.
|
|
336
|
-
const tasksPath = path.join(changesDir, entry, "tasks.md");
|
|
337
|
-
if (isFile(tasksPath)) {
|
|
338
|
-
const ph = (read(tasksPath).match(/^\s*-\s*\[[ xX]\]\s*$/gm) ?? []).length;
|
|
339
|
-
if (ph > 0) {
|
|
340
|
-
warnings.push(
|
|
341
|
-
`open change "${entry}" tasks.md still carries ${ph} scaffold placeholder task${ph === 1 ? "" : "s"} — ` +
|
|
342
|
-
`the change was opened but never planned (replace them with real tasks; analyze/close refuse them)`,
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
for (const deltaPath of walk(path.join(changesDir, entry, "specs"))) {
|
|
347
|
-
if (!deltaPath.endsWith("delta.md")) continue;
|
|
348
|
-
const text = read(deltaPath);
|
|
349
|
-
const m = text.match(/^\*\*Operation:\*\*\s*(\S+)/m);
|
|
350
|
-
if (!m) {
|
|
351
|
-
warnings.push(
|
|
352
|
-
`${relPath(projectRoot, deltaPath)} has no **Operation:** header — ` +
|
|
353
|
-
`analyze/apply will refuse it at close time (add "**Operation:** ADDED|MODIFIED|REMOVED")`,
|
|
354
|
-
);
|
|
355
|
-
} else if (!["ADDED", "MODIFIED", "REMOVED"].includes(m[1])) {
|
|
356
|
-
warnings.push(
|
|
357
|
-
`${relPath(projectRoot, deltaPath)} Operation "${m[1]}" is not ADDED|MODIFIED|REMOVED — ` +
|
|
358
|
-
`analyze/apply will refuse it at close time`,
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// 8. Specs over 400 lines warn (lost-in-the-middle).
|
|
366
|
-
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
367
|
-
if (isDir(specsDir)) {
|
|
368
|
-
const { readdirSync } = await import("node:fs");
|
|
369
|
-
const KNOWN_SPEC_HEADERS = ["Capability", "Status", "Implementation", "Version", "Last updated", "Realizes"];
|
|
370
|
-
for (const cap of readdirSync(specsDir)) {
|
|
371
|
-
const specPath = path.join(specsDir, cap, "spec.md");
|
|
372
|
-
if (isFile(specPath)) {
|
|
373
|
-
const lines = lineCount(specPath);
|
|
374
|
-
if (lines > 400) warnings.push(`${relPath(projectRoot, specPath)} is ${lines} lines (>400 soft cap)`);
|
|
375
|
-
|
|
376
|
-
// 8b. EARS grammar shape — section-appropriate When/While/Where/shall.
|
|
377
|
-
const text = read(specPath);
|
|
378
|
-
if (isEarsSpec(text)) {
|
|
379
|
-
for (const f of checkEars(text)) {
|
|
380
|
-
warnings.push(`${relPath(projectRoot, specPath)}:${f.line} ${f.message}`);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// 8b-ii. ORDERED requirements (change 0029). EARS states each
|
|
385
|
-
// event-driven requirement independently and says nothing about
|
|
386
|
-
// order, so a consumer step and its producer both pass while the
|
|
387
|
-
// consumer reads last run's file. A `### Pipeline` block declares
|
|
388
|
-
// the order and the artifact each step hands on; the invariant is
|
|
389
|
-
// that a step may only require what an EARLIER step produced.
|
|
390
|
-
// Opt-in: a spec without the block is never checked.
|
|
391
|
-
for (const f of checkPipeline(parsePipeline(text))) {
|
|
392
|
-
errors.push(`${relPath(projectRoot, specPath)}:${f.line} ${f.message} [${f.code}] — ${f.remedy}`);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// 8c. Capability-state honesty (two-axis status). An "active"
|
|
396
|
-
// document that records no built capability is an inventory
|
|
397
|
-
// claim — the gap the framework is meant to make visible.
|
|
398
|
-
// Warn when Status is active and Implementation is "planned"
|
|
399
|
-
// with no explanatory note after the state word. A note
|
|
400
|
-
// ("planned — deferred, see ADR 0007") is the deliberate-gap
|
|
401
|
-
// escape hatch. Specs with no Implementation header are off
|
|
402
|
-
// the axis and never warn.
|
|
403
|
-
const implRaw = specHeader(text, "Implementation");
|
|
404
|
-
if (implRaw) {
|
|
405
|
-
const docStatus = (specHeader(text, "Status") ?? "active").toLowerCase();
|
|
406
|
-
const implTokens = implRaw.trim().split(/\s+/);
|
|
407
|
-
const implWord = (implTokens[0] ?? "").replace(/[—-]+$/, "").toLowerCase();
|
|
408
|
-
const hasNote = implTokens.length > 1;
|
|
409
|
-
if (docStatus === "active" && implWord === "planned" && !hasNote) {
|
|
410
|
-
warnings.push(
|
|
411
|
-
`${relPath(projectRoot, specPath)}: Status is "active" but Implementation is ` +
|
|
412
|
-
`"planned" with no note — an active spec with no built capability is an ` +
|
|
413
|
-
`inventory claim (advance Implementation, set Status: draft, or add a note ` +
|
|
414
|
-
`after the value: "planned — <why deferred>")`,
|
|
415
|
-
);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// 8e. Provenance adoption (review 2026-06-27, §5/§9). `trace` is the
|
|
420
|
-
// intent→capability link, but it stays inert when nothing forces
|
|
421
|
-
// the tag, so an active capability with no Realizes: header is an
|
|
422
|
-
// untraced promise — warn here (validate is always run) rather than
|
|
423
|
-
// only in the opt-in `trace`. Advisory, with the same escape hatch
|
|
424
|
-
// pattern as Implementation: any value silences it, including a
|
|
425
|
-
// deliberate "n/a — <why>" for an internal capability that maps to
|
|
426
|
-
// no product anchor. Bug specs (off the implementation axis) and
|
|
427
|
-
// draft/deprecated documents are exempt — only active capability
|
|
428
|
-
// specs make the promise.
|
|
429
|
-
const docStatusForRealizes = (specHeader(text, "Status") ?? "active").toLowerCase();
|
|
430
|
-
const onImplAxis = specHeader(text, "Implementation") !== null;
|
|
431
|
-
if (docStatusForRealizes === "active" && onImplAxis && specHeader(text, "Realizes") === null) {
|
|
432
|
-
warnings.push(
|
|
433
|
-
`${relPath(projectRoot, specPath)}: active spec has no Realizes: header — ` +
|
|
434
|
-
`it traces to no product intent (\`doctrina trace\`). Tag a product.md ` +
|
|
435
|
-
`success-criteria bullet "- [SC1] ..." and add "**Realizes:** SC1", or ` +
|
|
436
|
-
`record "**Realizes:** n/a — <why>" if it maps to no product anchor`,
|
|
437
|
-
);
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// 8d. Metadata-header shape (review G11). In the header block (before
|
|
441
|
-
// the first `## ` section or `<!--` comment), a known key written
|
|
442
|
-
// without the canonical `**Key:** value` form silently fails to
|
|
443
|
-
// parse — the index then falls back to a default and drifts. Warn
|
|
444
|
-
// with the fix rather than letting the mis-typed header vanish.
|
|
445
|
-
const headerLines = text.split(/\r?\n/);
|
|
446
|
-
for (let i = 0; i < headerLines.length; i++) {
|
|
447
|
-
if (/^\s*(##\s|<!--)/.test(headerLines[i])) break;
|
|
448
|
-
for (const key of KNOWN_SPEC_HEADERS) {
|
|
449
|
-
const attempt = new RegExp(`^\\s*-?\\s*\\**${key}\\**\\s*:`, "i");
|
|
450
|
-
const canonical = new RegExp(`^(?:-\\s+)?\\*\\*${key}:\\*\\*\\s+\\S`);
|
|
451
|
-
if (attempt.test(headerLines[i]) && !canonical.test(headerLines[i])) {
|
|
452
|
-
warnings.push(
|
|
453
|
-
`${relPath(projectRoot, specPath)}:${i + 1} metadata header "${key}" is not in ` +
|
|
454
|
-
`canonical "**${key}:** value" form — it will not parse (G11)`,
|
|
455
|
-
);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// 8f. Self-certified acceptance criteria (honest gates, ADR 0008). A
|
|
461
|
-
// criterion that asserts [verified] but cites no proof path is a
|
|
462
|
-
// claim with nothing behind it — the dishonest-green case. `coverage`
|
|
463
|
-
// reports every bare criterion; this names the specific combo
|
|
464
|
-
// (claims verified, proves nothing) in the always-run gate. It reads
|
|
465
|
-
// through the shared multi-line parser, so a proof cited on a
|
|
466
|
-
// continuation line still counts (no false positive).
|
|
467
|
-
for (const crit of parseAcceptanceCriteria(text)) {
|
|
468
|
-
if (isVerified(crit) && crit.proofPaths.length === 0) {
|
|
469
|
-
warnings.push(
|
|
470
|
-
`${relPath(projectRoot, specPath)}: acceptance criterion #${crit.n} is marked ` +
|
|
471
|
-
`[verified] but cites no proof path — self-certified (cite the file/test in ` +
|
|
472
|
-
`backticks, or drop the marker; \`doctrina coverage\`)`,
|
|
473
|
-
);
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// 9. ADRs over 300 lines warn.
|
|
481
|
-
if (isDir(adrDir)) {
|
|
482
|
-
for (const f of walk(adrDir)) {
|
|
483
|
-
if (!f.endsWith(".md")) continue;
|
|
484
|
-
const lines = lineCount(f);
|
|
485
|
-
if (lines > 300) warnings.push(`${relPath(projectRoot, f)} is ${lines} lines (>300 soft cap)`);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// 10b. Stale-reference detection — Markdown link targets inside specs
|
|
490
|
-
// and ADRs that no longer exist on disk. Backtick paths are NOT
|
|
491
|
-
// checked (descriptive prose, too noisy). Links are pointers.
|
|
492
|
-
for (const dir of [specsDir, adrDir]) {
|
|
493
|
-
if (!isDir(dir)) continue;
|
|
494
|
-
for (const f of walk(dir)) {
|
|
495
|
-
if (!f.endsWith(".md")) continue;
|
|
496
|
-
const text = read(f);
|
|
497
|
-
const fileDir = path.dirname(f);
|
|
498
|
-
for (const ref of extractPathReferences(text)) {
|
|
499
|
-
// Try relative to the file first, then to project root
|
|
500
|
-
const candidates = [
|
|
501
|
-
path.resolve(fileDir, ref),
|
|
502
|
-
path.resolve(projectRoot, ref),
|
|
503
|
-
];
|
|
504
|
-
if (!candidates.some(exists)) {
|
|
505
|
-
warnings.push(`${relPath(projectRoot, f)} references missing path \`${ref}\``);
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// 10. Orphan detection — files on disk not referenced in index.json.
|
|
512
|
-
if (index?.artifacts) {
|
|
513
|
-
const indexedSpecIds = new Set((index.artifacts.specs ?? []).map((s) => s.id ?? s.capability));
|
|
514
|
-
if (isDir(specsDir)) {
|
|
515
|
-
const { readdirSync } = await import("node:fs");
|
|
516
|
-
for (const cap of readdirSync(specsDir)) {
|
|
517
|
-
const specPath = path.join(specsDir, cap, "spec.md");
|
|
518
|
-
if (isFile(specPath) && !indexedSpecIds.has(cap)) {
|
|
519
|
-
warnings.push(`orphan spec ${relPath(projectRoot, specPath)} (capability "${cap}" not in index.json)`);
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
const indexedAdrIds = new Set((index.artifacts.decisions ?? []).map((d) => d.id));
|
|
524
|
-
if (isDir(adrDir)) {
|
|
525
|
-
for (const f of walk(adrDir)) {
|
|
526
|
-
if (!f.endsWith(".md")) continue;
|
|
527
|
-
const m = path.basename(f).match(/^(\d{4})-/);
|
|
528
|
-
if (m && !indexedAdrIds.has(m[1])) {
|
|
529
|
-
warnings.push(`orphan ADR ${relPath(projectRoot, f)} (id "${m[1]}" not in index.json)`);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
const indexedContractIds = new Set((index.artifacts.contracts ?? []).map((ct) => ct.id));
|
|
534
|
-
const contractsDir = path.join(projectRoot, ".doctrina", "contracts");
|
|
535
|
-
if (isDir(contractsDir)) {
|
|
536
|
-
for (const f of walk(contractsDir)) {
|
|
537
|
-
if (!f.endsWith(".md")) continue;
|
|
538
|
-
const id = path.basename(f, ".md");
|
|
539
|
-
if (!indexedContractIds.has(id)) {
|
|
540
|
-
warnings.push(`orphan contract ${relPath(projectRoot, f)} (id "${id}" not in index.json)`);
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// 11. Archive ledger ↔ index cross-check. The history of archived
|
|
547
|
-
// changes is recorded twice — once human-facing in
|
|
548
|
-
// changes/archive/LEDGER.md, once machine-facing in
|
|
549
|
-
// index.json.changes_archive. Two sources of truth that silently
|
|
550
|
-
// disagree are worse than one: an incomplete ledger makes the
|
|
551
|
-
// history look shorter than it is. When the ledger exists, every
|
|
552
|
-
// archived change must appear in both, or validation fails.
|
|
553
|
-
const ledgerPath = path.join(changesDir, "archive", "LEDGER.md");
|
|
554
|
-
if (index?.artifacts && isFile(ledgerPath)) {
|
|
555
|
-
const ledgerIds = new Set();
|
|
556
|
-
for (const line of read(ledgerPath).split(/\r?\n/)) {
|
|
557
|
-
const m = line.match(/^-\s+\d{4}-\d{2}-\d{2}\s+[—-]\s+(\S+)\s+[—-]\s+(.*)$/);
|
|
558
|
-
if (!m) continue;
|
|
559
|
-
// An abandoned change is ledger-only BY DESIGN: `change abandon`
|
|
560
|
-
// records the discard in history but deletes the folder, so there is
|
|
561
|
-
// deliberately no archive entry to cross-check. Requiring one made
|
|
562
|
-
// every abandonment turn validate permanently red (found dogfooding
|
|
563
|
-
// change 0001-review-followups).
|
|
564
|
-
if (/^abandoned\b/i.test(m[2].trim())) continue;
|
|
565
|
-
ledgerIds.add(m[1]);
|
|
566
|
-
}
|
|
567
|
-
const archiveIds = new Set((index.artifacts.changes_archive ?? []).map((ch) => ch.id));
|
|
568
|
-
for (const id of archiveIds) {
|
|
569
|
-
if (!ledgerIds.has(id)) {
|
|
570
|
-
errors.push(`archived change "${id}" is in index.json but missing from changes/archive/LEDGER.md (ledger and index disagree)`);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
for (const id of ledgerIds) {
|
|
574
|
-
if (!archiveIds.has(id)) {
|
|
575
|
-
errors.push(`changes/archive/LEDGER.md lists "${id}" but index.json.changes_archive does not (ledger and index disagree)`);
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// 12-14. Skills validation
|
|
581
|
-
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
582
|
-
if (isDir(skillsDir)) {
|
|
583
|
-
for (const f of walk(skillsDir)) {
|
|
584
|
-
if (!f.endsWith(".md")) continue;
|
|
585
|
-
const text = read(f);
|
|
586
|
-
const rel = relPath(projectRoot, f);
|
|
587
|
-
const baseName = path.basename(f, ".md");
|
|
588
|
-
const nameField = parseFrontmatter(text, "name");
|
|
589
|
-
const descField = parseFrontmatter(text, "description");
|
|
590
|
-
const whenField = parseFrontmatter(text, "when");
|
|
591
|
-
if (!nameField) warnings.push(`${rel} missing required frontmatter field "name"`);
|
|
592
|
-
if (!descField) warnings.push(`${rel} missing required frontmatter field "description"`);
|
|
593
|
-
if (!whenField) warnings.push(`${rel} missing required frontmatter field "when"`);
|
|
594
|
-
if (nameField && nameField !== baseName) {
|
|
595
|
-
warnings.push(`${rel} name "${nameField}" does not match filename slug "${baseName}"`);
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// 14b. A skill nothing can TRIGGER is a file, not a memory (change
|
|
599
|
-
// 0029). `context` lists a skill's `when:` so an agent can fire
|
|
600
|
-
// the right one without loading any of them — but a trigger
|
|
601
|
-
// written as pure prose ("when it makes sense", "as needed")
|
|
602
|
-
// gives it nothing to match on, and the skill is never loaded by
|
|
603
|
-
// anyone who did not already know it existed. A usable trigger
|
|
604
|
-
// names something concrete: a keyword, a path, a command, an
|
|
605
|
-
// error string.
|
|
606
|
-
if (whenField && !hasDetectableTrigger(whenField)) {
|
|
607
|
-
warnings.push(
|
|
608
|
-
`${rel} frontmatter "when" has no detectable trigger — name a concrete ` +
|
|
609
|
-
`keyword, path, command or error string, or nothing can match it ` +
|
|
610
|
-
`(got: "${whenField.length > 60 ? `${whenField.slice(0, 59)}…` : whenField}")`,
|
|
611
|
-
);
|
|
612
|
-
}
|
|
613
|
-
const lines = lineCount(f);
|
|
614
|
-
if (lines > 200) warnings.push(`${rel} is ${lines} lines (>200 soft cap)`);
|
|
615
|
-
else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft cap)`);
|
|
616
|
-
|
|
617
|
-
// 15. Skill description drift — index.json must mirror the
|
|
618
|
-
// frontmatter description (run `doctrina skill sync`).
|
|
619
|
-
if (descField && index?.artifacts?.skills) {
|
|
620
|
-
const entry = index.artifacts.skills.find((s) => s.id === baseName);
|
|
621
|
-
if (entry && entry.description !== descField) {
|
|
622
|
-
warnings.push(`${rel} description differs from index.json (run \`doctrina skill sync\`)`);
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// 16. Bilingual docs parity. Projects that keep an EN-source docs tree
|
|
629
|
-
// with a PT mirror (docs/en + docs/pt) get filename-parity checking:
|
|
630
|
-
// a file added in one language and missing in the other is invisible
|
|
631
|
-
// to every other gate, and the mirror silently falls behind. Only the
|
|
632
|
-
// deterministic half (file sets) is checked — content staleness stays
|
|
633
|
-
// a human judgement. Projects without both trees never see this.
|
|
634
|
-
const docsEn = path.join(projectRoot, "docs", "en");
|
|
635
|
-
const docsPt = path.join(projectRoot, "docs", "pt");
|
|
636
|
-
if (isDir(docsEn) && isDir(docsPt)) {
|
|
637
|
-
const mdSet = (dir) => new Set(readdirSync(dir).filter((f) => f.endsWith(".md")));
|
|
638
|
-
const enSet = mdSet(docsEn);
|
|
639
|
-
const ptSet = mdSet(docsPt);
|
|
640
|
-
for (const f of [...enSet].sort()) {
|
|
641
|
-
if (!ptSet.has(f)) {
|
|
642
|
-
warnings.push(`docs/en/${f} has no docs/pt/${f} counterpart (EN is the source; the PT mirror fell behind)`);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
for (const f of [...ptSet].sort()) {
|
|
646
|
-
if (!enSet.has(f)) {
|
|
647
|
-
warnings.push(`docs/pt/${f} has no docs/en/${f} counterpart (PT translates an EN source, never leads it)`);
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
// Project rules — permanent, lintable constraints (.doctrina/rules.json;
|
|
653
|
-
// 0.11.0 field review item 12: "never reference company X" lived only in
|
|
654
|
-
// agent memory, and the remaining mentions sailed through every gate). Each
|
|
655
|
-
// rule is a forbid-regex over glob-scoped paths; a match is an ERROR with
|
|
656
|
-
// the rule's own message. Deterministic, zero-deps, and the instruction
|
|
657
|
-
// survives sessions because it is an artifact, not a memory.
|
|
658
|
-
for (const line of checkProjectRules(projectRoot)) errors.push(line);
|
|
659
|
-
|
|
660
|
-
// 18. --runtime folds the RUNTIME gate into the structural one, so a
|
|
661
|
-
// single call covers both halves of the truth: the shape of the
|
|
662
|
-
// artifacts, and whether what they declare about the running system
|
|
663
|
-
// still holds. Opt-in because it reads files outside .doctrina/
|
|
664
|
-
// (workflows, consumers, test sources) and a structural validate
|
|
665
|
-
// should stay cheap. Delegates to lib/runtime.js — the same checks
|
|
666
|
-
// `contract check`, `triage` and `doctor` render, so the four can
|
|
667
|
-
// never disagree.
|
|
668
|
-
if (flagBool(flags, "runtime", false)) {
|
|
669
|
-
for (const f of collectRuntimeFindings(projectRoot).findings) {
|
|
670
|
-
const line = `${f.contract}: ${f.message} [${f.code}] — ${f.remedy}`;
|
|
671
|
-
if (f.level === "error") errors.push(line);
|
|
672
|
-
else warnings.push(line);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
32
|
+
const { errors, warnings, fixes } = collectValidation(projectRoot, {
|
|
33
|
+
fix: flagBool(flags, "fix", false),
|
|
34
|
+
runtime: flagBool(flags, "runtime", false),
|
|
35
|
+
});
|
|
675
36
|
|
|
676
37
|
// Output
|
|
677
38
|
if (flagBool(flags, "json", false)) {
|
|
678
|
-
emitJson("validate", {
|
|
39
|
+
emitJson("validate", { errors, warnings });
|
|
679
40
|
return errors.length === 0 ? 0 : 1;
|
|
680
41
|
}
|
|
42
|
+
// The repairs print first and in the order they were made: --fix changes
|
|
43
|
+
// the tree, and what it changed must be visible before what it then found.
|
|
44
|
+
for (const f of fixes) console.log(c.green("fixed") + " " + f);
|
|
681
45
|
for (const w of warnings) console.log(c.yellow("warn: ") + w);
|
|
682
46
|
for (const e of errors) console.log(c.red("error: ") + e);
|
|
683
47
|
|
|
@@ -691,227 +55,6 @@ export async function run(_positional, flags) {
|
|
|
691
55
|
return errors.length === 0 ? 0 : 1;
|
|
692
56
|
}
|
|
693
57
|
|
|
694
|
-
// Does a skill's `when:` give anything to MATCH on? A trigger written as
|
|
695
|
-
// pure prose ("when it seems relevant", "as needed") reads fine and can
|
|
696
|
-
// never fire: `context` ranks skills by comparing the trigger against the
|
|
697
|
-
// task, and there is nothing there to compare. A usable trigger names
|
|
698
|
-
// something concrete — a path or glob, a command, a quoted error string, a
|
|
699
|
-
// file extension, an ALL_CAPS identifier, or simply enough distinctive
|
|
700
|
-
// keywords to match on.
|
|
701
|
-
const VAGUE_TRIGGER = /^(?:when(?:ever)?\s+)?(?:it|this|you|the agent)?\s*(?:is\s+)?(?:seems?|feels?|looks?)?\s*(?:relevant|appropriate|needed|necessary|useful|applicable|as needed|if needed)\.?$/i;
|
|
702
|
-
|
|
703
|
-
export function hasDetectableTrigger(when) {
|
|
704
|
-
const text = String(when ?? "").trim();
|
|
705
|
-
if (text === "" || VAGUE_TRIGGER.test(text)) return false;
|
|
706
|
-
// Anything structural is inherently matchable.
|
|
707
|
-
if (/[\/\]|\*|`|"|'|\.\w{2,4}|[A-Z][A-Z0-9_]{2,}/.test(text)) return true;
|
|
708
|
-
// Otherwise: enough distinctive words to rank on. Stopwords do not count.
|
|
709
|
-
const STOP = new Set(["when", "whenever", "the", "a", "an", "is", "are", "you", "your",
|
|
710
|
-
"it", "its", "this", "that", "and", "or", "to", "of", "in", "on", "for", "with",
|
|
711
|
-
"any", "some", "need", "needs", "needed", "should", "must", "at", "as", "by", "be"]);
|
|
712
|
-
const words = text.toLowerCase().match(/[a-z][a-z0-9-]{2,}/g) ?? [];
|
|
713
|
-
return words.filter((w) => !STOP.has(w)).length >= 2;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// Enforce .doctrina/rules.json — permanent project constraints as
|
|
717
|
-
// forbid-regexes over glob-scoped paths:
|
|
718
|
-
// { "rules": [ { "id": "white-label", "forbid": "\\bAcmeCorp\\b",
|
|
719
|
-
// "paths": ["src/**", ".doctrina/specs/**"],
|
|
720
|
-
// "message": "white-label product; use a generic placeholder" } ] }
|
|
721
|
-
// Returns error strings (one per offending file+rule, capped per rule so a
|
|
722
|
-
// mass violation stays readable). Missing/invalid file → no rules (an
|
|
723
|
-
// invalid JSON is reported once). Binary-ish and vendored dirs are skipped
|
|
724
|
-
// by the same bounded walk validate already uses elsewhere.
|
|
725
|
-
const RULES_SKIP_DIRS = new Set([
|
|
726
|
-
".git", "node_modules", "vendor", "dist", "build", "out", "target",
|
|
727
|
-
".venv", "venv", "__pycache__", ".next", "coverage",
|
|
728
|
-
]);
|
|
729
|
-
const RULES_MAX_HITS_PER_RULE = 10;
|
|
730
|
-
|
|
731
|
-
function checkProjectRules(projectRoot) {
|
|
732
|
-
const rulesPath = path.join(projectRoot, ".doctrina", "rules.json");
|
|
733
|
-
if (!isFile(rulesPath)) return [];
|
|
734
|
-
let cfg;
|
|
735
|
-
try {
|
|
736
|
-
cfg = JSON.parse(read(rulesPath));
|
|
737
|
-
} catch (err) {
|
|
738
|
-
return [`.doctrina/rules.json is not valid JSON: ${err.message}`];
|
|
739
|
-
}
|
|
740
|
-
const rules = Array.isArray(cfg?.rules) ? cfg.rules : [];
|
|
741
|
-
const out = [];
|
|
742
|
-
const compiled = [];
|
|
743
|
-
for (const r of rules) {
|
|
744
|
-
if (!r || typeof r.forbid !== "string" || !r.forbid) {
|
|
745
|
-
out.push(`.doctrina/rules.json: rule "${r?.id ?? "?"}" needs a non-empty "forbid" regex`);
|
|
746
|
-
continue;
|
|
747
|
-
}
|
|
748
|
-
let re;
|
|
749
|
-
try {
|
|
750
|
-
re = new RegExp(r.forbid, "m");
|
|
751
|
-
} catch (err) {
|
|
752
|
-
out.push(`.doctrina/rules.json: rule "${r.id ?? r.forbid}" has an invalid regex: ${err.message}`);
|
|
753
|
-
continue;
|
|
754
|
-
}
|
|
755
|
-
const paths = Array.isArray(r.paths) && r.paths.length ? r.paths : ["**"];
|
|
756
|
-
compiled.push({ id: r.id ?? r.forbid, re, matchers: paths.map(globToRegExp), message: r.message ?? "", hits: 0 });
|
|
757
|
-
}
|
|
758
|
-
if (compiled.length === 0) return out;
|
|
759
|
-
|
|
760
|
-
// One bounded walk; each text file is read at most once.
|
|
761
|
-
const stack = [projectRoot];
|
|
762
|
-
while (stack.length) {
|
|
763
|
-
const dir = stack.pop();
|
|
764
|
-
let entries;
|
|
765
|
-
try {
|
|
766
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
767
|
-
} catch {
|
|
768
|
-
continue;
|
|
769
|
-
}
|
|
770
|
-
for (const entry of entries) {
|
|
771
|
-
const full = path.join(dir, entry.name);
|
|
772
|
-
if (entry.isDirectory()) {
|
|
773
|
-
if (!RULES_SKIP_DIRS.has(entry.name)) stack.push(full);
|
|
774
|
-
continue;
|
|
775
|
-
}
|
|
776
|
-
const rel = relPath(projectRoot, full).replace(/\\/g, "/");
|
|
777
|
-
if (rel === ".doctrina/rules.json") continue; // the rule text itself always matches
|
|
778
|
-
const applicable = compiled.filter((cr) => cr.hits < RULES_MAX_HITS_PER_RULE && cr.matchers.some((m) => m.test(rel)));
|
|
779
|
-
if (applicable.length === 0) continue;
|
|
780
|
-
let text;
|
|
781
|
-
try {
|
|
782
|
-
text = read(full);
|
|
783
|
-
} catch {
|
|
784
|
-
continue;
|
|
785
|
-
}
|
|
786
|
-
for (const cr of applicable) {
|
|
787
|
-
const m = cr.re.exec(text);
|
|
788
|
-
if (m) {
|
|
789
|
-
cr.hits += 1;
|
|
790
|
-
const lineNo = text.slice(0, m.index).split("\n").length;
|
|
791
|
-
out.push(`rule "${cr.id}": ${rel}:${lineNo} matches forbidden pattern${cr.message ? ` — ${cr.message}` : ""}`);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
for (const cr of compiled) {
|
|
797
|
-
if (cr.hits >= RULES_MAX_HITS_PER_RULE) {
|
|
798
|
-
out.push(`rule "${cr.id}": more matches suppressed after ${RULES_MAX_HITS_PER_RULE} files`);
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
return out;
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
// Minimal glob → RegExp: ** crosses directories, * stays within a segment.
|
|
805
|
-
// Anchored to the whole repo-relative POSIX path.
|
|
806
|
-
function globToRegExp(glob) {
|
|
807
|
-
const esc = String(glob).replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
808
|
-
const body = esc
|
|
809
|
-
.replace(/\*\*\//g, "(?:.*/)?")
|
|
810
|
-
.replace(/\*\*/g, ".*")
|
|
811
|
-
.replace(/\*/g, "[^/]*")
|
|
812
|
-
.replace(/\?/g, "[^/]");
|
|
813
|
-
return new RegExp(`^${body}$`);
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
// Per-entry metadata drift between the on-disk index and the tree-derived
|
|
817
|
-
// one, for entries present in BOTH. Presence drift (an artifact on disk but
|
|
818
|
-
// not indexed, or indexed but missing) is left to the orphan warning and the
|
|
819
|
-
// missing-artifact error; this isolates the case the review hit — an indexed
|
|
820
|
-
// artifact whose recorded metadata no longer matches its file.
|
|
821
|
-
function metadataDrift(current, derived) {
|
|
822
|
-
const lines = [];
|
|
823
|
-
// Skills are deliberately excluded: their description has a dedicated
|
|
824
|
-
// reconciliation command (`doctrina skill sync`) and stays an advisory
|
|
825
|
-
// warning (see section 15), not a hard error.
|
|
826
|
-
const cats = ["specs", "decisions", "changes", "changes_archive", "contracts"];
|
|
827
|
-
for (const cat of cats) {
|
|
828
|
-
const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
829
|
-
const der = new Map((derived.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
830
|
-
for (const [id, entry] of cur) {
|
|
831
|
-
if (der.has(id) && stableStringify(entry) !== stableStringify(der.get(id))) {
|
|
832
|
-
lines.push(`index.json: ${cat} "${id}" metadata differs from its file`);
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
return lines;
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
// Find AGENTS.md files below the root (the "nearest AGENTS.md wins"
|
|
840
|
-
// hierarchy). Bounded walk: dependency, build, and VCS directories are
|
|
841
|
-
// skipped so validate stays fast on large repositories.
|
|
842
|
-
const NESTED_SKIP_DIRS = new Set([
|
|
843
|
-
".git", "node_modules", ".doctrina", "vendor", "dist", "build", "out",
|
|
844
|
-
"target", ".venv", "venv", "__pycache__", ".next", "coverage",
|
|
845
|
-
]);
|
|
846
|
-
|
|
847
|
-
function findNestedAgentsMd(projectRoot) {
|
|
848
|
-
const found = [];
|
|
849
|
-
const stack = readdirSync(projectRoot)
|
|
850
|
-
.filter((e) => !NESTED_SKIP_DIRS.has(e) && !e.startsWith("."))
|
|
851
|
-
.map((e) => path.join(projectRoot, e))
|
|
852
|
-
.filter(isDir);
|
|
853
|
-
while (stack.length) {
|
|
854
|
-
const dir = stack.pop();
|
|
855
|
-
for (const entry of readdirSync(dir)) {
|
|
856
|
-
const full = path.join(dir, entry);
|
|
857
|
-
if (isDir(full)) {
|
|
858
|
-
if (!NESTED_SKIP_DIRS.has(entry) && !entry.startsWith(".")) stack.push(full);
|
|
859
|
-
} else if (entry === "AGENTS.md") {
|
|
860
|
-
found.push(full);
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
return found.sort();
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
// Extract candidate file path references from Markdown text. Only
|
|
868
|
-
// Markdown link targets `[text](path)` are checked — backtick spans
|
|
869
|
-
// are descriptive prose and produce too many false positives.
|
|
870
|
-
function extractPathReferences(text) {
|
|
871
|
-
const out = new Set();
|
|
872
|
-
for (const m of text.matchAll(/\]\(([^)\s]+)\)/g)) {
|
|
873
|
-
const token = m[1].trim();
|
|
874
|
-
if (isLikelyPath(token)) out.add(stripFragment(token));
|
|
875
|
-
}
|
|
876
|
-
return [...out];
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
// Path-like tokens inside backtick spans — the convention for citing
|
|
880
|
-
// evidence (ADR **Evidence:**, acceptance-criteria links).
|
|
881
|
-
function extractBacktickPaths(text) {
|
|
882
|
-
const out = new Set();
|
|
883
|
-
for (const m of text.matchAll(/`([^`]+)`/g)) {
|
|
884
|
-
const token = m[1].trim();
|
|
885
|
-
if (isLikelyPath(token)) out.add(stripFragment(token));
|
|
886
|
-
}
|
|
887
|
-
return [...out];
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
function stripFragment(s) {
|
|
891
|
-
// Remove an in-page anchor (#section) or query string from a path token.
|
|
892
|
-
return s.split(/[#?]/)[0];
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
function isLikelyPath(s) {
|
|
896
|
-
if (!s) return false;
|
|
897
|
-
if (/^(https?:|mailto:|ftp:|#|@)/i.test(s)) return false;
|
|
898
|
-
if (s.includes("{{") || s.includes("}}")) return false;
|
|
899
|
-
if (s.includes("*") || s.includes("?")) return false;
|
|
900
|
-
if (s.includes("<") || s.includes(">")) return false;
|
|
901
|
-
if (s.includes(" ") || s.includes("`")) return false;
|
|
902
|
-
// Strip in-page anchor or query string before further checks
|
|
903
|
-
const head = stripFragment(s);
|
|
904
|
-
if (!head) return false;
|
|
905
|
-
// Folder references (path ending with /) are skipped: not file targets
|
|
906
|
-
if (head.endsWith("/")) return false;
|
|
907
|
-
// Must look like a file path: contains a slash or has a file extension
|
|
908
|
-
const hasSlash = head.includes("/");
|
|
909
|
-
const hasExt = /\.[a-z0-9]{1,8}$/i.test(head);
|
|
910
|
-
if (!hasSlash && !hasExt) return false;
|
|
911
|
-
if (head.startsWith("-")) return false;
|
|
912
|
-
return true;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
58
|
export const help = `
|
|
916
59
|
Usage: doctrina validate [--fix] [--runtime] [--json]
|
|
917
60
|
|