doctrina-cli 0.4.0 → 0.8.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 +32 -13
- package/package.json +1 -1
- package/src/commands/change.js +128 -38
- package/src/commands/close.js +104 -0
- package/src/commands/coverage.js +100 -20
- package/src/commands/hooks.js +9 -3
- package/src/commands/next.js +56 -3
- package/src/commands/review.js +177 -0
- package/src/commands/skill.js +112 -2
- package/src/commands/spec.js +92 -6
- package/src/commands/status.js +178 -0
- package/src/commands/trace.js +31 -0
- package/src/commands/validate.js +97 -11
- package/src/commands/verify.js +234 -13
- package/src/commands/watch.js +97 -0
- package/src/commands/why.js +182 -0
- package/src/commands/work.js +241 -20
- package/src/index.js +13 -1
- package/src/lib/spec-ops.js +236 -0
- package/templates/AGENTS.md.template +49 -22
- package/templates/change/spec-delta.md.template +13 -2
- package/templates/hooks/pre-commit.sample +21 -4
- package/templates/spec.md.template +7 -3
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, read, walk } from "../lib/fs-ops.js";
|
|
5
|
+
import * as idx from "../lib/index-json.js";
|
|
6
|
+
import { deriveIndex, indexesMatch, specHeader, listHeader } from "../lib/scan.js";
|
|
7
|
+
import { cliVersion } from "../lib/version.js";
|
|
8
|
+
import { summarize as coverageSummary } from "./coverage.js";
|
|
9
|
+
import { summarize as traceSummary } from "./trace.js";
|
|
10
|
+
import { c } from "../lib/colors.js";
|
|
11
|
+
|
|
12
|
+
// One-glance project health (review 2026-06-27 passive-user feature #1): a
|
|
13
|
+
// dashboard that answers "where do things stand?" in a single read, so neither
|
|
14
|
+
// the human nor the agent has to run validate + coverage + trace + next
|
|
15
|
+
// separately. Strictly read-only; never mutates and always exits 0. It reports
|
|
16
|
+
// the gate signals it can compute cheaply (index drift, the framework stamp,
|
|
17
|
+
// coverage/trace ratios, whether verify is configured) and the artifact counts;
|
|
18
|
+
// `doctrina validate` / `verify` remain the full, authoritative gates.
|
|
19
|
+
|
|
20
|
+
const CONFIG_REL = ".doctrina/verify.json";
|
|
21
|
+
|
|
22
|
+
export async function run(_positional, _flags) {
|
|
23
|
+
const projectRoot = process.cwd();
|
|
24
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
25
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let index = null;
|
|
29
|
+
try {
|
|
30
|
+
index = idx.load(projectRoot);
|
|
31
|
+
} catch {
|
|
32
|
+
index = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const project = index?.project ?? path.basename(projectRoot);
|
|
36
|
+
const stamp = index?.framework_version ?? "—";
|
|
37
|
+
const cli = cliVersion();
|
|
38
|
+
console.log(c.bold("Doctrina status") + c.gray(` — ${project} (framework ${stamp} / CLI ${cli})`));
|
|
39
|
+
console.log("");
|
|
40
|
+
|
|
41
|
+
// --- Gates ---
|
|
42
|
+
console.log(c.bold(" Gates"));
|
|
43
|
+
// Index drift (the silent rot validate now errors on).
|
|
44
|
+
let driftLine;
|
|
45
|
+
if (!index) {
|
|
46
|
+
driftLine = c.red("missing/unreadable") + c.gray(" — run `doctrina index rebuild`");
|
|
47
|
+
} else if (indexesMatch(deriveIndex(projectRoot, index), index)) {
|
|
48
|
+
driftLine = c.green("in sync");
|
|
49
|
+
} else {
|
|
50
|
+
driftLine = c.yellow("drifted") + c.gray(" — run `doctrina validate --fix`");
|
|
51
|
+
}
|
|
52
|
+
console.log(` ${"index".padEnd(11)} ${driftLine}`);
|
|
53
|
+
|
|
54
|
+
// Framework stamp divergence.
|
|
55
|
+
const stampLine = stamp === cli
|
|
56
|
+
? c.green("current")
|
|
57
|
+
: c.yellow(`${stamp} (CLI ${cli})`) + c.gray(" — `doctrina index rebuild`");
|
|
58
|
+
console.log(` ${"stamp".padEnd(11)} ${stampLine}`);
|
|
59
|
+
|
|
60
|
+
// Coverage and trace ratios.
|
|
61
|
+
const cov = coverageSummary(projectRoot);
|
|
62
|
+
const covExtra = cov.totalDangling + cov.totalConditional > 0
|
|
63
|
+
? c.yellow(` ${cov.totalDangling} dangling, ${cov.totalConditional} conditional`)
|
|
64
|
+
: "";
|
|
65
|
+
const covColor = cov.pct === 100 ? c.green : cov.pct >= 50 ? c.yellow : c.red;
|
|
66
|
+
console.log(` ${"coverage".padEnd(11)} ${covColor(`${cov.pct}%`)} ${c.gray(`(${cov.totalCovered}/${cov.totalCriteria} criteria)`)}${covExtra}`);
|
|
67
|
+
|
|
68
|
+
const tr = traceSummary(projectRoot);
|
|
69
|
+
const trExtra = tr.untraceable + tr.dropped + tr.dangling > 0
|
|
70
|
+
? c.yellow(` ${tr.dropped} dropped, ${tr.untraceable} untraceable`)
|
|
71
|
+
: "";
|
|
72
|
+
const trColor = tr.anchors === 0 ? c.gray : (tr.realized === tr.anchors && tr.untraceable === 0 ? c.green : c.yellow);
|
|
73
|
+
const trText = tr.anchors === 0 ? "no anchors" : `${tr.realized}/${tr.anchors} anchors`;
|
|
74
|
+
console.log(` ${"trace".padEnd(11)} ${trColor(trText)}${trExtra}`);
|
|
75
|
+
|
|
76
|
+
// Verify config presence (running it is the slow, authoritative gate).
|
|
77
|
+
const verifyCfg = path.join(projectRoot, CONFIG_REL);
|
|
78
|
+
let verifyLine = c.gray("not configured") + c.gray(" — `doctrina verify --init`");
|
|
79
|
+
if (isFile(verifyCfg)) {
|
|
80
|
+
try {
|
|
81
|
+
const cfg = JSON.parse(read(verifyCfg));
|
|
82
|
+
const n = Array.isArray(cfg?.checks) ? cfg.checks.length : 0;
|
|
83
|
+
verifyLine = c.cyan(`${n} check${n === 1 ? "" : "s"}`) + c.gray(" — run `doctrina verify`");
|
|
84
|
+
} catch {
|
|
85
|
+
verifyLine = c.red("invalid JSON");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
console.log(` ${"verify".padEnd(11)} ${verifyLine}`);
|
|
89
|
+
|
|
90
|
+
// --- Work ---
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log(c.bold(" Work"));
|
|
93
|
+
const specs = countSpecs(projectRoot);
|
|
94
|
+
const implBreak = Object.entries(specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
|
|
95
|
+
console.log(` ${"specs".padEnd(11)} ${specs.total}${implBreak ? c.gray(` (${implBreak})`) : ""}`);
|
|
96
|
+
console.log(` ${"changes".padEnd(11)} ${specs.openChanges} open`);
|
|
97
|
+
const adr = countDecisions(projectRoot);
|
|
98
|
+
const adrNotes = [];
|
|
99
|
+
if (adr.proposed > 0) adrNotes.push(c.yellow(`${adr.proposed} proposed`));
|
|
100
|
+
if (adr.bare > 0) adrNotes.push(c.yellow(`${adr.bare} unproven`));
|
|
101
|
+
console.log(` ${"decisions".padEnd(11)} ${adr.total}${adrNotes.length ? c.gray(" (") + adrNotes.join(c.gray(", ")) + c.gray(")") : ""}`);
|
|
102
|
+
const skills = countSkills(projectRoot);
|
|
103
|
+
console.log(` ${"skills".padEnd(11)} ${skills}`);
|
|
104
|
+
|
|
105
|
+
console.log("");
|
|
106
|
+
console.log(c.gray(" Full gates: `doctrina validate` · `doctrina verify`. Next step: `doctrina next`."));
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function countSpecs(projectRoot) {
|
|
111
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
112
|
+
const impl = {};
|
|
113
|
+
let total = 0;
|
|
114
|
+
if (isDir(specsDir)) {
|
|
115
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
116
|
+
const p = path.join(specsDir, cap, "spec.md");
|
|
117
|
+
if (!isFile(p)) continue;
|
|
118
|
+
total += 1;
|
|
119
|
+
const implRaw = specHeader(read(p), "Implementation");
|
|
120
|
+
if (implRaw) {
|
|
121
|
+
const word = implRaw.trim().split(/[\s—-]+/)[0].toLowerCase();
|
|
122
|
+
impl[word] = (impl[word] ?? 0) + 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
127
|
+
let openChanges = 0;
|
|
128
|
+
if (isDir(changesDir)) {
|
|
129
|
+
for (const e of readdirSync(changesDir)) {
|
|
130
|
+
if (e === "archive" || e.startsWith(".")) continue;
|
|
131
|
+
if (isDir(path.join(changesDir, e))) openChanges += 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { total, impl, openChanges };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function countDecisions(projectRoot) {
|
|
138
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
139
|
+
let total = 0, proposed = 0, bare = 0;
|
|
140
|
+
const isBare = (v) => {
|
|
141
|
+
const t = (v ?? "").trim();
|
|
142
|
+
return t === "" || t === "—" || t === "-";
|
|
143
|
+
};
|
|
144
|
+
if (isDir(adrDir)) {
|
|
145
|
+
for (const f of walk(adrDir)) {
|
|
146
|
+
if (!/^\d{4}-.+\.md$/.test(path.basename(f))) continue;
|
|
147
|
+
total += 1;
|
|
148
|
+
const text = read(f);
|
|
149
|
+
const status = (listHeader(text, "Status") ?? "").toLowerCase();
|
|
150
|
+
if (status === "proposed") proposed += 1;
|
|
151
|
+
if (status === "accepted") {
|
|
152
|
+
const evidence = listHeader(text, "Evidence");
|
|
153
|
+
const landed = listHeader(text, "Landed");
|
|
154
|
+
if (evidence !== null && isBare(evidence) && isBare(landed)) bare += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { total, proposed, bare };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function countSkills(projectRoot) {
|
|
162
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
163
|
+
if (!isDir(skillsDir)) return 0;
|
|
164
|
+
return walk(skillsDir).filter((f) => f.endsWith(".md")).length;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const help = `
|
|
168
|
+
Usage: doctrina status
|
|
169
|
+
|
|
170
|
+
Print a one-glance health dashboard for the .doctrina/ project: the gate
|
|
171
|
+
signals (index drift, framework stamp, coverage %, trace anchors, whether
|
|
172
|
+
verify is configured) and the artifact counts (specs by implementation
|
|
173
|
+
state, open changes, decisions, skills). Read-only; always exits 0.
|
|
174
|
+
|
|
175
|
+
It is a fast summary, not the authoritative gate: run \`doctrina validate\`
|
|
176
|
+
and \`doctrina verify\` for the full structural and build checks, and
|
|
177
|
+
\`doctrina next\` for the recommended next action.
|
|
178
|
+
`;
|
package/src/commands/trace.js
CHANGED
|
@@ -106,6 +106,37 @@ export async function run(_positional, flags) {
|
|
|
106
106
|
return strict ? 1 : 0;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// Pure summary of intent provenance, for other commands (`status`, `review`)
|
|
110
|
+
// that need the numbers without the report output.
|
|
111
|
+
export function summarize(projectRoot) {
|
|
112
|
+
const anchors = collectAnchors(projectRoot);
|
|
113
|
+
const specs = collectSpecs(projectRoot);
|
|
114
|
+
const anchorIds = new Set(anchors.map((a) => a.id));
|
|
115
|
+
const realizedBy = new Map();
|
|
116
|
+
let dangling = 0;
|
|
117
|
+
for (const s of specs) {
|
|
118
|
+
if (s.realizes === null) continue;
|
|
119
|
+
for (const id of s.realizes) {
|
|
120
|
+
if (anchorIds.has(id)) {
|
|
121
|
+
if (!realizedBy.has(id)) realizedBy.set(id, []);
|
|
122
|
+
realizedBy.get(id).push(s.cap);
|
|
123
|
+
} else {
|
|
124
|
+
dangling += 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const untraceable = specs.filter((s) => s.realizes === null && s.status === "active").length;
|
|
129
|
+
let realized = 0;
|
|
130
|
+
for (const a of anchors) if ((realizedBy.get(a.id) ?? []).length > 0) realized += 1;
|
|
131
|
+
return {
|
|
132
|
+
anchors: anchors.length,
|
|
133
|
+
realized,
|
|
134
|
+
dropped: anchors.length - realized,
|
|
135
|
+
dangling,
|
|
136
|
+
untraceable,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
109
140
|
// Every "[A-Z]+\d+" tag at the head of a bullet in product.md is an intent
|
|
110
141
|
// anchor. Section-agnostic so Success-criteria and In-scope bullets both work.
|
|
111
142
|
function collectAnchors(projectRoot) {
|
package/src/commands/validate.js
CHANGED
|
@@ -5,12 +5,14 @@ import { exists, isDir, isFile, lineCount, read, relPath, walk } from "../lib/fs
|
|
|
5
5
|
import * as idx from "../lib/index-json.js";
|
|
6
6
|
import { SCHEMA_VERSION } from "../lib/index-json.js";
|
|
7
7
|
import { cliVersion } from "../lib/version.js";
|
|
8
|
+
import { today } from "../lib/dates.js";
|
|
9
|
+
import { flagBool } from "../lib/args.js";
|
|
8
10
|
import { c } from "../lib/colors.js";
|
|
9
11
|
import { parseFrontmatter } from "./skill.js";
|
|
10
12
|
import { checkEars, isEarsSpec } from "../lib/ears.js";
|
|
11
|
-
import { specHeader, listHeader } from "../lib/scan.js";
|
|
13
|
+
import { specHeader, listHeader, deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
|
|
12
14
|
|
|
13
|
-
export async function run(_positional,
|
|
15
|
+
export async function run(_positional, flags) {
|
|
14
16
|
const projectRoot = process.cwd();
|
|
15
17
|
const errors = [];
|
|
16
18
|
const warnings = [];
|
|
@@ -47,6 +49,22 @@ export async function run(_positional, _flags) {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
if (index) {
|
|
52
|
+
// --fix (F5): regenerate the index from the tree before validating, so a
|
|
53
|
+
// drifted index is repaired rather than reported. A full rebuild — it also
|
|
54
|
+
// absorbs orphans and migrates the framework stamp, matching
|
|
55
|
+
// `index rebuild`. Subsequent checks then see the repaired index.
|
|
56
|
+
if (flagBool(flags, "fix", false)) {
|
|
57
|
+
const derived = deriveIndex(projectRoot, index);
|
|
58
|
+
const staleStamp = (index.framework_version ?? null) !== cliVersion();
|
|
59
|
+
if (!indexesMatch(derived, index) || staleStamp) {
|
|
60
|
+
derived.framework_version = cliVersion();
|
|
61
|
+
derived.last_updated = today();
|
|
62
|
+
idx.save(projectRoot, derived);
|
|
63
|
+
console.log(c.green("fixed") + " rebuilt .doctrina/index.json from the tree");
|
|
64
|
+
index = derived;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
if (index.$schema_version !== SCHEMA_VERSION) {
|
|
51
69
|
warnings.push(`index.json $schema_version is "${index.$schema_version}" (expected "${SCHEMA_VERSION}")`);
|
|
52
70
|
}
|
|
@@ -78,15 +96,18 @@ export async function run(_positional, _flags) {
|
|
|
78
96
|
if (!exists(full)) errors.push(`index.json references missing artifact: ${rel}`);
|
|
79
97
|
}
|
|
80
98
|
|
|
81
|
-
// 4b.
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
99
|
+
// 4b. Index metadata drift (F5 / review G5 / G7). validate is the single
|
|
100
|
+
// source of truth about whether the project is OK, so an indexed
|
|
101
|
+
// artifact whose recorded metadata (version, implementation, status,
|
|
102
|
+
// ...) no longer matches its file is an ERROR here — not a
|
|
103
|
+
// pass-with-warning that only `index rebuild --check` would catch
|
|
104
|
+
// (the "validate says OK while next says drift" footgun). Presence
|
|
105
|
+
// drift (orphan on disk / missing file) stays the orphan warning and
|
|
106
|
+
// the missing-artifact error above; this targets the metadata that
|
|
107
|
+
// silently lies. `validate --fix` (above) regenerates instead.
|
|
108
|
+
const derived = deriveIndex(projectRoot, index);
|
|
109
|
+
for (const line of metadataDrift(index, derived)) {
|
|
110
|
+
errors.push(`${line} — run \`doctrina validate --fix\` or \`doctrina index rebuild\``);
|
|
90
111
|
}
|
|
91
112
|
}
|
|
92
113
|
}
|
|
@@ -206,6 +227,7 @@ export async function run(_positional, _flags) {
|
|
|
206
227
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
207
228
|
if (isDir(specsDir)) {
|
|
208
229
|
const { readdirSync } = await import("node:fs");
|
|
230
|
+
const KNOWN_SPEC_HEADERS = ["Capability", "Status", "Implementation", "Version", "Last updated", "Realizes"];
|
|
209
231
|
for (const cap of readdirSync(specsDir)) {
|
|
210
232
|
const specPath = path.join(specsDir, cap, "spec.md");
|
|
211
233
|
if (isFile(specPath)) {
|
|
@@ -243,6 +265,47 @@ export async function run(_positional, _flags) {
|
|
|
243
265
|
);
|
|
244
266
|
}
|
|
245
267
|
}
|
|
268
|
+
|
|
269
|
+
// 8e. Provenance adoption (review 2026-06-27, §5/§9). `trace` is the
|
|
270
|
+
// intent→capability link, but it stays inert when nothing forces
|
|
271
|
+
// the tag, so an active capability with no Realizes: header is an
|
|
272
|
+
// untraced promise — warn here (validate is always run) rather than
|
|
273
|
+
// only in the opt-in `trace`. Advisory, with the same escape hatch
|
|
274
|
+
// pattern as Implementation: any value silences it, including a
|
|
275
|
+
// deliberate "n/a — <why>" for an internal capability that maps to
|
|
276
|
+
// no product anchor. Bug specs (off the implementation axis) and
|
|
277
|
+
// draft/deprecated documents are exempt — only active capability
|
|
278
|
+
// specs make the promise.
|
|
279
|
+
const docStatusForRealizes = (specHeader(text, "Status") ?? "active").toLowerCase();
|
|
280
|
+
const onImplAxis = specHeader(text, "Implementation") !== null;
|
|
281
|
+
if (docStatusForRealizes === "active" && onImplAxis && specHeader(text, "Realizes") === null) {
|
|
282
|
+
warnings.push(
|
|
283
|
+
`${relPath(projectRoot, specPath)}: active spec has no Realizes: header — ` +
|
|
284
|
+
`it traces to no product intent (\`doctrina trace\`). Tag a product.md ` +
|
|
285
|
+
`success-criteria bullet "- [SC1] ..." and add "**Realizes:** SC1", or ` +
|
|
286
|
+
`record "**Realizes:** n/a — <why>" if it maps to no product anchor`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 8d. Metadata-header shape (review G11). In the header block (before
|
|
291
|
+
// the first `## ` section or `<!--` comment), a known key written
|
|
292
|
+
// without the canonical `**Key:** value` form silently fails to
|
|
293
|
+
// parse — the index then falls back to a default and drifts. Warn
|
|
294
|
+
// with the fix rather than letting the mis-typed header vanish.
|
|
295
|
+
const headerLines = text.split(/\r?\n/);
|
|
296
|
+
for (let i = 0; i < headerLines.length; i++) {
|
|
297
|
+
if (/^\s*(##\s|<!--)/.test(headerLines[i])) break;
|
|
298
|
+
for (const key of KNOWN_SPEC_HEADERS) {
|
|
299
|
+
const attempt = new RegExp(`^\\s*-?\\s*\\**${key}\\**\\s*:`, "i");
|
|
300
|
+
const canonical = new RegExp(`^(?:-\\s+)?\\*\\*${key}:\\*\\*\\s+\\S`);
|
|
301
|
+
if (attempt.test(headerLines[i]) && !canonical.test(headerLines[i])) {
|
|
302
|
+
warnings.push(
|
|
303
|
+
`${relPath(projectRoot, specPath)}:${i + 1} metadata header "${key}" is not in ` +
|
|
304
|
+
`canonical "**${key}:** value" form — it will not parse (G11)`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
246
309
|
}
|
|
247
310
|
}
|
|
248
311
|
}
|
|
@@ -386,6 +449,29 @@ export async function run(_positional, _flags) {
|
|
|
386
449
|
return errors.length === 0 ? 0 : 1;
|
|
387
450
|
}
|
|
388
451
|
|
|
452
|
+
// Per-entry metadata drift between the on-disk index and the tree-derived
|
|
453
|
+
// one, for entries present in BOTH. Presence drift (an artifact on disk but
|
|
454
|
+
// not indexed, or indexed but missing) is left to the orphan warning and the
|
|
455
|
+
// missing-artifact error; this isolates the case the review hit — an indexed
|
|
456
|
+
// artifact whose recorded metadata no longer matches its file.
|
|
457
|
+
function metadataDrift(current, derived) {
|
|
458
|
+
const lines = [];
|
|
459
|
+
// Skills are deliberately excluded: their description has a dedicated
|
|
460
|
+
// reconciliation command (`doctrina skill sync`) and stays an advisory
|
|
461
|
+
// warning (see section 15), not a hard error.
|
|
462
|
+
const cats = ["specs", "decisions", "changes", "changes_archive", "contracts"];
|
|
463
|
+
for (const cat of cats) {
|
|
464
|
+
const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
465
|
+
const der = new Map((derived.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
466
|
+
for (const [id, entry] of cur) {
|
|
467
|
+
if (der.has(id) && stableStringify(entry) !== stableStringify(der.get(id))) {
|
|
468
|
+
lines.push(`index.json: ${cat} "${id}" metadata differs from its file`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return lines;
|
|
473
|
+
}
|
|
474
|
+
|
|
389
475
|
// Find AGENTS.md files below the root (the "nearest AGENTS.md wins"
|
|
390
476
|
// hierarchy). Bounded walk: dependency, build, and VCS directories are
|
|
391
477
|
// skipped so validate stays fast on large repositories.
|