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
|
@@ -0,0 +1,1299 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The structural gate's checks, as a collection rather than a printout.
|
|
3
|
+
//
|
|
4
|
+
// `validate` renders these; `doctor` reports them as one row of its
|
|
5
|
+
// diagnostic. Before this they were one function inside the command module,
|
|
6
|
+
// so the only way for another command to ask "does the tree validate?" was
|
|
7
|
+
// to spawn the CLI again and parse its own JSON back — two integration
|
|
8
|
+
// styles in one binary, and a "did not produce a report" failure path that
|
|
9
|
+
// existed only because of the choice (audit finding F4). ADR 0025: command
|
|
10
|
+
// modules render, shared logic lives here.
|
|
11
|
+
//
|
|
12
|
+
// The one impure part is deliberate: with `fix: true` the collection REPAIRS
|
|
13
|
+
// what it can (headers, a drifted index) and returns what it repaired, so the
|
|
14
|
+
// caller prints it. A collector that silently wrote files while pretending to
|
|
15
|
+
// be a read would be worse than one that says so in its options.
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { readdirSync } from "node:fs";
|
|
18
|
+
import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "./fs-ops.js";
|
|
19
|
+
import * as idx from "./index-json.js";
|
|
20
|
+
import { SCHEMA_VERSION } from "./index-json.js";
|
|
21
|
+
import { cliVersion, newestVersion } from "./version.js";
|
|
22
|
+
import { today } from "./dates.js";
|
|
23
|
+
import { checklistProgress, kindFromPath, maskComments, nonConformingHeaders, repairHeaders, parseFrontmatter, isPlaceholderHeaderValue } from "./doc-model.js";
|
|
24
|
+
import { checkEars, isEarsSpec } from "./ears.js";
|
|
25
|
+
import { parseAdrScope, parseSourceGlobs, parseDependsOn, specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "./scan.js";
|
|
26
|
+
import { COMMAND_NAMES, referencedCommands, DEPRECATED } from "./commands.js";
|
|
27
|
+
import { parseAcceptanceCriteria, isVerified } from "./criteria.js";
|
|
28
|
+
import { parsePipeline, checkPipeline } from "./pipeline.js";
|
|
29
|
+
import { declaredBudget, collectRuntimeFindings, filesMatching } from "./runtime.js";
|
|
30
|
+
import { derivedImplementations, implementationMismatch } from "./coverage-model.js";
|
|
31
|
+
import { headerValueError, criterionMarkError } from "./spec-ops.js";
|
|
32
|
+
import { readLedger, ledgerPath as ledgerFile } from "./ledger.js";
|
|
33
|
+
import { loadConfig, SOURCES, CONFIG_REL, RULES_REL } from "./config.js";
|
|
34
|
+
|
|
35
|
+
// AGENTS.md is treated as a maintained doctrina-command catalog only once it
|
|
36
|
+
// documents at least this many real commands; below it, the file defers to
|
|
37
|
+
// `doctrina --help` and is never nagged about omissions.
|
|
38
|
+
const CATALOG_THRESHOLD = 8;
|
|
39
|
+
|
|
40
|
+
// The shipped ceiling for a project that declares no `agents-md-lines` budget.
|
|
41
|
+
// AGENTS.md is loaded into EVERY session, so its size is a tax on all work.
|
|
42
|
+
export const AGENTS_MD_SOFT_LIMIT = 150;
|
|
43
|
+
export const AGENTS_MD_HARD_MARGIN = 50;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The AGENTS.md size budget: what the project declares, what the file spends,
|
|
47
|
+
* and what is left.
|
|
48
|
+
*
|
|
49
|
+
* The ceiling had two homes — a literal here and the `agents-md-lines` row of
|
|
50
|
+
* the contract's Budgets table — which is how every count in this repository
|
|
51
|
+
* has ever drifted (change 0059). The contract is the declaration, so it wins;
|
|
52
|
+
* the literal below is the fallback for a project that declares nothing.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} projectRoot
|
|
55
|
+
* @returns {{ used: number, soft: number, hard: number, slack: number, declared: boolean }}
|
|
56
|
+
*/
|
|
57
|
+
export function agentsMdBudget(projectRoot) {
|
|
58
|
+
const file = path.join(projectRoot, "AGENTS.md");
|
|
59
|
+
const used = isFile(file) ? lineCount(file) : 0;
|
|
60
|
+
const { value: soft, declared } = declaredBudget(projectRoot, "agents-md-lines", AGENTS_MD_SOFT_LIMIT);
|
|
61
|
+
return { used, soft, hard: soft + AGENTS_MD_HARD_MARGIN, slack: soft - used, declared };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run every structural check over the tree.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} projectRoot
|
|
69
|
+
* @param {{fix?: boolean, runtime?: boolean}} [options]
|
|
70
|
+
* fix — repair what is mechanically repairable before reporting;
|
|
71
|
+
* runtime — also check the declared runtime surface (ADR 0023).
|
|
72
|
+
* @returns {{errors: string[], warnings: string[], fixes: string[]}}
|
|
73
|
+
*/
|
|
74
|
+
export function collectValidation(projectRoot, { fix = false, runtime = false } = {}) {
|
|
75
|
+
const fixes = [];
|
|
76
|
+
const errors = [];
|
|
77
|
+
const warnings = [];
|
|
78
|
+
|
|
79
|
+
// 1. AGENTS.md
|
|
80
|
+
const agentsMd = path.join(projectRoot, "AGENTS.md");
|
|
81
|
+
if (!isFile(agentsMd)) {
|
|
82
|
+
errors.push("AGENTS.md missing at project root");
|
|
83
|
+
} else {
|
|
84
|
+
const budget = agentsMdBudget(projectRoot);
|
|
85
|
+
const lines = budget.used;
|
|
86
|
+
if (lines > budget.hard) errors.push(`AGENTS.md is ${lines} lines (>${budget.hard}, hard limit)`);
|
|
87
|
+
else if (lines > budget.soft) warnings.push(`AGENTS.md is ${lines} lines (>${budget.soft} soft limit)`);
|
|
88
|
+
|
|
89
|
+
// 1c. AGENTS.md command-surface drift. AGENTS.md is the hub the agent reads
|
|
90
|
+
// first, so the doctrina commands it documents must match the real CLI.
|
|
91
|
+
// Two drifts are flagged: a reference to a command that does not exist
|
|
92
|
+
// (a typo or a removed command — the agent will try to run it and fail),
|
|
93
|
+
// and a maintained catalog that has fallen behind the CLI (commands the
|
|
94
|
+
// agent never discovers because the hub never names them). validate only
|
|
95
|
+
// checked AGENTS.md *size* before, so the hub could rot while staying
|
|
96
|
+
// green; this closes that gap.
|
|
97
|
+
const agentsText = read(agentsMd);
|
|
98
|
+
const referenced = referencedCommands(agentsText);
|
|
99
|
+
const known = new Set(COMMAND_NAMES);
|
|
100
|
+
// Reverse drift is always flagged — a dangling command reference misleads
|
|
101
|
+
// the agent regardless of how the file is structured.
|
|
102
|
+
for (const cmd of [...referenced].sort()) {
|
|
103
|
+
if (!known.has(cmd)) {
|
|
104
|
+
warnings.push(
|
|
105
|
+
`AGENTS.md references \`doctrina ${cmd}\` which is not a CLI command ` +
|
|
106
|
+
`(typo or removed command — agents reading the hub will try to run it)`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Forward drift (omitted commands) is flagged only when the file presents
|
|
111
|
+
// itself as an exhaustive catalog: it documents many commands AND does not
|
|
112
|
+
// defer to `doctrina --help`. A file that defers is declaring its list
|
|
113
|
+
// illustrative (the shipped template does this), so its omissions are
|
|
114
|
+
// intentional and stay silent.
|
|
115
|
+
const documented = [...referenced].filter((cmd) => known.has(cmd));
|
|
116
|
+
const defersToHelp = /doctrina(?:-cli)?\s+--help/.test(agentsText);
|
|
117
|
+
if (documented.length >= CATALOG_THRESHOLD && !defersToHelp) {
|
|
118
|
+
// A DEPRECATED command is absent from the generated block on purpose
|
|
119
|
+
// (change 0049): the block lists what to reach for, and a name that
|
|
120
|
+
// warns when used is not that. Counting it as a gap would make the
|
|
121
|
+
// deprecation permanently red.
|
|
122
|
+
const missing = COMMAND_NAMES.filter((cmd) => !referenced.has(cmd) && !DEPRECATED[cmd]);
|
|
123
|
+
if (missing.length > 0) {
|
|
124
|
+
warnings.push(
|
|
125
|
+
`AGENTS.md documents the doctrina command surface but omits ` +
|
|
126
|
+
`${missing.length} command${missing.length === 1 ? "" : "s"} ` +
|
|
127
|
+
`(${missing.join(", ")}) — agents reading the hub will not discover ` +
|
|
128
|
+
`them (add them, or defer to \`doctrina --help\` for the full list)`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 1b. Nested AGENTS.md files ("nearest wins" hierarchy) obey the same
|
|
135
|
+
// size caps as the root file.
|
|
136
|
+
for (const nested of findNestedAgentsMd(projectRoot)) {
|
|
137
|
+
const rel = relPath(projectRoot, nested);
|
|
138
|
+
const lines = lineCount(nested);
|
|
139
|
+
if (lines > 200) errors.push(`${rel} is ${lines} lines (>200, hard limit)`);
|
|
140
|
+
else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft limit)`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 2. product.md
|
|
144
|
+
const productMd = path.join(projectRoot, ".doctrina", "product.md");
|
|
145
|
+
if (!isFile(productMd)) errors.push(".doctrina/product.md missing");
|
|
146
|
+
|
|
147
|
+
// Header repair (M3). One grammar means a non-canonical header can be
|
|
148
|
+
// REWRITTEN, not just reported: `- **Status**: x` becomes
|
|
149
|
+
// `- **Status:** x`, and a spec written with list-style headers is
|
|
150
|
+
// normalised to the bare-bold form its kind uses. Content is never
|
|
151
|
+
// touched — only the header's own punctuation and style.
|
|
152
|
+
const doFix = fix;
|
|
153
|
+
{
|
|
154
|
+
const artifacts = [];
|
|
155
|
+
const dot = path.join(projectRoot, ".doctrina");
|
|
156
|
+
for (const f of walk(dot)) {
|
|
157
|
+
if (!f.endsWith(".md")) continue;
|
|
158
|
+
const rel = relPath(projectRoot, f).replace(/\\/g, "/");
|
|
159
|
+
if (rel.includes("/changes/archive/")) continue; // history is immutable
|
|
160
|
+
artifacts.push({ file: f, rel, kind: kindFromPath(rel) });
|
|
161
|
+
}
|
|
162
|
+
let repairedFiles = 0;
|
|
163
|
+
for (const a of artifacts) {
|
|
164
|
+
const text = read(a.file);
|
|
165
|
+
const bad = nonConformingHeaders(text, a.kind);
|
|
166
|
+
if (bad.length === 0) continue;
|
|
167
|
+
if (doFix) {
|
|
168
|
+
const { text: fixed, repaired } = repairHeaders(text, a.kind);
|
|
169
|
+
if (repaired > 0 && fixed !== text) {
|
|
170
|
+
write(a.file, fixed, { force: true });
|
|
171
|
+
repairedFiles += 1;
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
for (const h of bad) {
|
|
175
|
+
warnings.push(
|
|
176
|
+
`${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\``,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (doFix && repairedFiles > 0) {
|
|
182
|
+
fixes.push(`normalised headers in ${repairedFiles} artifact${repairedFiles === 1 ? "" : "s"}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 3. index.json
|
|
187
|
+
let index = null;
|
|
188
|
+
try {
|
|
189
|
+
index = idx.load(projectRoot);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
errors.push(err.message);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (index) {
|
|
195
|
+
// --fix (F5): regenerate the index from the tree before validating, so a
|
|
196
|
+
// drifted index is repaired rather than reported. A full rebuild — it also
|
|
197
|
+
// absorbs orphans and migrates the framework stamp, matching
|
|
198
|
+
// `index rebuild`. Subsequent checks then see the repaired index.
|
|
199
|
+
if (fix) {
|
|
200
|
+
const derived = deriveIndex(projectRoot, index);
|
|
201
|
+
// Never rewound (change 0116): a stamp AHEAD of this CLI is a tree
|
|
202
|
+
// managed by a newer release, and the remedy is to upgrade the CLI,
|
|
203
|
+
// not to rewrite the stamp backwards on every commit.
|
|
204
|
+
const stamp = newestVersion(index.framework_version, cliVersion());
|
|
205
|
+
const staleStamp = (index.framework_version ?? null) !== stamp;
|
|
206
|
+
if (!indexesMatch(derived, index) || staleStamp) {
|
|
207
|
+
derived.framework_version = stamp;
|
|
208
|
+
derived.last_updated = today();
|
|
209
|
+
idx.save(projectRoot, derived);
|
|
210
|
+
fixes.push("rebuilt .doctrina/index.json from the tree");
|
|
211
|
+
index = derived;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (index.$schema_version !== SCHEMA_VERSION) {
|
|
216
|
+
warnings.push(`index.json $schema_version is "${index.$schema_version}" (expected "${SCHEMA_VERSION}")`);
|
|
217
|
+
}
|
|
218
|
+
// framework_version stamp divergence (3.6). The index records which CLI
|
|
219
|
+
// manages it; a stamp behind (or absent vs.) the running CLI means the
|
|
220
|
+
// tree may predate the current schema. `index rebuild` migrates it.
|
|
221
|
+
const runningVersion = cliVersion();
|
|
222
|
+
if (!index.framework_version) {
|
|
223
|
+
warnings.push(`index.json has no framework_version (run \`doctrina index rebuild\` to stamp it)`);
|
|
224
|
+
} else if (newestVersion(index.framework_version, runningVersion) !== runningVersion) {
|
|
225
|
+
warnings.push(
|
|
226
|
+
`index.json framework_version is "${index.framework_version}", ahead of the running CLI ` +
|
|
227
|
+
`${runningVersion} — this tree is managed by a newer release; upgrade the CLI (the stamp is never rewound)`,
|
|
228
|
+
);
|
|
229
|
+
} else if (index.framework_version !== runningVersion) {
|
|
230
|
+
warnings.push(
|
|
231
|
+
`index.json framework_version is "${index.framework_version}" but the running CLI is ` +
|
|
232
|
+
`${runningVersion} (run \`doctrina index rebuild\` to stamp/migrate)`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (!index.artifacts) {
|
|
236
|
+
errors.push("index.json missing artifacts object");
|
|
237
|
+
} else {
|
|
238
|
+
// 4. Every referenced artifact path must exist
|
|
239
|
+
const allArtifactPaths = [];
|
|
240
|
+
if (index.artifacts.product?.path) allArtifactPaths.push(index.artifacts.product.path);
|
|
241
|
+
for (const s of index.artifacts.specs ?? []) allArtifactPaths.push(s.path);
|
|
242
|
+
for (const d of index.artifacts.decisions ?? []) allArtifactPaths.push(d.path);
|
|
243
|
+
for (const ch of index.artifacts.changes ?? []) allArtifactPaths.push(ch.path);
|
|
244
|
+
for (const ch of index.artifacts.changes_archive ?? []) allArtifactPaths.push(ch.path);
|
|
245
|
+
for (const ct of index.artifacts.contracts ?? []) allArtifactPaths.push(ct.path);
|
|
246
|
+
for (const rel of allArtifactPaths) {
|
|
247
|
+
const full = path.join(projectRoot, rel);
|
|
248
|
+
if (!exists(full)) errors.push(`index.json references missing artifact: ${rel}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 4b. Index metadata drift (F5 / review G5 / G7). validate is the single
|
|
252
|
+
// source of truth about whether the project is OK, so an indexed
|
|
253
|
+
// artifact whose recorded metadata (version, implementation, status,
|
|
254
|
+
// ...) no longer matches its file is an ERROR here — not a
|
|
255
|
+
// pass-with-warning that only `index rebuild --check` would catch
|
|
256
|
+
// (the "validate says OK while next says drift" footgun). Presence
|
|
257
|
+
// drift (orphan on disk / missing file) stays the orphan warning and
|
|
258
|
+
// the missing-artifact error above; this targets the metadata that
|
|
259
|
+
// silently lies. `validate --fix` (above) regenerates instead.
|
|
260
|
+
const derived = deriveIndex(projectRoot, index);
|
|
261
|
+
for (const line of metadataDrift(index, derived)) {
|
|
262
|
+
errors.push(`${line} — run \`doctrina validate --fix\` or \`doctrina index rebuild\``);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 5. ADRs have parseable Status: header
|
|
268
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
269
|
+
// Track which files claim each NNNN so a merge-time collision (two branches
|
|
270
|
+
// both allocating the same number) becomes a loud error rather than one ADR
|
|
271
|
+
// silently shadowing the other (C2).
|
|
272
|
+
const adrByNumber = new Map();
|
|
273
|
+
if (isDir(adrDir)) {
|
|
274
|
+
for (const f of walk(adrDir)) {
|
|
275
|
+
if (!f.endsWith(".md")) continue;
|
|
276
|
+
const base = path.basename(f);
|
|
277
|
+
const numMatch = base.match(/^(\d{4})-/);
|
|
278
|
+
const num = numMatch ? numMatch[1] : null;
|
|
279
|
+
if (num) {
|
|
280
|
+
if (!adrByNumber.has(num)) adrByNumber.set(num, []);
|
|
281
|
+
adrByNumber.get(num).push(relPath(projectRoot, f));
|
|
282
|
+
}
|
|
283
|
+
// ADR filenames must be NNNN-slug.md (four digits). The entire ADR
|
|
284
|
+
// toolchain — decision new/accept/supersede, index derivation, and
|
|
285
|
+
// the orphan check below — keys off this shape; a file like
|
|
286
|
+
// "ADR-001-foo.md" is silently invisible to all of them.
|
|
287
|
+
if (base !== "README.md" && !/^\d{4}-.+\.md$/.test(base)) {
|
|
288
|
+
errors.push(
|
|
289
|
+
`${relPath(projectRoot, f)} has a non-canonical ADR filename ` +
|
|
290
|
+
`(expected four digits + slug, e.g. "0001-jwt-algorithm.md"; ` +
|
|
291
|
+
`the decision and index commands silently ignore other shapes)`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const text = read(f);
|
|
295
|
+
if (!/^-\s+\*\*Status:\*\*\s+\S+/m.test(text)) {
|
|
296
|
+
errors.push(
|
|
297
|
+
`${relPath(projectRoot, f)} missing or malformed Status: header ` +
|
|
298
|
+
`(expected a Markdown list item exactly like "- **Status:** accepted")`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 5b. ADR evidence drift. A decision is only true if reality reflects
|
|
303
|
+
// it; an accepted ADR that points at nothing — or at a file that
|
|
304
|
+
// no longer exists — is the classic "ADR decrees X, the code
|
|
305
|
+
// never did X". Two header fields anchor a decision to reality:
|
|
306
|
+
// **Evidence:** (cited at any point) and **Landed:** (the
|
|
307
|
+
// non-mutating "this is now implemented and verified here" stamp,
|
|
308
|
+
// written by `doctrina decision land`; 3.4). Both are checked for
|
|
309
|
+
// dangling citations; an accepted ADR is only flagged bare when
|
|
310
|
+
// BOTH are empty. ADRs that adopt neither header opt out entirely.
|
|
311
|
+
const evidence = listHeader(text, "Evidence");
|
|
312
|
+
const landed = listHeader(text, "Landed");
|
|
313
|
+
const isBare = (v) => {
|
|
314
|
+
const t = (v ?? "").trim();
|
|
315
|
+
return t === "" || t === "—" || t === "-";
|
|
316
|
+
};
|
|
317
|
+
for (const [field, value] of [["Evidence", evidence], ["Landed", landed]]) {
|
|
318
|
+
if (value === null || isBare(value)) continue;
|
|
319
|
+
for (const token of extractBacktickPaths(value)) {
|
|
320
|
+
const candidates = [path.resolve(path.dirname(f), token), path.resolve(projectRoot, token)];
|
|
321
|
+
if (!candidates.some(exists)) {
|
|
322
|
+
warnings.push(
|
|
323
|
+
`${relPath(projectRoot, f)}: ${field} cites \`${token}\` which is missing on disk — ` +
|
|
324
|
+
`the decision may have drifted from the code (update it or supersede the ADR)`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const adrStatus = (listHeader(text, "Status") ?? "").toLowerCase();
|
|
330
|
+
if (adrStatus === "accepted" && evidence !== null && isBare(evidence) && isBare(landed)) {
|
|
331
|
+
warnings.push(
|
|
332
|
+
`${relPath(projectRoot, f)}: accepted ADR cites no implementation evidence ` +
|
|
333
|
+
`(link the file(s) that prove it, run \`doctrina decision land ${num ?? "NNNN"}\` once ` +
|
|
334
|
+
`it ships, or note "n/a — <why>"); a decision with nothing behind it may be drift ` +
|
|
335
|
+
`waiting to be superseded`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 5c. Duplicate ADR numbers (C2).
|
|
341
|
+
for (const [num, files] of adrByNumber) {
|
|
342
|
+
if (files.length > 1) {
|
|
343
|
+
errors.push(
|
|
344
|
+
`ADR number ${num} is claimed by ${files.length} files (${files.join(", ")}) — ` +
|
|
345
|
+
`two branches likely allocated it concurrently; renumber all but one ` +
|
|
346
|
+
`(the index keys decisions by number, so duplicates silently shadow each other)`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 5d. Scope drift: a spec cites an ADR that does not name it (change 0075).
|
|
353
|
+
//
|
|
354
|
+
// `Scope:` decides which context packs an ADR loads into. A spec that
|
|
355
|
+
// CITES an ADR has declared it load-bearing for that capability — so if
|
|
356
|
+
// the ADR's scope omits the capability, the pack gets it only by
|
|
357
|
+
// dependency inheritance, and inheritance is the first tier the budget
|
|
358
|
+
// fitter drops. The ADRs a capability most needs are then the ones it
|
|
359
|
+
// loses first, silently.
|
|
360
|
+
//
|
|
361
|
+
// Splitting a spec is what produces this: change 0054 created
|
|
362
|
+
// `authoring` out of `cli` and left seven cited ADRs pointing at `cli`;
|
|
363
|
+
// the same drift reached `insight` and `scaffolding` from earlier
|
|
364
|
+
// splits. Nothing reported it, because nothing compared the two.
|
|
365
|
+
//
|
|
366
|
+
// A global (unscoped) ADR loads everywhere and is never a violation.
|
|
367
|
+
const specsDirForScope = path.join(projectRoot, ".doctrina", "specs");
|
|
368
|
+
if (isDir(specsDirForScope)) {
|
|
369
|
+
/** @type {Map<string, string[]|null>} */
|
|
370
|
+
const adrScopes = new Map();
|
|
371
|
+
const decisionsDirForScope = path.join(projectRoot, ".doctrina", "decisions");
|
|
372
|
+
if (isDir(decisionsDirForScope)) {
|
|
373
|
+
for (const f of walk(decisionsDirForScope)) {
|
|
374
|
+
const num = /(\d{4})-/.exec(path.basename(f))?.[1];
|
|
375
|
+
if (!num || !f.endsWith(".md")) continue;
|
|
376
|
+
const text = read(f);
|
|
377
|
+
if ((listHeader(text, "Status") ?? "").toLowerCase() !== "accepted") continue;
|
|
378
|
+
const scope = parseAdrScope(text);
|
|
379
|
+
adrScopes.set(num, scope.length > 0 ? scope : null);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
for (const capDir of readdirSync(specsDirForScope, { withFileTypes: true })) {
|
|
383
|
+
if (!capDir.isDirectory()) continue;
|
|
384
|
+
const cap = capDir.name;
|
|
385
|
+
const specFile = path.join(specsDirForScope, cap, "spec.md");
|
|
386
|
+
if (!isFile(specFile)) continue;
|
|
387
|
+
const cited = new Set((read(specFile).match(/ADR (\d{4})/g) ?? []).map((m) => m.slice(4)));
|
|
388
|
+
for (const num of [...cited].sort()) {
|
|
389
|
+
const scope = adrScopes.get(num);
|
|
390
|
+
if (scope === undefined || scope === null) continue; // unknown or global
|
|
391
|
+
if (scope.includes(cap)) continue;
|
|
392
|
+
warnings.push(
|
|
393
|
+
`.doctrina/specs/${cap}/spec.md cites ADR ${num}, but that ADR's Scope: ` +
|
|
394
|
+
`(${scope.join(", ")}) does not name "${cap}" — the pack for ${cap} receives it ` +
|
|
395
|
+
`only by dependency, which is the first thing the context budget drops. ` +
|
|
396
|
+
`Add ${cap} to the ADR's Scope: header, or stop citing it here`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 6. Adapter templates under 30 lines
|
|
403
|
+
const adaptersDir = path.join(projectRoot, ".doctrina", "templates", "adapters");
|
|
404
|
+
if (isDir(adaptersDir)) {
|
|
405
|
+
for (const f of walk(adaptersDir)) {
|
|
406
|
+
if (!f.endsWith(".template")) continue;
|
|
407
|
+
const lines = lineCount(f);
|
|
408
|
+
if (lines > 30) errors.push(`${relPath(projectRoot, f)} is ${lines} lines (>30, adapter cap)`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 7. Every open change has a proposal, and its delta files carry a
|
|
413
|
+
// well-formed **Operation:** header. The delta used to be the one
|
|
414
|
+
// 100% hand-authored file with no early check: a missing header passed
|
|
415
|
+
// silently at creation and exploded days later in the closing analyze
|
|
416
|
+
// (operator review 2026-07-19 §3.2). Warning-level — the change is
|
|
417
|
+
// still a draft — but now every `validate` run names it.
|
|
418
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
419
|
+
if (isDir(changesDir)) {
|
|
420
|
+
for (const entry of readdirSync(changesDir)) {
|
|
421
|
+
if (entry === "archive" || entry.startsWith(".")) continue;
|
|
422
|
+
const proposal = path.join(changesDir, entry, "proposal.md");
|
|
423
|
+
if (!isFile(proposal)) {
|
|
424
|
+
errors.push(
|
|
425
|
+
`open change "${entry}" missing proposal.md ` +
|
|
426
|
+
`(every dir under .doctrina/changes/ needs a proposal.md — that exact filename)`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
// A change whose tasks are still the scaffold placeholders was opened
|
|
430
|
+
// but never planned — the agent is (or will be) implementing with no
|
|
431
|
+
// recorded plan. Early, every-run signal; analyze/close hard-fail it.
|
|
432
|
+
const tasksPath = path.join(changesDir, entry, "tasks.md");
|
|
433
|
+
if (isFile(tasksPath)) {
|
|
434
|
+
const ph = checklistProgress(read(tasksPath)).placeholders;
|
|
435
|
+
if (ph > 0) {
|
|
436
|
+
warnings.push(
|
|
437
|
+
`open change "${entry}" tasks.md still carries ${ph} scaffold placeholder task${ph === 1 ? "" : "s"} — ` +
|
|
438
|
+
`the change was opened but never planned (replace them with real tasks; analyze/close refuse them)`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// A ghost in `Affects specs:` (change 0108). The header names the
|
|
443
|
+
// capabilities a change touches, and a name with no spec behind it
|
|
444
|
+
// — a typo, or a capability that was never created — passed
|
|
445
|
+
// analyze, check and close in silence. A capability this change is
|
|
446
|
+
// about to CREATE (an ADDED delta) is not a ghost.
|
|
447
|
+
if (isFile(proposal)) {
|
|
448
|
+
const affects = (listHeader(read(proposal), "Affects specs") ?? "")
|
|
449
|
+
.match(/[a-z][a-z0-9-]*/g) ?? [];
|
|
450
|
+
const specsRoot = path.join(projectRoot, ".doctrina", "specs");
|
|
451
|
+
for (const cap of affects) {
|
|
452
|
+
if (isFile(path.join(specsRoot, cap, "spec.md"))) continue;
|
|
453
|
+
if (isFile(path.join(changesDir, entry, "specs", cap, "delta.md"))) continue;
|
|
454
|
+
warnings.push(
|
|
455
|
+
`open change "${entry}" Affects specs: names "${cap}", which has no spec and no delta ` +
|
|
456
|
+
`in this change — correct the name, or \`doctrina spec new ${cap}\``,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
for (const deltaPath of walk(path.join(changesDir, entry, "specs"))) {
|
|
461
|
+
if (!deltaPath.endsWith("delta.md")) continue;
|
|
462
|
+
const text = read(deltaPath);
|
|
463
|
+
const m = text.match(/^\*\*Operation:\*\*\s*(\S+)/m);
|
|
464
|
+
if (!m) {
|
|
465
|
+
warnings.push(
|
|
466
|
+
`${relPath(projectRoot, deltaPath)} has no **Operation:** header — ` +
|
|
467
|
+
`analyze/apply will refuse it at close time (add "**Operation:** ADDED|MODIFIED|REMOVED")`,
|
|
468
|
+
);
|
|
469
|
+
} else if (!["ADDED", "MODIFIED", "REMOVED"].includes(m[1])) {
|
|
470
|
+
warnings.push(
|
|
471
|
+
`${relPath(projectRoot, deltaPath)} Operation "${m[1]}" is not ADDED|MODIFIED|REMOVED — ` +
|
|
472
|
+
`analyze/apply will refuse it at close time`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// 8. Specs over 400 lines warn (lost-in-the-middle).
|
|
480
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
481
|
+
if (isDir(specsDir)) {
|
|
482
|
+
const KNOWN_SPEC_HEADERS = ["Capability", "Status", "Implementation", "Version", "Last updated", "Realizes"];
|
|
483
|
+
// The coverage arithmetic, read once for the whole tree: every spec's
|
|
484
|
+
// Implementation header is checked against it below (8d).
|
|
485
|
+
const derived = derivedImplementations(projectRoot);
|
|
486
|
+
for (const cap of readdirSync(specsDir)) {
|
|
487
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
488
|
+
if (isFile(specPath)) {
|
|
489
|
+
const lines = lineCount(specPath);
|
|
490
|
+
if (lines > 400) warnings.push(`${relPath(projectRoot, specPath)} is ${lines} lines (>400 soft cap)`);
|
|
491
|
+
|
|
492
|
+
// 8b. EARS grammar shape — section-appropriate When/While/Where/shall.
|
|
493
|
+
const text = read(specPath);
|
|
494
|
+
if (isEarsSpec(text)) {
|
|
495
|
+
for (const f of checkEars(text)) {
|
|
496
|
+
warnings.push(`${relPath(projectRoot, specPath)}:${f.line} ${f.message}`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// 8b-ii. ORDERED requirements (change 0029). EARS states each
|
|
501
|
+
// event-driven requirement independently and says nothing about
|
|
502
|
+
// order, so a consumer step and its producer both pass while the
|
|
503
|
+
// consumer reads last run's file. A `### Pipeline` block declares
|
|
504
|
+
// the order and the artifact each step hands on; the invariant is
|
|
505
|
+
// that a step may only require what an EARLIER step produced.
|
|
506
|
+
// Opt-in: a spec without the block is never checked.
|
|
507
|
+
for (const f of checkPipeline(parsePipeline(text))) {
|
|
508
|
+
errors.push(`${relPath(projectRoot, specPath)}:${f.line} ${f.message} [${f.code}] — ${f.remedy}`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// 8i. An ACTIVE spec that declares no acceptance criterion. It says
|
|
512
|
+
// what the system must do and never says how anyone would know
|
|
513
|
+
// it does — and the section being present and empty is the
|
|
514
|
+
// common shape, since `spec new` scaffolds the heading.
|
|
515
|
+
//
|
|
516
|
+
// Measured before this check: on such a spec, `clarify` failed,
|
|
517
|
+
// `doctor` warned and `status` showed 0/0, while `validate` said
|
|
518
|
+
// "ok all validation checks passed" and `coverage --strict`
|
|
519
|
+
// exited 0. Three surfaces knew; the two a pipeline runs
|
|
520
|
+
// approved. The information was in the tree — the gate that
|
|
521
|
+
// decides was the one not using it (change 0091).
|
|
522
|
+
//
|
|
523
|
+
// Scoped to ACTIVE, symmetrically with 8c: a spec still being
|
|
524
|
+
// drawn may not know how to prove anything yet, and `Status:
|
|
525
|
+
// draft` is the escape hatch the scaffold already starts in.
|
|
526
|
+
{
|
|
527
|
+
const docStatus = (specHeader(text, "Status") ?? "active").toLowerCase();
|
|
528
|
+
if (docStatus === "active" && parseAcceptanceCriteria(text).length === 0) {
|
|
529
|
+
errors.push(
|
|
530
|
+
`${relPath(projectRoot, specPath)}: Status is "active" but the spec declares no ` +
|
|
531
|
+
`acceptance criterion — an active capability states what the system must do ` +
|
|
532
|
+
`and nothing about how anyone would know it does (write one, or set ` +
|
|
533
|
+
`Status: draft while it is still being drawn)`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 8c. Capability-state honesty (two-axis status). An "active"
|
|
539
|
+
// document that records no built capability is an inventory
|
|
540
|
+
// claim — the gap the framework is meant to make visible.
|
|
541
|
+
// Warn when Status is active and Implementation is "planned"
|
|
542
|
+
// with no explanatory note after the state word. A note
|
|
543
|
+
// ("planned — deferred, see ADR 0007") is the deliberate-gap
|
|
544
|
+
// escape hatch. Specs with no Implementation header are off
|
|
545
|
+
// the axis and never warn.
|
|
546
|
+
// 8j. The two machine-read headers and the criterion mark have a
|
|
547
|
+
// DOMAIN (change 0102). Before this check `Status: bogus`,
|
|
548
|
+
// `Implementation: banana` and `[banana]` were written by `spec
|
|
549
|
+
// set`, synced into the index, and passed here with 0 errors —
|
|
550
|
+
// `prime` then reported "1 banana". Every gate branches on these
|
|
551
|
+
// words; a word outside the domain is a value no branch reads.
|
|
552
|
+
// A note after the value stays legal: only the word is checked.
|
|
553
|
+
for (const name of ["Status", "Implementation"]) {
|
|
554
|
+
const raw = specHeader(text, name);
|
|
555
|
+
if (raw === null || raw.trim() === "") continue;
|
|
556
|
+
const domain = headerValueError(name, raw);
|
|
557
|
+
if (domain) errors.push(`${relPath(projectRoot, specPath)}: ${domain}`);
|
|
558
|
+
}
|
|
559
|
+
for (const crit of parseAcceptanceCriteria(text)) {
|
|
560
|
+
if (crit.marker === null) continue;
|
|
561
|
+
const domain = criterionMarkError(crit.marker);
|
|
562
|
+
if (domain) errors.push(`${relPath(projectRoot, specPath)}: acceptance criterion #${crit.n}: ${domain}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const implRaw = specHeader(text, "Implementation");
|
|
566
|
+
if (implRaw) {
|
|
567
|
+
const docStatus = (specHeader(text, "Status") ?? "active").toLowerCase();
|
|
568
|
+
const implTokens = implRaw.trim().split(/\s+/);
|
|
569
|
+
const implWord = (implTokens[0] ?? "").replace(/[—-]+$/, "").toLowerCase();
|
|
570
|
+
const hasNote = implTokens.length > 1;
|
|
571
|
+
if (docStatus === "active" && implWord === "planned" && !hasNote) {
|
|
572
|
+
warnings.push(
|
|
573
|
+
`${relPath(projectRoot, specPath)}: Status is "active" but Implementation is ` +
|
|
574
|
+
`"planned" with no note — an active spec with no built capability is an ` +
|
|
575
|
+
`inventory claim (advance Implementation, set Status: draft, or add a note ` +
|
|
576
|
+
`after the value: "planned — <why deferred>")`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// 8d. The same header, checked against the ARITHMETIC rather than
|
|
581
|
+
// against Status (audit finding F10). Coverage already knows how
|
|
582
|
+
// many of a spec's criteria cite proof that resolves, which is
|
|
583
|
+
// what "verified" means — so a header that disagrees with its own
|
|
584
|
+
// evidence is a warning with the exact op that settles it, not a
|
|
585
|
+
// field the agent is asked to remember. Never rewritten here: the
|
|
586
|
+
// gate proposes, a human or `spec set --implementation auto`
|
|
587
|
+
// applies. A note after the state word silences it, as it does
|
|
588
|
+
// the coverage gate itself.
|
|
589
|
+
const mismatch = implementationMismatch(implRaw, derived.get(cap)?.derived ?? null);
|
|
590
|
+
if (mismatch) {
|
|
591
|
+
const row = derived.get(cap);
|
|
592
|
+
const stillMarked = (row.unverified ?? 0) > 0
|
|
593
|
+
? ` and ${row.unverified} of them ${row.unverified === 1 ? "is" : "are"} still marked [unverified]`
|
|
594
|
+
: "";
|
|
595
|
+
warnings.push(
|
|
596
|
+
`${relPath(projectRoot, specPath)}: Implementation is "${mismatch.written}" but ` +
|
|
597
|
+
`${row.covered}/${row.total} criteria have resolving proof${stillMarked}, which supports ` +
|
|
598
|
+
`"${mismatch.derived}" (apply it with \`doctrina spec set ${cap} --implementation auto\`, ` +
|
|
599
|
+
`or add a note after the value saying why the count is not the whole story)`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 8e. Provenance adoption (review 2026-06-27, §5/§9). `trace` is the
|
|
605
|
+
// intent→capability link, but it stays inert when nothing forces
|
|
606
|
+
// the tag, so an active capability with no Realizes: header is an
|
|
607
|
+
// untraced promise — warn here (validate is always run) rather than
|
|
608
|
+
// only in the opt-in `trace`. Advisory, with the same escape hatch
|
|
609
|
+
// pattern as Implementation: any value silences it, including a
|
|
610
|
+
// deliberate "n/a — <why>" for an internal capability that maps to
|
|
611
|
+
// no product anchor. Bug specs (off the implementation axis) and
|
|
612
|
+
// draft/deprecated documents are exempt — only active capability
|
|
613
|
+
// specs make the promise.
|
|
614
|
+
// The escape hatch used to arrive PRE-ARMED: the scaffold writes a
|
|
615
|
+
// placeholder value for Realizes, any value silenced the check, so
|
|
616
|
+
// in the normal flow this warning was dead code and `trace` was
|
|
617
|
+
// left to report the intent as dropped, far from the cause. A value
|
|
618
|
+
// that is still the template's placeholder counts as absent
|
|
619
|
+
// (change 0057).
|
|
620
|
+
const docStatusForRealizes = (specHeader(text, "Status") ?? "active").toLowerCase();
|
|
621
|
+
const onImplAxis = specHeader(text, "Implementation") !== null;
|
|
622
|
+
const realizesValue = specHeader(text, "Realizes");
|
|
623
|
+
const realizesMissing = isPlaceholderHeaderValue(realizesValue, {
|
|
624
|
+
template: "spec.md.template", name: "Realizes",
|
|
625
|
+
});
|
|
626
|
+
if (docStatusForRealizes === "active" && onImplAxis && realizesMissing) {
|
|
627
|
+
warnings.push(
|
|
628
|
+
`${relPath(projectRoot, specPath)}: active spec ${realizesValue === null ? "has no" : "still carries the scaffold's"} Realizes: header — ` +
|
|
629
|
+
`it traces to no product intent (\`doctrina trace\`). Tag a product.md ` +
|
|
630
|
+
`success-criteria bullet "- [SC1] ..." and add "**Realizes:** SC1", or ` +
|
|
631
|
+
`record "**Realizes:** n/a — <why>" if it maps to no product anchor`,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// 8g. An acceptance criterion still in the template's placeholder form
|
|
636
|
+
// (change 0065). `spec new` ships one — "[unverified] <observable
|
|
637
|
+
// signal> — verified by `path/to/test`" — and nothing reported it.
|
|
638
|
+
// It survived into an ACTIVE spec and only surfaced days later as
|
|
639
|
+
// a coverage failure in the close of an unrelated change, because
|
|
640
|
+
// `path/to/test` resolves nowhere. The same ruler change 0057
|
|
641
|
+
// applied to headers: a value still wrapped in <...> is a value
|
|
642
|
+
// nobody wrote.
|
|
643
|
+
for (const crit of parseAcceptanceCriteria(text)) {
|
|
644
|
+
const placeholder = /^\s*\[[^\]]*\]\s*<[^>]*>/.test(crit.body)
|
|
645
|
+
|| crit.proofPaths.includes("path/to/test");
|
|
646
|
+
if (!placeholder) continue;
|
|
647
|
+
warnings.push(
|
|
648
|
+
`${relPath(projectRoot, specPath)}: acceptance criterion #${crit.n} is still the ` +
|
|
649
|
+
`scaffold's placeholder — write the observable signal and cite proof that exists, ` +
|
|
650
|
+
`or delete the criterion (it resolves nowhere, so \`coverage\` reports it dangling ` +
|
|
651
|
+
`in the close of whatever change touches this capability next)`,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// 8h. A `**Source:**` glob that claims code which is not there
|
|
656
|
+
// (change 0077). The header is the tree's only machine-readable
|
|
657
|
+
// statement that a capability OWNS a file, and `review` decides
|
|
658
|
+
// from it whether the spec kept up with the code. A pattern
|
|
659
|
+
// matching nothing is the same failure as a selector matching no
|
|
660
|
+
// target (RT05): the declaration reads as coverage and provides
|
|
661
|
+
// none, so a file moved out from under it goes unnoticed.
|
|
662
|
+
for (const glob of parseSourceGlobs(text)) {
|
|
663
|
+
if (filesMatching(projectRoot, glob).length > 0) continue;
|
|
664
|
+
warnings.push(
|
|
665
|
+
`${relPath(projectRoot, specPath)}: **Source:** pattern \`${glob}\` matches no file ` +
|
|
666
|
+
`— the capability claims code that is not there; correct the pattern or drop it`,
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// 8d. Metadata-header shape (review G11). In the header block (before
|
|
671
|
+
// the first `## ` section or `<!--` comment), a known key written
|
|
672
|
+
// without the canonical `**Key:** value` form silently fails to
|
|
673
|
+
// parse — the index then falls back to a default and drifts. Warn
|
|
674
|
+
// with the fix rather than letting the mis-typed header vanish.
|
|
675
|
+
const headerLines = text.split(/\r?\n/);
|
|
676
|
+
for (let i = 0; i < headerLines.length; i++) {
|
|
677
|
+
if (/^\s*(##\s|<!--)/.test(headerLines[i])) break;
|
|
678
|
+
for (const key of KNOWN_SPEC_HEADERS) {
|
|
679
|
+
const attempt = new RegExp(`^\\s*-?\\s*\\**${key}\\**\\s*:`, "i");
|
|
680
|
+
const canonical = new RegExp(`^(?:-\\s+)?\\*\\*${key}:\\*\\*\\s+\\S`);
|
|
681
|
+
if (attempt.test(headerLines[i]) && !canonical.test(headerLines[i])) {
|
|
682
|
+
warnings.push(
|
|
683
|
+
`${relPath(projectRoot, specPath)}:${i + 1} metadata header "${key}" is not in ` +
|
|
684
|
+
`canonical "**${key}:** value" form — it will not parse (G11)`,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// 8f. Self-certified acceptance criteria (honest gates, ADR 0008). A
|
|
691
|
+
// criterion that asserts [verified] but cites no proof path is a
|
|
692
|
+
// claim with nothing behind it — the dishonest-green case. `coverage`
|
|
693
|
+
// reports every bare criterion; this names the specific combo
|
|
694
|
+
// (claims verified, proves nothing) in the always-run gate. It reads
|
|
695
|
+
// through the shared multi-line parser, so a proof cited on a
|
|
696
|
+
// continuation line still counts (no false positive).
|
|
697
|
+
for (const crit of parseAcceptanceCriteria(text)) {
|
|
698
|
+
if (isVerified(crit) && crit.proofPaths.length === 0) {
|
|
699
|
+
warnings.push(
|
|
700
|
+
`${relPath(projectRoot, specPath)}: acceptance criterion #${crit.n} is marked ` +
|
|
701
|
+
`[verified] but cites no proof path — self-certified (cite the file/test in ` +
|
|
702
|
+
`backticks, or drop the marker; \`doctrina coverage\`)`,
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// 8k. A dependency on a capability that does not exist, and an intent
|
|
711
|
+
// anchor declared twice (change 0108). `**Depends on:** fantasma`
|
|
712
|
+
// feeds `why`, `context` and `review` and none of them said the
|
|
713
|
+
// capability was not there — `why` printed it, `context` dropped it
|
|
714
|
+
// in silence. Contrast `Realizes: SC9`, which `trace` names as
|
|
715
|
+
// dangling. And a second `- [SC1] ...` bullet in product.md was
|
|
716
|
+
// deduplicated on read, so the second intent vanished from every
|
|
717
|
+
// view while `trace --strict` stayed green.
|
|
718
|
+
if (isDir(specsDir)) {
|
|
719
|
+
const known = new Set(readdirSync(specsDir).filter((cap) => isFile(path.join(specsDir, cap, "spec.md"))));
|
|
720
|
+
for (const cap of [...known].sort()) {
|
|
721
|
+
const text = read(path.join(specsDir, cap, "spec.md"));
|
|
722
|
+
for (const dep of parseDependsOn(text)) {
|
|
723
|
+
if (known.has(dep)) continue;
|
|
724
|
+
errors.push(
|
|
725
|
+
`.doctrina/specs/${cap}/spec.md: Depends on "${dep}", which has no spec — ` +
|
|
726
|
+
`the pack, the graph and the review all read this header; correct the name, or \`doctrina spec new ${dep}\``,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
{
|
|
732
|
+
const productPath = path.join(projectRoot, ".doctrina", "product.md");
|
|
733
|
+
if (isFile(productPath)) {
|
|
734
|
+
const seen = new Map();
|
|
735
|
+
const lines = read(productPath).split(/\r?\n/);
|
|
736
|
+
for (let i = 0; i < lines.length; i++) {
|
|
737
|
+
const m = lines[i].match(/^\s*[-*]\s+\[([A-Z]+\d+)\]\s+/);
|
|
738
|
+
if (!m) continue;
|
|
739
|
+
if (seen.has(m[1])) {
|
|
740
|
+
errors.push(
|
|
741
|
+
`.doctrina/product.md:${i + 1} intent anchor [${m[1]}] is declared twice (first at line ${seen.get(m[1])}) — ` +
|
|
742
|
+
`two intents under one id leave the second invisible to trace, why and every pack; give it its own id`,
|
|
743
|
+
);
|
|
744
|
+
} else {
|
|
745
|
+
seen.set(m[1], i + 1);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// 8l. A contract still carrying the scaffold's rows (change 0111).
|
|
752
|
+
// Tasks and specs had a placeholder check; a contract whose Wiring,
|
|
753
|
+
// Selectors and References were all `<NAME>` / `specs/<capability>`
|
|
754
|
+
// passed here with nothing said — `contract check` ignores such rows
|
|
755
|
+
// by design (change 0029), which is right for the runtime verdict and
|
|
756
|
+
// silent about the artifact.
|
|
757
|
+
{
|
|
758
|
+
const contractsDir = path.join(projectRoot, ".doctrina", "contracts");
|
|
759
|
+
if (isDir(contractsDir)) {
|
|
760
|
+
for (const f of walk(contractsDir)) {
|
|
761
|
+
if (!f.endsWith(".md")) continue;
|
|
762
|
+
// The template's own comments mention the placeholders; only the
|
|
763
|
+
// content counts (comment-is-not-content, change 0055).
|
|
764
|
+
const text = maskComments(read(f));
|
|
765
|
+
const left = [];
|
|
766
|
+
if (/^\|\s*<NAME>\s*\|/m.test(text)) left.push("a `<NAME>` row in Wiring/Selectors/Budgets");
|
|
767
|
+
if (/specs\/<capability>/.test(text)) left.push("`specs/<capability>` under References");
|
|
768
|
+
if (left.length > 0) {
|
|
769
|
+
warnings.push(`${relPath(projectRoot, f)} still carries the scaffold's placeholder rows (${left.join("; ")}) — write them, or delete the rows that do not apply`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// 8m. A spec that is not where the tree reads specs from (change 0113).
|
|
776
|
+
// A change directory without `proposal.md` is an error above; a spec
|
|
777
|
+
// at `.doctrina/specs/legacy.md`, a capability directory with no
|
|
778
|
+
// `spec.md`, and a second `spec-old.md` beside the real one were
|
|
779
|
+
// silence — not indexed, in no pack, not counted, not traced.
|
|
780
|
+
if (isDir(specsDir)) {
|
|
781
|
+
for (const entry of readdirSync(specsDir, { withFileTypes: true })) {
|
|
782
|
+
if (entry.name.startsWith(".")) continue;
|
|
783
|
+
if (entry.isFile()) {
|
|
784
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
785
|
+
warnings.push(`.doctrina/specs/${entry.name} is not where a spec is read from — a spec lives at .doctrina/specs/<capability>/spec.md; move it, or it stays outside every pack, count and trace`);
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
if (!entry.isDirectory()) continue;
|
|
789
|
+
const capDir = path.join(specsDir, entry.name);
|
|
790
|
+
if (!isFile(path.join(capDir, "spec.md"))) {
|
|
791
|
+
warnings.push(`.doctrina/specs/${entry.name}/ has no spec.md — a capability directory is read only through .doctrina/specs/${entry.name}/spec.md (\`doctrina spec new ${entry.name}\`, or remove the directory)`);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
for (const sibling of readdirSync(capDir)) {
|
|
795
|
+
if (sibling === "spec.md" || !sibling.endsWith(".md")) continue;
|
|
796
|
+
const head = read(path.join(capDir, sibling)).split(/\r?\n/).find((l) => l.trim() !== "") ?? "";
|
|
797
|
+
if (/^#\s+Spec\b/i.test(head)) {
|
|
798
|
+
warnings.push(`.doctrina/specs/${entry.name}/${sibling} opens like a spec but only spec.md is read — merge it into spec.md, or rename it so it does not claim to be one`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// 9. ADRs over 300 lines warn.
|
|
805
|
+
if (isDir(adrDir)) {
|
|
806
|
+
for (const f of walk(adrDir)) {
|
|
807
|
+
if (!f.endsWith(".md")) continue;
|
|
808
|
+
const lines = lineCount(f);
|
|
809
|
+
if (lines > 300) warnings.push(`${relPath(projectRoot, f)} is ${lines} lines (>300 soft cap)`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// 10b. Stale-reference detection — Markdown link targets inside specs
|
|
814
|
+
// and ADRs that no longer exist on disk. Backtick paths are NOT
|
|
815
|
+
// checked (descriptive prose, too noisy). Links are pointers.
|
|
816
|
+
for (const dir of [specsDir, adrDir]) {
|
|
817
|
+
if (!isDir(dir)) continue;
|
|
818
|
+
for (const f of walk(dir)) {
|
|
819
|
+
if (!f.endsWith(".md")) continue;
|
|
820
|
+
const text = read(f);
|
|
821
|
+
const fileDir = path.dirname(f);
|
|
822
|
+
for (const ref of extractPathReferences(text)) {
|
|
823
|
+
// Try relative to the file first, then to project root
|
|
824
|
+
const candidates = [
|
|
825
|
+
path.resolve(fileDir, ref),
|
|
826
|
+
path.resolve(projectRoot, ref),
|
|
827
|
+
];
|
|
828
|
+
if (!candidates.some(exists)) {
|
|
829
|
+
warnings.push(`${relPath(projectRoot, f)} references missing path \`${ref}\``);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// 10. Orphan detection — files on disk not referenced in index.json.
|
|
836
|
+
if (index?.artifacts) {
|
|
837
|
+
const indexedSpecIds = new Set((index.artifacts.specs ?? []).map((s) => s.id ?? s.capability));
|
|
838
|
+
if (isDir(specsDir)) {
|
|
839
|
+
for (const cap of readdirSync(specsDir)) {
|
|
840
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
841
|
+
if (isFile(specPath) && !indexedSpecIds.has(cap)) {
|
|
842
|
+
warnings.push(`orphan spec ${relPath(projectRoot, specPath)} (capability "${cap}" not in index.json)`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const indexedAdrIds = new Set((index.artifacts.decisions ?? []).map((d) => d.id));
|
|
847
|
+
if (isDir(adrDir)) {
|
|
848
|
+
for (const f of walk(adrDir)) {
|
|
849
|
+
if (!f.endsWith(".md")) continue;
|
|
850
|
+
const m = path.basename(f).match(/^(\d{4})-/);
|
|
851
|
+
if (m && !indexedAdrIds.has(m[1])) {
|
|
852
|
+
warnings.push(`orphan ADR ${relPath(projectRoot, f)} (id "${m[1]}" not in index.json)`);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const indexedContractIds = new Set((index.artifacts.contracts ?? []).map((ct) => ct.id));
|
|
857
|
+
const contractsDir = path.join(projectRoot, ".doctrina", "contracts");
|
|
858
|
+
if (isDir(contractsDir)) {
|
|
859
|
+
for (const f of walk(contractsDir)) {
|
|
860
|
+
if (!f.endsWith(".md")) continue;
|
|
861
|
+
const id = path.basename(f, ".md");
|
|
862
|
+
if (!indexedContractIds.has(id)) {
|
|
863
|
+
warnings.push(`orphan contract ${relPath(projectRoot, f)} (id "${id}" not in index.json)`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// 10b. An artifact that carries nothing. Check 4 asks whether the file
|
|
870
|
+
// EXISTS; a zero-byte file exists. Measured before this check: a
|
|
871
|
+
// spec, a proposal, a contract, a skill and `product.md` emptied to
|
|
872
|
+
// zero bytes each reported `ok, 0 errors`, and only an ADR was
|
|
873
|
+
// caught. The header-vs-index comparison runs on the headers it
|
|
874
|
+
// FINDS, so a file with none is compared against nothing and agrees.
|
|
875
|
+
// Third appearance of one pattern — absence is not approval, fixed in
|
|
876
|
+
// `coverage` by change 0057 and in `trace` by 0083, and it was in the
|
|
877
|
+
// structural gate the whole time (change 0087).
|
|
878
|
+
//
|
|
879
|
+
// Two levels, both cheap and both indisputable: nothing at all, and
|
|
880
|
+
// something that is not an artifact — every template the CLI ships
|
|
881
|
+
// writes an H1, so a Markdown artifact without one was not scaffolded
|
|
882
|
+
// and was not written by hand either.
|
|
883
|
+
for (const rel of artifactFiles(projectRoot)) {
|
|
884
|
+
const full = path.join(projectRoot, rel);
|
|
885
|
+
if (!isFile(full)) continue; // check 4 owns the missing-file case
|
|
886
|
+
const text = read(full);
|
|
887
|
+
if (text.trim() === "") {
|
|
888
|
+
errors.push(`${rel} is empty — an artifact with no content is registered as if it said something`);
|
|
889
|
+
} else if (!/^#\s+\S/m.test(maskComments(text))) {
|
|
890
|
+
errors.push(`${rel} carries no title — every scaffolded artifact opens with an \`# \` heading, so a file without one is neither scaffolded nor authored`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// 11. Archive ledger ↔ index cross-check. The history of archived
|
|
895
|
+
// changes is recorded twice — once human-facing in
|
|
896
|
+
// changes/archive/LEDGER.md, once machine-facing in
|
|
897
|
+
// index.json.changes_archive. Two sources of truth that silently
|
|
898
|
+
// disagree are worse than one: an incomplete ledger makes the
|
|
899
|
+
// history look shorter than it is. When the ledger exists, every
|
|
900
|
+
// archived change must appear in both, or validation fails.
|
|
901
|
+
if (index?.artifacts && isFile(ledgerFile(projectRoot))) {
|
|
902
|
+
// One parse of the ledger grammar, in lib/ledger.js (change 0046). This
|
|
903
|
+
// check used to carry its own regex, so the file's readers and its
|
|
904
|
+
// writers could drift apart with nothing to notice.
|
|
905
|
+
//
|
|
906
|
+
// An abandoned change is ledger-only BY DESIGN: `change abandon` records
|
|
907
|
+
// the discard in history but deletes the folder, so there is deliberately
|
|
908
|
+
// no archive entry to cross-check. Requiring one made every abandonment
|
|
909
|
+
// turn validate permanently red (found dogfooding change
|
|
910
|
+
// 0001-review-followups). A gap line records a waived gate, not a change
|
|
911
|
+
// that landed, so it is not cross-checked either.
|
|
912
|
+
const ledgerIds = new Set(
|
|
913
|
+
readLedger(projectRoot).entries.filter((e) => e.kind === "archived").map((e) => e.id),
|
|
914
|
+
);
|
|
915
|
+
const archiveIds = new Set((index.artifacts.changes_archive ?? []).map((ch) => ch.id));
|
|
916
|
+
for (const id of archiveIds) {
|
|
917
|
+
if (!ledgerIds.has(id)) {
|
|
918
|
+
errors.push(`archived change "${id}" is in index.json but missing from changes/archive/LEDGER.md (ledger and index disagree)`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
for (const id of ledgerIds) {
|
|
922
|
+
if (!archiveIds.has(id)) {
|
|
923
|
+
errors.push(`changes/archive/LEDGER.md lists "${id}" but index.json.changes_archive does not (ledger and index disagree)`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// 12-14. Skills validation
|
|
929
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
930
|
+
if (isDir(skillsDir)) {
|
|
931
|
+
for (const f of walk(skillsDir)) {
|
|
932
|
+
if (!f.endsWith(".md")) continue;
|
|
933
|
+
const text = read(f);
|
|
934
|
+
const rel = relPath(projectRoot, f);
|
|
935
|
+
const baseName = path.basename(f, ".md");
|
|
936
|
+
const nameField = parseFrontmatter(text, "name");
|
|
937
|
+
const descField = parseFrontmatter(text, "description");
|
|
938
|
+
const whenField = parseFrontmatter(text, "when");
|
|
939
|
+
if (!nameField) warnings.push(`${rel} missing required frontmatter field "name"`);
|
|
940
|
+
if (!descField) warnings.push(`${rel} missing required frontmatter field "description"`);
|
|
941
|
+
if (!whenField) warnings.push(`${rel} missing required frontmatter field "when"`);
|
|
942
|
+
if (nameField && nameField !== baseName) {
|
|
943
|
+
warnings.push(`${rel} name "${nameField}" does not match filename slug "${baseName}"`);
|
|
944
|
+
}
|
|
945
|
+
// 14c. A frontmatter value still in the scaffold's `<...>` form is a
|
|
946
|
+
// value nobody wrote (change 0111) — and it was served into every
|
|
947
|
+
// context pack as the skill's description, while `skill sync`
|
|
948
|
+
// called the file up to date.
|
|
949
|
+
for (const [field, value] of [["description", descField], ["when", whenField]]) {
|
|
950
|
+
if (value && isScaffoldValue(value)) {
|
|
951
|
+
warnings.push(`${rel} frontmatter "${field}" is still the scaffold's placeholder — write it, or the pack lists a skill nobody described (${value.length > 50 ? `${value.slice(0, 49)}…` : value})`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// 14b. A skill nothing can TRIGGER is a file, not a memory (change
|
|
956
|
+
// 0029). `context` lists a skill's `when:` so an agent can fire
|
|
957
|
+
// the right one without loading any of them — but a trigger
|
|
958
|
+
// written as pure prose ("when it makes sense", "as needed")
|
|
959
|
+
// gives it nothing to match on, and the skill is never loaded by
|
|
960
|
+
// anyone who did not already know it existed. A usable trigger
|
|
961
|
+
// names something concrete: a keyword, a path, a command, an
|
|
962
|
+
// error string.
|
|
963
|
+
if (whenField && !hasDetectableTrigger(whenField)) {
|
|
964
|
+
warnings.push(
|
|
965
|
+
`${rel} frontmatter "when" has no detectable trigger — name a concrete ` +
|
|
966
|
+
`keyword, path, command or error string, or nothing can match it ` +
|
|
967
|
+
`(got: "${whenField.length > 60 ? `${whenField.slice(0, 59)}…` : whenField}")`,
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
const lines = lineCount(f);
|
|
971
|
+
if (lines > 200) warnings.push(`${rel} is ${lines} lines (>200 soft cap)`);
|
|
972
|
+
else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft cap)`);
|
|
973
|
+
|
|
974
|
+
// 15. Skill description drift — index.json must mirror the
|
|
975
|
+
// frontmatter description (run `doctrina skill sync`).
|
|
976
|
+
if (descField && index?.artifacts?.skills) {
|
|
977
|
+
const entry = index.artifacts.skills.find((s) => s.id === baseName);
|
|
978
|
+
if (entry && entry.description !== descField) {
|
|
979
|
+
warnings.push(`${rel} description differs from index.json (run \`doctrina skill sync\`)`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// 16. Bilingual docs parity. Projects that keep an EN-source docs tree
|
|
986
|
+
// with a PT mirror (docs/en + docs/pt) get filename-parity checking:
|
|
987
|
+
// a file added in one language and missing in the other is invisible
|
|
988
|
+
// to every other gate, and the mirror silently falls behind. Only the
|
|
989
|
+
// deterministic half (file sets) is checked — content staleness stays
|
|
990
|
+
// a human judgement. Projects without both trees never see this.
|
|
991
|
+
const docsEn = path.join(projectRoot, "docs", "en");
|
|
992
|
+
const docsPt = path.join(projectRoot, "docs", "pt");
|
|
993
|
+
if (isDir(docsEn) && isDir(docsPt)) {
|
|
994
|
+
const mdSet = (dir) => new Set(readdirSync(dir).filter((f) => f.endsWith(".md")));
|
|
995
|
+
const enSet = mdSet(docsEn);
|
|
996
|
+
const ptSet = mdSet(docsPt);
|
|
997
|
+
for (const f of [...enSet].sort()) {
|
|
998
|
+
if (!ptSet.has(f)) {
|
|
999
|
+
warnings.push(`docs/en/${f} has no docs/pt/${f} counterpart (EN is the source; the PT mirror fell behind)`);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
for (const f of [...ptSet].sort()) {
|
|
1003
|
+
if (!enSet.has(f)) {
|
|
1004
|
+
warnings.push(`docs/pt/${f} has no docs/en/${f} counterpart (PT translates an EN source, never leads it)`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Project rules — permanent, lintable constraints (.doctrina/rules.json;
|
|
1010
|
+
// 0.11.0 field review item 12: "never reference company X" lived only in
|
|
1011
|
+
// agent memory, and the remaining mentions sailed through every gate). Each
|
|
1012
|
+
// rule is a forbid-regex over glob-scoped paths; a match is an ERROR with
|
|
1013
|
+
// the rule's own message. Deterministic, zero-deps, and the instruction
|
|
1014
|
+
// survives sessions because it is an artifact, not a memory.
|
|
1015
|
+
for (const line of checkProjectRules(projectRoot)) errors.push(line);
|
|
1016
|
+
for (const key of loadConfig(projectRoot).unknown) {
|
|
1017
|
+
warnings.push(`${CONFIG_REL}: unknown key "${key}" is ignored — the keys are language, context_budget, rules (a typo here is a setting that never applied)`);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// 18. --runtime folds the RUNTIME gate into the structural one, so a
|
|
1021
|
+
// single call covers both halves of the truth: the shape of the
|
|
1022
|
+
// artifacts, and whether what they declare about the running system
|
|
1023
|
+
// still holds. Opt-in because it reads files outside .doctrina/
|
|
1024
|
+
// (workflows, consumers, test sources) and a structural validate
|
|
1025
|
+
// should stay cheap. Delegates to lib/runtime.js — the same checks
|
|
1026
|
+
// `contract check`, `triage` and `doctor` render, so the four can
|
|
1027
|
+
// never disagree.
|
|
1028
|
+
if (runtime) {
|
|
1029
|
+
for (const f of collectRuntimeFindings(projectRoot).findings) {
|
|
1030
|
+
const line = `${f.contract}: ${f.message} [${f.code}] — ${f.remedy}`;
|
|
1031
|
+
if (f.level === "error") errors.push(line);
|
|
1032
|
+
else warnings.push(line);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
return { errors, warnings, fixes };
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Does a skill's `when:` give anything to MATCH on? A trigger written as
|
|
1040
|
+
// pure prose ("when it seems relevant", "as needed") reads fine and can
|
|
1041
|
+
// never fire: `context` ranks skills by comparing the trigger against the
|
|
1042
|
+
// task, and there is nothing there to compare. A usable trigger names
|
|
1043
|
+
// something concrete — a path or glob, a command, a quoted error string, a
|
|
1044
|
+
// file extension, an ALL_CAPS identifier, or simply enough distinctive
|
|
1045
|
+
// keywords to match on.
|
|
1046
|
+
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;
|
|
1047
|
+
|
|
1048
|
+
/** A value still wrapped in the template's angle brackets: `<one-sentence …>`. */
|
|
1049
|
+
export function isScaffoldValue(value) {
|
|
1050
|
+
return /^<[^<>]*>$/.test(String(value ?? "").trim());
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
export function hasDetectableTrigger(when) {
|
|
1054
|
+
const text = String(when ?? "").trim();
|
|
1055
|
+
if (text === "" || VAGUE_TRIGGER.test(text)) return false;
|
|
1056
|
+
// The scaffold's own placeholder has enough words to rank on and names
|
|
1057
|
+
// nothing (change 0111): a trigger nobody wrote fires for nobody.
|
|
1058
|
+
if (isScaffoldValue(text)) return false;
|
|
1059
|
+
// Anything structural is inherently matchable.
|
|
1060
|
+
if (/[\/\]|\*|`|"|'|\.\w{2,4}|[A-Z][A-Z0-9_]{2,}/.test(text)) return true;
|
|
1061
|
+
// Otherwise: enough distinctive words to rank on. Stopwords do not count.
|
|
1062
|
+
const STOP = new Set(["when", "whenever", "the", "a", "an", "is", "are", "you", "your",
|
|
1063
|
+
"it", "its", "this", "that", "and", "or", "to", "of", "in", "on", "for", "with",
|
|
1064
|
+
"any", "some", "need", "needs", "needed", "should", "must", "at", "as", "by", "be"]);
|
|
1065
|
+
const words = text.toLowerCase().match(/[a-z][a-z0-9-]{2,}/g) ?? [];
|
|
1066
|
+
return words.filter((w) => !STOP.has(w)).length >= 2;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Enforce the project's rules — permanent constraints as forbid-regexes over
|
|
1070
|
+
// glob-scoped paths:
|
|
1071
|
+
// { "rules": [ { "id": "white-label", "forbid": "\\bAcmeCorp\\b",
|
|
1072
|
+
// "paths": ["src/**", ".doctrina/specs/**"],
|
|
1073
|
+
// "message": "white-label product; use a generic placeholder" } ] }
|
|
1074
|
+
// Declared in .doctrina/config.json; still read from the legacy
|
|
1075
|
+
// .doctrina/rules.json when that is where a project put them (change 0047).
|
|
1076
|
+
// Returns error strings (one per offending file+rule, capped per rule so a
|
|
1077
|
+
// mass violation stays readable). No rules → nothing to enforce; a malformed
|
|
1078
|
+
// file is reported once, by the reader. Binary-ish and vendored dirs are
|
|
1079
|
+
// skipped by the same bounded walk validate already uses elsewhere.
|
|
1080
|
+
const RULES_SKIP_DIRS = new Set([
|
|
1081
|
+
".git", "node_modules", "vendor", "dist", "build", "out", "target",
|
|
1082
|
+
".venv", "venv", "__pycache__", ".next", "coverage",
|
|
1083
|
+
]);
|
|
1084
|
+
const RULES_MAX_HITS_PER_RULE = 10;
|
|
1085
|
+
|
|
1086
|
+
function checkProjectRules(projectRoot) {
|
|
1087
|
+
const cfg = loadConfig(projectRoot);
|
|
1088
|
+
const out = [...cfg.errors];
|
|
1089
|
+
const where = cfg.sources.rules === SOURCES.config ? CONFIG_REL : RULES_REL;
|
|
1090
|
+
const compiled = [];
|
|
1091
|
+
for (const r of cfg.rules) {
|
|
1092
|
+
if (!r || typeof r.forbid !== "string" || !r.forbid) {
|
|
1093
|
+
out.push(`${where}: rule "${r?.id ?? "?"}" needs a non-empty "forbid" regex`);
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
let re;
|
|
1097
|
+
try {
|
|
1098
|
+
re = new RegExp(r.forbid, "m");
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
out.push(`${where}: rule "${r.id ?? r.forbid}" has an invalid regex: ${err.message}`);
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
const paths = Array.isArray(r.paths) && r.paths.length ? r.paths : ["**"];
|
|
1104
|
+
compiled.push({ id: r.id ?? r.forbid, re, matchers: paths.map(globToRegExp), message: r.message ?? "", hits: 0 });
|
|
1105
|
+
}
|
|
1106
|
+
if (compiled.length === 0) return out;
|
|
1107
|
+
|
|
1108
|
+
// One bounded walk; each text file is read at most once.
|
|
1109
|
+
const stack = [projectRoot];
|
|
1110
|
+
while (stack.length) {
|
|
1111
|
+
const dir = stack.pop();
|
|
1112
|
+
let entries;
|
|
1113
|
+
try {
|
|
1114
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1115
|
+
} catch {
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
for (const entry of entries) {
|
|
1119
|
+
const full = path.join(dir, entry.name);
|
|
1120
|
+
if (entry.isDirectory()) {
|
|
1121
|
+
if (!RULES_SKIP_DIRS.has(entry.name)) stack.push(full);
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
const rel = relPath(projectRoot, full).replace(/\\/g, "/");
|
|
1125
|
+
if (rel === ".doctrina/rules.json") continue; // the rule text itself always matches
|
|
1126
|
+
const applicable = compiled.filter((cr) => cr.hits < RULES_MAX_HITS_PER_RULE && cr.matchers.some((m) => m.test(rel)));
|
|
1127
|
+
if (applicable.length === 0) continue;
|
|
1128
|
+
let text;
|
|
1129
|
+
try {
|
|
1130
|
+
text = read(full);
|
|
1131
|
+
} catch {
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
for (const cr of applicable) {
|
|
1135
|
+
const m = cr.re.exec(text);
|
|
1136
|
+
if (m) {
|
|
1137
|
+
cr.hits += 1;
|
|
1138
|
+
const lineNo = text.slice(0, m.index).split("\n").length;
|
|
1139
|
+
out.push(`rule "${cr.id}": ${rel}:${lineNo} matches forbidden pattern${cr.message ? ` — ${cr.message}` : ""}`);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
for (const cr of compiled) {
|
|
1145
|
+
if (cr.hits >= RULES_MAX_HITS_PER_RULE) {
|
|
1146
|
+
out.push(`rule "${cr.id}": more matches suppressed after ${RULES_MAX_HITS_PER_RULE} files`);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
return out;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Minimal glob → RegExp: ** crosses directories, * stays within a segment.
|
|
1153
|
+
// Anchored to the whole repo-relative POSIX path.
|
|
1154
|
+
function globToRegExp(glob) {
|
|
1155
|
+
const esc = String(glob).replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
1156
|
+
const body = esc
|
|
1157
|
+
.replace(/\*\*\//g, "(?:.*/)?")
|
|
1158
|
+
.replace(/\*\*/g, ".*")
|
|
1159
|
+
.replace(/\*/g, "[^/]*")
|
|
1160
|
+
.replace(/\?/g, "[^/]");
|
|
1161
|
+
return new RegExp(`^${body}$`);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Per-entry metadata drift between the on-disk index and the tree-derived
|
|
1165
|
+
// one, for entries present in BOTH. Presence drift (an artifact on disk but
|
|
1166
|
+
// not indexed, or indexed but missing) is left to the orphan warning and the
|
|
1167
|
+
// missing-artifact error; this isolates the case the review hit — an indexed
|
|
1168
|
+
// artifact whose recorded metadata no longer matches its file.
|
|
1169
|
+
function metadataDrift(current, derived) {
|
|
1170
|
+
const lines = [];
|
|
1171
|
+
// Skills are deliberately excluded: their description has a dedicated
|
|
1172
|
+
// reconciliation command (`doctrina skill sync`) and stays an advisory
|
|
1173
|
+
// warning (see section 15), not a hard error.
|
|
1174
|
+
const cats = ["specs", "decisions", "changes", "changes_archive", "contracts"];
|
|
1175
|
+
for (const cat of cats) {
|
|
1176
|
+
const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
1177
|
+
const der = new Map((derived.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
1178
|
+
for (const [id, entry] of cur) {
|
|
1179
|
+
if (der.has(id) && stableStringify(entry) !== stableStringify(der.get(id))) {
|
|
1180
|
+
lines.push(`index.json: ${cat} "${id}" metadata differs from its file`);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
return lines;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Find AGENTS.md files below the root (the "nearest AGENTS.md wins"
|
|
1188
|
+
// hierarchy). Bounded walk: dependency, build, and VCS directories are
|
|
1189
|
+
// skipped so validate stays fast on large repositories.
|
|
1190
|
+
const NESTED_SKIP_DIRS = new Set([
|
|
1191
|
+
".git", "node_modules", ".doctrina", "vendor", "dist", "build", "out",
|
|
1192
|
+
"target", ".venv", "venv", "__pycache__", ".next", "coverage",
|
|
1193
|
+
]);
|
|
1194
|
+
|
|
1195
|
+
function findNestedAgentsMd(projectRoot) {
|
|
1196
|
+
const found = [];
|
|
1197
|
+
const stack = readdirSync(projectRoot)
|
|
1198
|
+
.filter((e) => !NESTED_SKIP_DIRS.has(e) && !e.startsWith("."))
|
|
1199
|
+
.map((e) => path.join(projectRoot, e))
|
|
1200
|
+
.filter(isDir);
|
|
1201
|
+
while (stack.length) {
|
|
1202
|
+
const dir = stack.pop();
|
|
1203
|
+
for (const entry of readdirSync(dir)) {
|
|
1204
|
+
const full = path.join(dir, entry);
|
|
1205
|
+
if (isDir(full)) {
|
|
1206
|
+
if (!NESTED_SKIP_DIRS.has(entry) && !entry.startsWith(".")) stack.push(full);
|
|
1207
|
+
} else if (entry === "AGENTS.md") {
|
|
1208
|
+
found.push(full);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return found.sort();
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Extract candidate file path references from Markdown text. Only
|
|
1216
|
+
// Markdown link targets `[text](path)` are checked — backtick spans
|
|
1217
|
+
// are descriptive prose and produce too many false positives.
|
|
1218
|
+
function extractPathReferences(text) {
|
|
1219
|
+
const out = new Set();
|
|
1220
|
+
for (const m of text.matchAll(/\]\(([^)\s]+)\)/g)) {
|
|
1221
|
+
const token = m[1].trim();
|
|
1222
|
+
if (isLikelyPath(token)) out.add(stripFragment(token));
|
|
1223
|
+
}
|
|
1224
|
+
return [...out];
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Path-like tokens inside backtick spans — the convention for citing
|
|
1228
|
+
// evidence (ADR **Evidence:**, acceptance-criteria links).
|
|
1229
|
+
function extractBacktickPaths(text) {
|
|
1230
|
+
const out = new Set();
|
|
1231
|
+
for (const m of text.matchAll(/`([^`]+)`/g)) {
|
|
1232
|
+
const token = m[1].trim();
|
|
1233
|
+
if (isLikelyPath(token)) out.add(stripFragment(token));
|
|
1234
|
+
}
|
|
1235
|
+
return [...out];
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function stripFragment(s) {
|
|
1239
|
+
// Remove an in-page anchor (#section) or query string from a path token.
|
|
1240
|
+
return s.split(/[#?]/)[0];
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function isLikelyPath(s) {
|
|
1244
|
+
if (!s) return false;
|
|
1245
|
+
if (/^(https?:|mailto:|ftp:|#|@)/i.test(s)) return false;
|
|
1246
|
+
if (s.includes("{{") || s.includes("}}")) return false;
|
|
1247
|
+
if (s.includes("*") || s.includes("?")) return false;
|
|
1248
|
+
if (s.includes("<") || s.includes(">")) return false;
|
|
1249
|
+
if (s.includes(" ") || s.includes("`")) return false;
|
|
1250
|
+
// Strip in-page anchor or query string before further checks
|
|
1251
|
+
const head = stripFragment(s);
|
|
1252
|
+
if (!head) return false;
|
|
1253
|
+
// Folder references (path ending with /) are skipped: not file targets
|
|
1254
|
+
if (head.endsWith("/")) return false;
|
|
1255
|
+
// Must look like a file path: contains a slash or has a file extension
|
|
1256
|
+
const hasSlash = head.includes("/");
|
|
1257
|
+
const hasExt = /\.[a-z0-9]{1,8}$/i.test(head);
|
|
1258
|
+
if (!hasSlash && !hasExt) return false;
|
|
1259
|
+
if (head.startsWith("-")) return false;
|
|
1260
|
+
return true;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Every Markdown artifact the framework owns, as project-relative paths.
|
|
1265
|
+
*
|
|
1266
|
+
* The index records a change by its DIRECTORY, so a proposal cannot be
|
|
1267
|
+
* reached by walking `index.json` alone; and skills are indexed but were
|
|
1268
|
+
* outside the existence check. This is the one list of "files that are
|
|
1269
|
+
* supposed to say something", used by the emptiness check (change 0087).
|
|
1270
|
+
*
|
|
1271
|
+
* The archive is excluded, like everywhere else: it is history, out of the
|
|
1272
|
+
* default read path.
|
|
1273
|
+
*/
|
|
1274
|
+
export function artifactFiles(projectRoot) {
|
|
1275
|
+
const dot = path.join(projectRoot, ".doctrina");
|
|
1276
|
+
const out = [];
|
|
1277
|
+
const add = (full) => {
|
|
1278
|
+
if (isFile(full)) out.push(relPath(projectRoot, full).replaceAll("\\\\", "/"));
|
|
1279
|
+
};
|
|
1280
|
+
add(path.join(dot, "product.md"));
|
|
1281
|
+
add(path.join(dot, "intake.md"));
|
|
1282
|
+
const specsDir = path.join(dot, "specs");
|
|
1283
|
+
if (isDir(specsDir)) {
|
|
1284
|
+
for (const cap of readdirSync(specsDir)) add(path.join(specsDir, cap, "spec.md"));
|
|
1285
|
+
}
|
|
1286
|
+
const changesDir = path.join(dot, "changes");
|
|
1287
|
+
if (isDir(changesDir)) {
|
|
1288
|
+
for (const id of readdirSync(changesDir)) {
|
|
1289
|
+
if (id === "archive" || id.startsWith(".")) continue;
|
|
1290
|
+
add(path.join(changesDir, id, "proposal.md"));
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
for (const dir of ["decisions", "contracts", "skills"]) {
|
|
1294
|
+
const full = path.join(dot, dir);
|
|
1295
|
+
if (!isDir(full)) continue;
|
|
1296
|
+
for (const f of walk(full)) if (f.endsWith(".md")) add(f);
|
|
1297
|
+
}
|
|
1298
|
+
return out;
|
|
1299
|
+
}
|