@sdd-method/maintain-cli 0.11.0 → 0.13.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/dist/lib/behaviour-store/archive.js +13 -0
- package/dist/lib/catalogue/aggregate.js +64 -0
- package/dist/lib/catalogue/build.js +45 -2
- package/dist/lib/catalogue/builders/adr-governs.js +39 -0
- package/dist/lib/catalogue/builders/adrs.js +11 -0
- package/dist/lib/catalogue/builders/capability-spec.js +1 -0
- package/dist/lib/catalogue/builders/contract-revisions.js +126 -0
- package/dist/lib/catalogue/builders/data-model.js +316 -0
- package/dist/lib/catalogue/builders/entities-mermaid.js +18 -85
- package/dist/lib/catalogue/builders/feature-grounding.js +1 -0
- package/dist/lib/catalogue/builders/features.js +23 -2
- package/dist/lib/catalogue/builders/integration-components.js +49 -5
- package/dist/lib/catalogue/builders/journey-requirement.js +144 -0
- package/dist/lib/catalogue/builders/quality-scores.js +17 -0
- package/dist/lib/catalogue/builders/requirements.js +168 -32
- package/dist/lib/catalogue/canonical-schema.yaml +845 -28
- package/dist/lib/catalogue/manifest-loader.js +13 -0
- package/dist/lib/catalogue/retired-catalogues.js +25 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +108 -2
- package/dist/lib/catalogue/schema-compat.js +1 -1
- package/dist/lib/catalogue/schema-loader.js +3 -0
- package/dist/lib/catalogue/staleness-verdicts.js +75 -6
- package/dist/lib/metamodel/tql-subset-parser.js +35 -2
- package/dist/lib/structure/catalogue.js +183 -0
- package/dist/lib/structure/compose.js +174 -0
- package/dist/lib/structure/effects.js +36 -0
- package/dist/lib/structure/model.js +212 -0
- package/dist/lib/structure/render.js +242 -0
- package/dist/lib/structure/view.js +301 -0
- package/dist/lib/validate/agreement-evidence.js +216 -0
- package/dist/lib/validate/behaviour-store.js +516 -28
- package/dist/lib/validate/dispatch.js +21 -0
- package/dist/lib/validate/feature-spec-pointers.js +175 -0
- package/dist/lib/validate/journey-impact.js +193 -0
- package/dist/lib/validate/structural-model.js +265 -0
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -8,18 +8,37 @@
|
|
|
8
8
|
//
|
|
9
9
|
// 1. records: every agreement record has the shape the checks below read.
|
|
10
10
|
// 2. agreement: an open change with any completed task has an agreed record and a Gate 0 that
|
|
11
|
-
// ends CLEAR (the pre-apply check, M-ADR 0223 §4.3).
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
11
|
+
// ends CLEAR (the pre-apply check, M-ADR 0223 §4.3). A record for an open change must
|
|
12
|
+
// declare agreement-record schema 1.1.0 or later.
|
|
13
|
+
// 3. stale: for each open, agreed change, each requirement it MODIFIES, REMOVES or RENAMES is, block by
|
|
14
|
+
// block, as it was at the agreed commit (the latest amendment's, else sdd.commit; M-ADR 0223
|
|
15
|
+
// §4.5). Another requirement of the same spec moving does not stale it. With no targets the
|
|
16
|
+
// check is vacuous, and says so.
|
|
17
|
+
// agreed content: the change's proposal, deltas, Gate 0, design, tasks (checkboxes ignored), test
|
|
18
|
+
// plan (its execution results ignored) and structural deltas equal their state at that commit
|
|
19
|
+
// (M-ADR 0222 §4.6; a structural delta's change is a [WARN] while M-ADR 0230 is Proposed).
|
|
20
|
+
// kinds: on a record held to 1.1.0, each test-plan row's Kind (changed or carried) is the one computed
|
|
21
|
+
// from the deltas and the living spec, a row naming no scenario of the deltas fails, and a
|
|
22
|
+
// filled Before is a fail for changed and a pass for carried (M-ADR 0223 §4.4).
|
|
23
|
+
// 4. verdicts: every archived change except a baseline has a verdict.md whose verdict line agrees with
|
|
24
|
+
// its record's PASS (M-ADR 0223 §4.6). A record held to 1.1.0 (by its own history, or by the
|
|
25
|
+
// schema the SDD shipped when the change archived) also needs: an agreed record; archived
|
|
26
|
+
// content equal to the agreed content; its targeted requirements as agreed just before the
|
|
27
|
+
// archive entered HEAD's history; an independent judge; a work order for each tasks.md group
|
|
28
|
+
// and each added or modified requirement; judged and merged commits that are commits, resolve
|
|
29
|
+
// in a checkout beside the SDD where there is one, and match; and a ledger naming each
|
|
30
|
+
// implementing repository. A pin no checkout resolves is a [WARN], failed under --strict.
|
|
31
|
+
// conditions: each agreement condition is recorded in full, and an open one past its date fails unless the
|
|
32
|
+
// change was withdrawn or replaced (M-ADR 0222 §4.6).
|
|
17
33
|
// 5. surfaces: every archived baseline's evidence lists, under **Surfaces:**, each contract the
|
|
18
34
|
// repositories it was observed in serve, as covered at transport or excluded with a
|
|
19
35
|
// reason, and the list is checked against the catalogue (catalogues/repo_contract.csv),
|
|
20
36
|
// never against itself (M-ADR 0222 §4.3, §4.4). A list the catalogue gives nothing to
|
|
21
37
|
// check against (no Observed in line, or a repository with no catalogue contracts) fails:
|
|
22
38
|
// a cross-check of zero contracts is unresolved, not a pass.
|
|
39
|
+
// A baseline whose every requirement has since left the living specs (removed, or its
|
|
40
|
+
// spec retired) is history: its list is not checked, so a retired surface can drop its
|
|
41
|
+
// contract (M-ADR 0222 §4.4).
|
|
23
42
|
// 7. features: every living spec is keyed by a catalogue feature (M-ADR 0221 §4.7). A spec keyed by no
|
|
24
43
|
// feature is dropped from the catalogue by the requirements builder, which reports it only as a
|
|
25
44
|
// build finding; this check makes it a failure, so a shipped requirement cannot vanish from the
|
|
@@ -48,8 +67,10 @@ import { tmpdir } from "node:os";
|
|
|
48
67
|
import { basename, join } from "node:path";
|
|
49
68
|
import { promisify } from "node:util";
|
|
50
69
|
import { parseDocument } from "yaml";
|
|
51
|
-
import { observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, surfaceMatches } from "../behaviour-store/archive.js";
|
|
70
|
+
import { observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, renamePairs, resolveImplements, surfaceMatches } from "../behaviour-store/archive.js";
|
|
52
71
|
import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
72
|
+
import { boundFile, conditionProblems, structuralFile, isCommit, normaliseBound, requirementBlock, sameCommit, scenarioKind, taskGroups, testPlanHeader, testPlanMapping, verdictLine, versionAtLeast } from "./agreement-evidence.js";
|
|
73
|
+
import { ENFORCED as STRUCTURE_ENFORCED } from "./structural-model.js";
|
|
53
74
|
import { compareStoreSchema, SCHEMA_NAME, SHIPPED_SCHEMA_DIR, STORE_CONFIG, STORE_SCHEMA_DIR, storeSchemaName } from "../behaviour-store/schema.js";
|
|
54
75
|
const execFileAsync = promisify(execFile);
|
|
55
76
|
/** The OpenSpec release the method depends on (M-ADR 0223 ruling 2). The module installs exactly this. */
|
|
@@ -138,6 +159,33 @@ export function parseAgreementRecord(file, text) {
|
|
|
138
159
|
}
|
|
139
160
|
}
|
|
140
161
|
const verdict = r.verdict && typeof r.verdict === "object" ? r.verdict : undefined;
|
|
162
|
+
if (r.schema_version !== undefined && typeof r.schema_version !== "string")
|
|
163
|
+
problems.push(`${file}: schema_version must be a quoted string`);
|
|
164
|
+
const amendments = Array.isArray(r.amendments) ? r.amendments : [];
|
|
165
|
+
let amendCommit;
|
|
166
|
+
for (const a of amendments) {
|
|
167
|
+
if (a && typeof a.commit === "string" && typeof a.approved_by === "string" && a.approved_by.trim() !== "")
|
|
168
|
+
amendCommit = a.commit;
|
|
169
|
+
}
|
|
170
|
+
const workOrders = (Array.isArray(r.work_orders) ? r.work_orders : [])
|
|
171
|
+
.filter((w) => w && typeof w.repo === "string")
|
|
172
|
+
.map((w) => ({ repo: w.repo, implements: Array.isArray(w.implements) ? w.implements.map(String) : [] }));
|
|
173
|
+
const conditions = Array.isArray(r.conditions) ? r.conditions : [];
|
|
174
|
+
if (r.conditions !== undefined && !Array.isArray(r.conditions))
|
|
175
|
+
problems.push(`${file}: conditions must be a list`);
|
|
176
|
+
const noLongerImplements = [];
|
|
177
|
+
if (r.no_longer_implements !== undefined) {
|
|
178
|
+
if (!Array.isArray(r.no_longer_implements))
|
|
179
|
+
problems.push(`${file}: no_longer_implements must be a list of {repo, requirement}`);
|
|
180
|
+
else
|
|
181
|
+
for (const [i, x] of r.no_longer_implements.entries()) {
|
|
182
|
+
const e = x;
|
|
183
|
+
if (!e || typeof e.repo !== "string" || typeof e.requirement !== "string")
|
|
184
|
+
problems.push(`${file}: no_longer_implements[${i}] names a repo and a requirement`);
|
|
185
|
+
else
|
|
186
|
+
noLongerImplements.push({ repo: e.repo, requirement: e.requirement });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
141
189
|
if (problems.length > 0)
|
|
142
190
|
return { problems };
|
|
143
191
|
return {
|
|
@@ -145,9 +193,14 @@ export function parseAgreementRecord(file, text) {
|
|
|
145
193
|
file,
|
|
146
194
|
change: expected,
|
|
147
195
|
status,
|
|
196
|
+
...(typeof r.schema_version === "string" ? { schemaVersion: r.schema_version } : {}),
|
|
148
197
|
...(typeof sdd.commit === "string" ? { commit: sdd.commit } : {}),
|
|
198
|
+
...(amendCommit ? { amendCommit } : {}),
|
|
149
199
|
...(typeof gate0?.result === "string" ? { gate0: gate0.result } : {}),
|
|
150
200
|
...(verdict ? { verdict } : {}),
|
|
201
|
+
workOrders,
|
|
202
|
+
conditions,
|
|
203
|
+
noLongerImplements,
|
|
151
204
|
},
|
|
152
205
|
problems,
|
|
153
206
|
};
|
|
@@ -176,21 +229,39 @@ function lastGateLine(text) {
|
|
|
176
229
|
const lines = text.split("\n").map((l) => l.trim()).filter((l) => l !== "");
|
|
177
230
|
return lines.at(-1) ?? "";
|
|
178
231
|
}
|
|
179
|
-
async function
|
|
232
|
+
async function commitResolves(repo, commit) {
|
|
180
233
|
try {
|
|
181
234
|
await execFileAsync("git", ["-C", repo, "cat-file", "-e", `${commit}^{commit}`]);
|
|
235
|
+
return true;
|
|
182
236
|
}
|
|
183
237
|
catch {
|
|
184
|
-
return
|
|
238
|
+
return false;
|
|
185
239
|
}
|
|
240
|
+
}
|
|
241
|
+
/** A file's text at a commit; null when the commit holds no such file. The caller has resolved the commit. */
|
|
242
|
+
async function gitShow(repo, commit, path) {
|
|
186
243
|
try {
|
|
187
|
-
await execFileAsync("git", ["-C", repo, "
|
|
188
|
-
return "same";
|
|
244
|
+
return (await execFileAsync("git", ["-C", repo, "show", `${commit}:${path}`], { maxBuffer: 64 * 1024 * 1024 })).stdout;
|
|
189
245
|
}
|
|
190
|
-
catch
|
|
191
|
-
return
|
|
246
|
+
catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** Paths under a directory at a commit, relative to that directory. */
|
|
251
|
+
async function gitFiles(repo, commit, dir) {
|
|
252
|
+
try {
|
|
253
|
+
const out = (await execFileAsync("git", ["-C", repo, "ls-tree", "-r", "--name-only", commit, "--", `${dir}/`], { maxBuffer: 64 * 1024 * 1024 })).stdout;
|
|
254
|
+
return out.split("\n").filter(Boolean).map((p) => p.slice(dir.length + 1));
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return [];
|
|
192
258
|
}
|
|
193
259
|
}
|
|
260
|
+
/** Requirement names a delta targets: MODIFIED and REMOVED, and the former name of each RENAMED. */
|
|
261
|
+
function targetNames(delta) {
|
|
262
|
+
const names = parseRequirements(delta).filter((r) => r.operation === "MODIFIED" || r.operation === "REMOVED").map((r) => r.name);
|
|
263
|
+
return [...new Set([...names, ...renamedFrom(delta)])];
|
|
264
|
+
}
|
|
194
265
|
/** The living specs an open change's deltas modify, remove or rename: openspec/specs/<capability>/spec.md. */
|
|
195
266
|
async function targetedSpecs(repo, changeDir) {
|
|
196
267
|
const out = [];
|
|
@@ -217,12 +288,116 @@ async function targetedSpecs(repo, changeDir) {
|
|
|
217
288
|
await walk("");
|
|
218
289
|
return out.sort();
|
|
219
290
|
}
|
|
220
|
-
|
|
291
|
+
/**
|
|
292
|
+
* The highest schema_version a record has ever been committed with, from every commit that touched it, or null when
|
|
293
|
+
* the record has no history here. Applicability is monotonic: once a record entered the 1.1.0 rules, no later
|
|
294
|
+
* declaration takes it out, and a record with no history is unresolved rather than taken to be legacy.
|
|
295
|
+
*/
|
|
296
|
+
async function recordVersionEver(repo, file) {
|
|
297
|
+
let commits;
|
|
298
|
+
try {
|
|
299
|
+
commits = (await execFileAsync("git", ["-C", repo, "log", "--format=%H", "--", file], { maxBuffer: 64 * 1024 * 1024 })).stdout.split("\n").filter(Boolean);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
if (commits.length === 0)
|
|
305
|
+
return null;
|
|
306
|
+
let highest;
|
|
307
|
+
for (const c of commits) {
|
|
308
|
+
const text = await gitShow(repo, c, file);
|
|
309
|
+
const m = text ? /^\s*schema_version:\s*["']?([0-9.]+)["']?/m.exec(text) ?? /"schema_version":\s*"([0-9.]+)"/.exec(text) : null;
|
|
310
|
+
if (m && (highest === undefined || versionAtLeast(m[1], highest)))
|
|
311
|
+
highest = m[1];
|
|
312
|
+
}
|
|
313
|
+
return { highest };
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* The commit at which an archive entered the history HEAD integrates: the oldest commit on HEAD's first-parent chain
|
|
317
|
+
* that adds it. A change archived on a branch and merged in enters at the merge, so its first parent is the target
|
|
318
|
+
* state the change was integrated over, not the branch's own older parent. Null when the archive is not committed.
|
|
319
|
+
*/
|
|
320
|
+
async function integratedAt(repo, path) {
|
|
321
|
+
try {
|
|
322
|
+
const out = (await execFileAsync("git", ["-C", repo, "log", "--first-parent", "--diff-filter=A", "--format=%H", "--", path], { maxBuffer: 64 * 1024 * 1024 })).stdout.trim();
|
|
323
|
+
return out === "" ? null : out.split("\n").at(-1);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/** The agreement-record schema this SDD shipped at a commit (its working tree when the commit is null). */
|
|
330
|
+
async function shippedRecordSchema(repo, commit) {
|
|
331
|
+
const path = "docs/method/foundations/schemas/agreement-record-schema.yaml";
|
|
332
|
+
const text = commit ? await gitShow(repo, commit, path) : await readText(join(repo, path));
|
|
333
|
+
return text ? /^schema_version:\s*"([0-9.]+)"/m.exec(text)?.[1] : undefined;
|
|
334
|
+
}
|
|
335
|
+
/** Files under a directory, relative to it. */
|
|
336
|
+
async function listFiles(dir, rel = "") {
|
|
337
|
+
const out = [];
|
|
338
|
+
let entries;
|
|
339
|
+
try {
|
|
340
|
+
entries = await readdir(join(dir, rel), { withFileTypes: true });
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
for (const e of entries) {
|
|
346
|
+
const r = rel === "" ? e.name : `${rel}/${e.name}`;
|
|
347
|
+
if (e.isDirectory())
|
|
348
|
+
out.push(...(await listFiles(dir, r)));
|
|
349
|
+
else
|
|
350
|
+
out.push(r);
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Where a work order's repository is checked out: the SDD itself for the SDD's own work order (its repository id is
|
|
356
|
+
* the SDD's sdd_id), else a checkout beside the SDD (../<repo>). The SDD's own commits are always at hand, wherever
|
|
357
|
+
* the SDD is mounted, so its work order never depends on a sibling directory's name.
|
|
358
|
+
*/
|
|
359
|
+
function checkoutDir(sddRepo, codeRepo, ownId) {
|
|
360
|
+
return ownId !== "" && codeRepo === ownId ? sddRepo : join(sddRepo, "..", codeRepo);
|
|
361
|
+
}
|
|
362
|
+
/** A work order repository's checkout, when there is one. */
|
|
363
|
+
async function codeCheckout(sddRepo, codeRepo, ownId) {
|
|
364
|
+
const dir = checkoutDir(sddRepo, codeRepo, ownId);
|
|
365
|
+
try {
|
|
366
|
+
await execFileAsync("git", ["-C", dir, "rev-parse", "--git-dir"]);
|
|
367
|
+
return dir;
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** Whether two commits of a work order's repository, in its checkout, have one tree. */
|
|
374
|
+
async function sameTree(sddRepo, codeRepo, ownId, a, b) {
|
|
375
|
+
const dir = checkoutDir(sddRepo, codeRepo, ownId);
|
|
376
|
+
try {
|
|
377
|
+
const [x, y] = await Promise.all([a, b].map(async (c) => (await execFileAsync("git", ["-C", dir, "rev-parse", `${c}^{tree}`])).stdout.trim()));
|
|
378
|
+
return x === y ? "same" : "differs";
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return "unresolved";
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
export async function checkBehaviourStore(repo, options = {}) {
|
|
385
|
+
const today = options.today ?? new Date().toISOString().slice(0, 10);
|
|
221
386
|
if (!(await exists(join(repo, STORE_DIR)))) {
|
|
222
387
|
return { exitCode: 0, stdout: `[N/A] no behaviour store (${STORE_DIR}/) in this repository\n` };
|
|
223
388
|
}
|
|
224
389
|
const lines = [];
|
|
225
390
|
const failures = [];
|
|
391
|
+
const warnings = [];
|
|
392
|
+
// The SDD's own id: a work order for the SDD's own repository is checked in the SDD itself.
|
|
393
|
+
let ownId = "";
|
|
394
|
+
try {
|
|
395
|
+
const m = parseDocument((await readText(join(repo, ".sdd", "sdd-manifest.yaml"))) ?? "").toJS();
|
|
396
|
+
ownId = String(m?.sdd_id ?? "");
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// the manifest check reports an unreadable manifest
|
|
400
|
+
}
|
|
226
401
|
// 1. records
|
|
227
402
|
const records = new Map();
|
|
228
403
|
let recordFiles = [];
|
|
@@ -244,6 +419,8 @@ export async function checkBehaviourStore(repo) {
|
|
|
244
419
|
const open = (await dirs(join(repo, STORE_DIR, "changes"))).filter((d) => d !== "archive");
|
|
245
420
|
let started = 0;
|
|
246
421
|
let staleChecked = 0;
|
|
422
|
+
let contentChecked = 0;
|
|
423
|
+
let kindsChecked = 0;
|
|
247
424
|
for (const change of open) {
|
|
248
425
|
const dir = `${STORE_DIR}/changes/${change}`;
|
|
249
426
|
const tasks = await readText(join(repo, dir, "tasks.md"));
|
|
@@ -262,19 +439,115 @@ export async function checkBehaviourStore(repo) {
|
|
|
262
439
|
else if (lastGateLine(gate) !== "GATE: CLEAR")
|
|
263
440
|
failures.push(`${dir}: has completed tasks but gate-0.md ends "${lastGateLine(gate)}", not "GATE: CLEAR"`);
|
|
264
441
|
}
|
|
442
|
+
if (record && !versionAtLeast(record.schemaVersion, "1.1.0") && (record.status === "agreed" || record.status === "proposed")) {
|
|
443
|
+
failures.push(`${record.file}: an open change's record must declare schema_version "1.1.0" or later (it has ${JSON.stringify(record.schemaVersion ?? null)}); the agreed-content, test-plan and verdict-evidence rules read it (agreement-record schema 1.1.0)`);
|
|
444
|
+
}
|
|
265
445
|
if (record?.status === "agreed" && record.commit) {
|
|
266
|
-
for
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
446
|
+
// One baseline for both checks: what was agreed, at the latest amendment's commit where one re-bound the content.
|
|
447
|
+
const agreedAt = record.amendCommit ?? record.commit;
|
|
448
|
+
const resolves = await commitResolves(repo, agreedAt);
|
|
449
|
+
const targeted = await targetedSpecs(repo, dir);
|
|
450
|
+
if (!resolves && targeted.length > 0) {
|
|
451
|
+
failures.push(`${dir}: stale check unresolved: commit ${agreedAt} is not in this clone's history (a shallow clone cannot run it)`);
|
|
452
|
+
}
|
|
453
|
+
// 3. stale, per requirement (M-ADR 0223 §4.5, §4.10): a change is stale when a requirement it targets moved since
|
|
454
|
+
// agreement, not when another requirement of the same spec did.
|
|
455
|
+
if (resolves) {
|
|
456
|
+
for (const spec of targeted) {
|
|
457
|
+
const feature = spec.split("/").at(-2);
|
|
458
|
+
const delta = (await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "";
|
|
459
|
+
const then = (await gitShow(repo, agreedAt, spec)) ?? "";
|
|
460
|
+
const now = (await gitShow(repo, "HEAD", spec)) ?? "";
|
|
461
|
+
for (const name of targetNames(delta)) {
|
|
462
|
+
staleChecked++;
|
|
463
|
+
const before = requirementBlock(then, name);
|
|
464
|
+
const after = requirementBlock(now, name);
|
|
465
|
+
if (before === null)
|
|
466
|
+
failures.push(`${dir}: stale check unresolved: requirement "${name}" of ${feature} is not in the living spec at the agreed commit ${agreedAt}`);
|
|
467
|
+
else if (after !== before)
|
|
468
|
+
failures.push(`${dir}: STALE: requirement "${name}" of ${feature} ${after === null ? "has left" : "changed in"} ${spec} since the agreed commit ${agreedAt}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// Agreement binds the content approved (M-ADR 0222 §4.6 as amended): the change's own artefacts equal their state
|
|
473
|
+
// at the pinned commit, the latest recorded amendment's or the agreement's.
|
|
474
|
+
const base = record.amendCommit ?? record.commit;
|
|
475
|
+
if (!(await commitResolves(repo, base))) {
|
|
476
|
+
failures.push(`${dir}: agreed content unresolved: commit ${base} is not in this clone's history`);
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
const then = (await gitFiles(repo, base, dir)).filter(boundFile);
|
|
480
|
+
const now = (await gitFiles(repo, "HEAD", dir)).filter(boundFile);
|
|
481
|
+
if (then.length === 0) {
|
|
482
|
+
failures.push(`${dir}: agreed content unresolved: the pinned commit ${base} holds no ${dir}/; pin the commit that holds the agreed change`);
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
contentChecked++;
|
|
486
|
+
for (const f of [...new Set([...then, ...now])].sort()) {
|
|
487
|
+
const a = await gitShow(repo, base, `${dir}/${f}`);
|
|
488
|
+
const b = await gitShow(repo, "HEAD", `${dir}/${f}`);
|
|
489
|
+
if (a === null || b === null || normaliseBound(f, a) !== normaliseBound(f, b)) {
|
|
490
|
+
const what = `${dir}: AGREED CONTENT CHANGED: ${f} ${a === null ? "was added" : b === null ? "was removed" : "differs"} since ${base}, with no amendment recording a commit and an approver; amend the agreement or renew it (M-ADR 0222 §4.6)`;
|
|
491
|
+
if (structuralFile(f) && !STRUCTURE_ENFORCED)
|
|
492
|
+
warnings.push(`${what}; a structural delta, report only while M-ADR 0230 is Proposed`);
|
|
493
|
+
else
|
|
494
|
+
failures.push(what);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// A test plan's kinds (M-ADR 0223 §4.4 as amended): changed scenarios go red then green, carried ones stay green.
|
|
501
|
+
if (versionAtLeast(record?.schemaVersion, "1.1.0") && record?.status === "agreed") {
|
|
502
|
+
const plan = await readText(join(repo, dir, "test-plan.md"));
|
|
503
|
+
if (plan !== null) {
|
|
504
|
+
const header = testPlanHeader(plan);
|
|
505
|
+
const col = (name) => header.indexOf(name);
|
|
506
|
+
if (col("kind") < 0)
|
|
507
|
+
failures.push(`${dir}/test-plan.md: the scenario table has no Kind column (changed | carried)`);
|
|
508
|
+
else {
|
|
509
|
+
for (const row of testPlanMapping(plan)) {
|
|
510
|
+
const cell = row[col("requirement")] ?? "";
|
|
511
|
+
const scen = row[col("scenario")] ?? "";
|
|
512
|
+
const kind = (row[col("kind")] ?? "").toLowerCase();
|
|
513
|
+
// `<feature>::<name>` names one feature's requirement; a bare name is read in every feature the change touches.
|
|
514
|
+
const q = /^(.+?)::(.+)$/.exec(cell);
|
|
515
|
+
const req = q ? q[2] : cell;
|
|
516
|
+
let expected = null;
|
|
517
|
+
const kinds = new Map();
|
|
518
|
+
for (const feature of await dirs(join(repo, dir, "specs"))) {
|
|
519
|
+
if (q && feature !== q[1])
|
|
520
|
+
continue;
|
|
521
|
+
const delta = (await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "";
|
|
522
|
+
const k = scenarioKind(delta, await readText(join(repo, STORE_DIR, "specs", feature, "spec.md")), req, scen);
|
|
523
|
+
if (k)
|
|
524
|
+
kinds.set(feature, k);
|
|
525
|
+
}
|
|
526
|
+
if (kinds.size > 1) {
|
|
527
|
+
failures.push(`${dir}/test-plan.md: "${cell}" / "${scen}" names a requirement ${kinds.size} features in the change have (${[...kinds.keys()].join(", ")}); qualify it as <feature>::<name>`);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
expected = [...kinds.values()][0] ?? null;
|
|
531
|
+
if (expected === null) {
|
|
532
|
+
failures.push(`${dir}/test-plan.md: "${cell}" / "${scen}" names no scenario the change's deltas hold; a misnamed row is not checked, so it fails`);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
kindsChecked++;
|
|
536
|
+
if (kind !== expected)
|
|
537
|
+
failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is ${expected} against the living spec, and the plan says ${JSON.stringify(kind)}`);
|
|
538
|
+
const before = col("before") >= 0 ? (row[col("before")] ?? "") : "";
|
|
539
|
+
if (before !== "" && expected === "changed" && !/fail|red/i.test(before))
|
|
540
|
+
failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is changed, so its test fails before the code; the scenario table's Before cell says ${JSON.stringify(before)}`);
|
|
541
|
+
if (before !== "" && expected === "carried" && !/pass|green/i.test(before))
|
|
542
|
+
failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is carried, so its test passes before the code as regression; the scenario table's Before cell says ${JSON.stringify(before)}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
273
545
|
}
|
|
274
546
|
}
|
|
275
547
|
}
|
|
276
548
|
lines.push(`agreement: ${open.length} open change(s), ${started} with completed tasks`);
|
|
277
|
-
lines.push(staleChecked === 0 ? "stale: vacuous (no open agreed change modifies, removes or renames a requirement)" : `stale: ${staleChecked} targeted
|
|
549
|
+
lines.push(staleChecked === 0 ? "stale: vacuous (no open agreed change modifies, removes or renames a requirement)" : `stale: ${staleChecked} targeted requirement(s) checked against their agreed commits`);
|
|
550
|
+
lines.push(`agreed content: ${contentChecked} open agreed change(s) compared with their pinned commits; ${kindsChecked} test-plan scenario kind(s) checked`);
|
|
278
551
|
// 8. overlap: two open, agreed changes never name the same requirement of one living spec (M-ADR 0223 §4.10).
|
|
279
552
|
// A name counts whether the delta adds, modifies, removes or renames it (both FROM and TO). Proposals may overlap.
|
|
280
553
|
const claimed = new Map();
|
|
@@ -301,6 +574,8 @@ export async function checkBehaviourStore(repo) {
|
|
|
301
574
|
// 4. archived changes
|
|
302
575
|
const archived = await dirs(join(repo, STORE_DIR, "changes", "archive"));
|
|
303
576
|
let baselines = 0;
|
|
577
|
+
let verdictsEvidenced = 0;
|
|
578
|
+
let pinsUnverified = 0;
|
|
304
579
|
for (const name of archived) {
|
|
305
580
|
const dir = `${STORE_DIR}/changes/archive/${name}`;
|
|
306
581
|
if (await exists(join(repo, dir, "baseline-evidence.md"))) {
|
|
@@ -318,10 +593,167 @@ export async function checkBehaviourStore(repo) {
|
|
|
318
593
|
if (record.verdict?.result !== "PASS")
|
|
319
594
|
failures.push(`${record.file}: change is archived but verdict.result is ${JSON.stringify(record.verdict?.result)}, not PASS`);
|
|
320
595
|
const vr = record.verdict?.record;
|
|
321
|
-
|
|
596
|
+
const verdictText = typeof vr === "string" ? await readText(join(repo, vr)) : null;
|
|
597
|
+
if (verdictText === null)
|
|
322
598
|
failures.push(`${record.file}: verdict.record ${JSON.stringify(vr)} does not name an existing file`);
|
|
599
|
+
// The record's verdict and the verdict file's agree (M-ADR 0223 §4.6 as amended), for every archived change.
|
|
600
|
+
const stated = verdictText === null ? null : verdictLine(verdictText);
|
|
601
|
+
if (stated !== null && stated !== record.verdict?.result)
|
|
602
|
+
failures.push(`${record.file}: the record says ${JSON.stringify(record.verdict?.result)} and ${vr} says ${stated}`);
|
|
603
|
+
// Which rules hold this record is decided from history, not from what the record says of itself now: the highest
|
|
604
|
+
// version any committed version of it declared, and the agreement-record schema the SDD shipped when the change
|
|
605
|
+
// was archived. A record cannot leave the 1.1.0 rules by declaring an older version.
|
|
606
|
+
const archivedAt = await integratedAt(repo, dir);
|
|
607
|
+
const ever = await recordVersionEver(repo, record.file);
|
|
608
|
+
if (ever === null) {
|
|
609
|
+
failures.push(`${record.file}: has no history in this clone, so which record rules hold it is unresolved (a record with no history is not taken to be legacy); run from a full clone`);
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
const everVersion = ever.highest;
|
|
613
|
+
const shipped = await shippedRecordSchema(repo, archivedAt);
|
|
614
|
+
if (versionAtLeast(everVersion, "1.1.0") && !versionAtLeast(record.schemaVersion, "1.1.0")) {
|
|
615
|
+
failures.push(`${record.file}: must declare schema_version "1.1.0" or later (it declares ${JSON.stringify(record.schemaVersion ?? null)}), because a committed version of it declared ${everVersion}; once a record has entered the 1.1.0 rules, declaring an older version does not take it out`);
|
|
616
|
+
}
|
|
617
|
+
else if (versionAtLeast(shipped, "1.1.0") && !versionAtLeast(record.schemaVersion, "1.1.0")) {
|
|
618
|
+
failures.push(`${record.file}: the change was archived when this SDD shipped agreement-record schema ${shipped}, so its record must declare schema_version "1.1.0" or later and carry the evidence`);
|
|
619
|
+
}
|
|
620
|
+
if (!(versionAtLeast(record.schemaVersion, "1.1.0") || versionAtLeast(everVersion, "1.1.0") || versionAtLeast(shipped, "1.1.0")))
|
|
621
|
+
continue;
|
|
622
|
+
verdictsEvidenced++;
|
|
623
|
+
// An archived change was agreed: the archive is not a way round the agreement checks.
|
|
624
|
+
if (record.status !== "agreed")
|
|
625
|
+
failures.push(`${record.file}: the change is archived, but its record is ${JSON.stringify(record.status)}, not agreed; an archive needs an approval, a pinned commit and a CLEAR Gate 0`);
|
|
626
|
+
const base = record.amendCommit ?? record.commit;
|
|
627
|
+
if (base && (await commitResolves(repo, base))) {
|
|
628
|
+
// The archived content is the agreed content (M-ADR 0222 §4.6): an edit made after agreement and carried into the
|
|
629
|
+
// archive fails as it would have while the change was open.
|
|
630
|
+
const openDir = `${STORE_DIR}/changes/${change}`;
|
|
631
|
+
const then = (await gitFiles(repo, base, openDir)).filter(boundFile);
|
|
632
|
+
const nowFiles = (await listFiles(join(repo, dir))).filter(boundFile);
|
|
633
|
+
if (then.length === 0)
|
|
634
|
+
failures.push(`${dir}: agreed content unresolved: the pinned commit ${base} holds no ${openDir}/`);
|
|
635
|
+
for (const f of [...new Set([...then, ...nowFiles])].sort()) {
|
|
636
|
+
if (then.length === 0)
|
|
637
|
+
break;
|
|
638
|
+
const a = await gitShow(repo, base, `${openDir}/${f}`);
|
|
639
|
+
const b = await readText(join(repo, dir, f));
|
|
640
|
+
if (a === null || b === null || normaliseBound(f, a) !== normaliseBound(f, b)) {
|
|
641
|
+
const what = `${dir}: AGREED CONTENT CHANGED: ${f} ${a === null ? "was added" : b === null ? "was removed" : "differs"} from what was agreed at ${base}, with no amendment recording a commit and an approver (M-ADR 0222 §4.6)`;
|
|
642
|
+
if (structuralFile(f) && !STRUCTURE_ENFORCED)
|
|
643
|
+
warnings.push(`${what}; a structural delta, report only while M-ADR 0230 is Proposed`);
|
|
644
|
+
else
|
|
645
|
+
failures.push(what);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
// Stale at archive: the requirements the change targets, just before it archived, are as they were agreed. After
|
|
649
|
+
// the archive the living specs hold the change's own deltas, so the comparison is with the state before it.
|
|
650
|
+
const before = archivedAt ? `${archivedAt}^` : "HEAD";
|
|
651
|
+
for (const feature of await dirs(join(repo, dir, "specs"))) {
|
|
652
|
+
const delta = (await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "";
|
|
653
|
+
const spec = `${STORE_DIR}/specs/${feature}/spec.md`;
|
|
654
|
+
const agreedSpec = (await gitShow(repo, base, spec)) ?? "";
|
|
655
|
+
const priorSpec = (await gitShow(repo, before, spec)) ?? "";
|
|
656
|
+
for (const name of targetNames(delta)) {
|
|
657
|
+
const x = requirementBlock(agreedSpec, name);
|
|
658
|
+
const y = requirementBlock(priorSpec, name);
|
|
659
|
+
if (x !== null && y !== x)
|
|
660
|
+
failures.push(`${dir}: STALE AT ARCHIVE: requirement "${name}" of ${feature} had changed since the agreed commit ${base} when the change archived`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
else if (base) {
|
|
665
|
+
failures.push(`${dir}: agreed content unresolved: commit ${base} is not in this clone's history`);
|
|
666
|
+
}
|
|
667
|
+
// Records on schema 1.1.0 carry the evidence, checked here, not declared.
|
|
668
|
+
if (verdictText !== null && stated === null)
|
|
669
|
+
failures.push(`${vr}: states no verdict line (\`Verdict: PASS\` or \`Verdict: FAIL\`)`);
|
|
670
|
+
const v = record.verdict ?? {};
|
|
671
|
+
const by = typeof v.judged_by === "string" ? v.judged_by.trim() : "";
|
|
672
|
+
const applier = typeof v.applied_by === "string" ? v.applied_by.trim() : "";
|
|
673
|
+
if (by === "" || applier === "")
|
|
674
|
+
failures.push(`${record.file}: verdict names ${by === "" ? "no judged_by" : ""}${by === "" && applier === "" ? " and " : ""}${applier === "" ? "no applied_by" : ""} (M-ADR 0223 §4.5: the judge is independent of the applier)`);
|
|
675
|
+
else if (by.toLowerCase() === applier.toLowerCase())
|
|
676
|
+
failures.push(`${record.file}: verdict.judged_by and verdict.applied_by are both "${by}"; the judge is independent of the applier (M-ADR 0223 §4.5)`);
|
|
677
|
+
// The repositories whose evidence is required come from the change itself, not from the record's list: each task
|
|
678
|
+
// group's repository, and a work order for every requirement the deltas add or modify.
|
|
679
|
+
const groups = taskGroups((await readText(join(repo, dir, "tasks.md"))) ?? "");
|
|
680
|
+
const recorded = new Set(record.workOrders.map((w) => w.repo));
|
|
681
|
+
for (const g of groups)
|
|
682
|
+
if (!recorded.has(g.repo))
|
|
683
|
+
failures.push(`${record.file}: tasks.md has a work order for ${g.repo} and the record's work_orders do not; its evidence cannot be checked`);
|
|
684
|
+
// Requirement names are unique within a feature, not across a change: a work order names `<feature>::<name>`,
|
|
685
|
+
// or a bare name only one feature in the change has (schema 5.0.0). An ambiguous bare name implements nothing.
|
|
686
|
+
const built = [];
|
|
687
|
+
for (const feature of await dirs(join(repo, dir, "specs"))) {
|
|
688
|
+
const delta = (await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "";
|
|
689
|
+
for (const r of parseRequirements(delta))
|
|
690
|
+
if (r.operation === "ADDED" || r.operation === "MODIFIED")
|
|
691
|
+
built.push({ feature, name: r.name, operation: r.operation });
|
|
692
|
+
}
|
|
693
|
+
const covers = (entries, d) => entries.some((e) => resolveImplements(built, e).matches.some((m) => m.feature === d.feature && m.name === d.name));
|
|
694
|
+
for (const w of record.workOrders) {
|
|
695
|
+
for (const e of w.implements) {
|
|
696
|
+
const { ambiguous } = resolveImplements(built, e);
|
|
697
|
+
if (ambiguous.length > 0)
|
|
698
|
+
failures.push(`${record.file}: the work order for ${w.repo} implements "${e}", a name ${ambiguous.length} features in the change share (${ambiguous.join(", ")}); qualify it as <feature>::<name>`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
for (const d of built) {
|
|
702
|
+
if (!covers(record.workOrders.flatMap((w) => w.implements), d)) {
|
|
703
|
+
failures.push(`${record.file}: requirement "${d.name}" (${d.feature}) is ${d.operation} by the change and no work order implements it${covers(groups.flatMap((g) => g.implements), d) ? " (tasks.md says one does; the record's work_orders do not)" : ""}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const workOrders = [...record.workOrders, ...groups.filter((g) => !recorded.has(g.repo))];
|
|
707
|
+
const judged = (v.judged && typeof v.judged === "object" ? v.judged : {});
|
|
708
|
+
const merged = (v.merged && typeof v.merged === "object" ? v.merged : {});
|
|
709
|
+
const plan = (await readText(join(repo, dir, "test-plan.md"))) ?? "";
|
|
710
|
+
const ledger = plan.split(/^##\s+Ledger\b/m)[1]?.split(/^##\s/m)[0] ?? "";
|
|
711
|
+
for (const wo of workOrders) {
|
|
712
|
+
const j = judged[wo.repo];
|
|
713
|
+
const mList = merged[wo.repo];
|
|
714
|
+
const m = Array.isArray(mList) ? mList.at(-1) : mList;
|
|
715
|
+
if (wo.implements.length > 0 && !ledger.includes(wo.repo))
|
|
716
|
+
failures.push(`${dir}/test-plan.md: the ledger does not name ${wo.repo}, which implements requirements; the verdict has no red/green evidence for it`);
|
|
717
|
+
if (!isCommit(j)) {
|
|
718
|
+
failures.push(`${record.file}: verdict.judged ${j === undefined ? "names no commit" : `names ${JSON.stringify(j)}, not a commit`} for ${wo.repo}; the judge judges each repository's candidate commit before it merges (M-ADR 0223 §4.5)`);
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
if (!isCommit(m)) {
|
|
722
|
+
failures.push(`${record.file}: verdict.merged ${m === undefined ? "names no commit" : `names ${JSON.stringify(m)}, not a commit`} for ${wo.repo}`);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
const checkout = await codeCheckout(repo, wo.repo, ownId);
|
|
726
|
+
if (checkout) {
|
|
727
|
+
for (const c of [j, m])
|
|
728
|
+
if (!(await commitResolves(checkout, c)))
|
|
729
|
+
failures.push(`${record.file}: ${wo.repo} has no commit ${c} in the checkout beside the SDD`);
|
|
730
|
+
}
|
|
731
|
+
else if (sameCommit(j, m))
|
|
732
|
+
pinsUnverified++;
|
|
733
|
+
if (!sameCommit(j, m)) {
|
|
734
|
+
const cmp = await sameTree(repo, wo.repo, ownId, j, m);
|
|
735
|
+
if (cmp === "differs")
|
|
736
|
+
failures.push(`${record.file}: ${wo.repo} merged ${m}, whose tree differs from the judged ${j}: what merged is not what was judged`);
|
|
737
|
+
if (cmp === "unresolved")
|
|
738
|
+
failures.push(`${record.file}: ${wo.repo} merged ${m}, not the judged ${j}, and their trees could not be compared (no checkout beside the SDD holds both): unresolved`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
323
741
|
}
|
|
324
|
-
lines.push(`verdicts: ${archived.length} archived change(s), ${baselines} baseline(s), ${archived.length - baselines} needing a PASS verdict`);
|
|
742
|
+
lines.push(`verdicts: ${archived.length} archived change(s), ${baselines} baseline(s), ${archived.length - baselines} needing a PASS verdict, ${verdictsEvidenced} held to record schema 1.1.0 with their evidence checked`);
|
|
743
|
+
// A pin that cannot be resolved is a partial result, never a pass: a [WARN] the run counts as warned, and a failure
|
|
744
|
+
// under --strict, which is how the judge runs it, with the code repositories checked out (M-ADR 0223 §4.6).
|
|
745
|
+
if (pinsUnverified > 0)
|
|
746
|
+
warnings.push(`verdict pins: ${pinsUnverified} repository pin pair(s) are well-formed and consistent, but no checkout beside the SDD resolves them, so the verified-commit claim is unconfirmed here; run with the code repositories checked out beside the SDD, and with --strict where the claim must hold`);
|
|
747
|
+
// Conditions an agreement was made on (M-ADR 0222 §4.6 as amended): each recorded in full, none open past its date.
|
|
748
|
+
let conditions = 0;
|
|
749
|
+
for (const rec of records.values()) {
|
|
750
|
+
for (const [i, c] of rec.conditions.entries()) {
|
|
751
|
+
conditions++;
|
|
752
|
+
for (const p of conditionProblems(c ?? {}, today, rec.status))
|
|
753
|
+
failures.push(`${rec.file}: conditions[${i}]: ${p}`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
lines.push(`conditions: ${conditions} agreement condition(s)`);
|
|
325
757
|
// 5. surfaces, and 6. what each baseline may change, in archive order
|
|
326
758
|
const archiveRel = `${STORE_DIR}/changes/archive`;
|
|
327
759
|
const { order, ties, history } = await orderArchives(repo, archiveRel, archived);
|
|
@@ -351,6 +783,10 @@ export async function checkBehaviourStore(repo) {
|
|
|
351
783
|
let scopeUnresolved = 0;
|
|
352
784
|
const servedBy = baselines > 0 ? await catalogueContracts(repo) : null;
|
|
353
785
|
let surfacesChecked = 0;
|
|
786
|
+
// A baseline whose every requirement has since left the living specs (removed, or its spec retired) records a
|
|
787
|
+
// surface that no longer exists; its Surfaces list is history, not checked against today's catalogue, or a
|
|
788
|
+
// retired surface could never drop its contract.
|
|
789
|
+
const historical = await historicalBaselines(repo, archiveRel, order);
|
|
354
790
|
const lastTouch = new Map();
|
|
355
791
|
let scopeChecked = 0;
|
|
356
792
|
for (const name of order) {
|
|
@@ -363,7 +799,10 @@ export async function checkBehaviourStore(repo) {
|
|
|
363
799
|
failures.push(`${dir}/baseline-evidence.md: no **Surfaces:** list; name every contract the observed repositories serve, covered at transport or excluded with a reason`);
|
|
364
800
|
for (const p of parsed.problems)
|
|
365
801
|
failures.push(`${dir}/baseline-evidence.md: Surfaces: ${p}`);
|
|
366
|
-
if (parsed.present) {
|
|
802
|
+
if (parsed.present && historical.has(name)) {
|
|
803
|
+
// history: see historicalBaselines
|
|
804
|
+
}
|
|
805
|
+
else if (parsed.present) {
|
|
367
806
|
if (servedBy === null) {
|
|
368
807
|
failures.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: catalogues/repo_contract.csv or catalogues/contracts.csv is missing; build the catalogue`);
|
|
369
808
|
}
|
|
@@ -417,7 +856,7 @@ export async function checkBehaviourStore(repo) {
|
|
|
417
856
|
}
|
|
418
857
|
}
|
|
419
858
|
const unseen = servedBy === null ? "" : `; repositories with no catalogue contracts: ${[...new Set(await observedInAll(repo, archiveRel, order))].filter((r) => (servedBy.get(r) ?? []).length === 0).join(", ") || "none"}`;
|
|
420
|
-
lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv${unseen}`);
|
|
859
|
+
lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv${unseen}${historical.size ? `; ${historical.size} historical baseline(s) not checked (every requirement they baselined has left the living specs)` : ""}`);
|
|
421
860
|
lines.push(scopeChecked === 0 ? "scope: vacuous (no baseline removes, modifies or renames a requirement)" : `scope: ${scopeChecked} requirement(s) a baseline removes, modifies or renames, checked against their lineage`);
|
|
422
861
|
if (!history && archived.length > 0)
|
|
423
862
|
lines.push("order: no git history (not a git work tree, or git log failed), so same-day archives cannot be ordered");
|
|
@@ -471,11 +910,60 @@ export async function checkBehaviourStore(repo) {
|
|
|
471
910
|
const population = recordFiles.length + open.length + archived.length;
|
|
472
911
|
const body = lines.map((l) => ` ${l}`).join("\n") + "\n";
|
|
473
912
|
if (failures.length > 0) {
|
|
474
|
-
return { exitCode: 1, stdout: body + failures.map((f) => `[FAIL] ${f}`).join("\n") + "\n" };
|
|
913
|
+
return { exitCode: 1, stdout: body + warnings.map((w) => `[WARN] ${w}\n`).join("") + failures.map((f) => `[FAIL] ${f}`).join("\n") + "\n" };
|
|
475
914
|
}
|
|
476
915
|
if (population === 0)
|
|
477
916
|
return { exitCode: 0, stdout: body + `[N/A] the behaviour store has no changes and no agreement records yet\n` };
|
|
478
|
-
|
|
917
|
+
const warned = warnings.map((w) => `[WARN] ${w}\n`).join("");
|
|
918
|
+
return { exitCode: 0, stdout: body + warned + "[PASS] behaviour store: records, agreement, stale, overlap, verdict, surfaces, scope, feature and schema checks hold" + (warnings.length ? ", with the warnings above" : "") + "\n" };
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Baselines whose requirements have all left the living specs. Each baseline's requirements are followed forward
|
|
922
|
+
* through every later archive in order (a REMOVED drops one, a RENAMED carries it to its new name); the baseline is
|
|
923
|
+
* historical when none of what remains is a requirement of a living spec today. A baseline that names no
|
|
924
|
+
* requirement is never historical.
|
|
925
|
+
*/
|
|
926
|
+
async function historicalBaselines(repo, archiveRel, order) {
|
|
927
|
+
const living = new Set();
|
|
928
|
+
for (const feature of await dirs(join(repo, STORE_DIR, "specs"))) {
|
|
929
|
+
const text = (await readText(join(repo, STORE_DIR, "specs", feature, "spec.md"))) ?? "";
|
|
930
|
+
for (const r of parseRequirements(text))
|
|
931
|
+
living.add(`${feature}::${r.name}`);
|
|
932
|
+
}
|
|
933
|
+
const deltas = [];
|
|
934
|
+
for (const name of order) {
|
|
935
|
+
const specs = [];
|
|
936
|
+
for (const feature of await dirs(join(repo, archiveRel, name, "specs"))) {
|
|
937
|
+
specs.push({ feature, text: (await readText(join(repo, archiveRel, name, "specs", feature, "spec.md"))) ?? "" });
|
|
938
|
+
}
|
|
939
|
+
deltas.push({ name, baseline: await exists(join(repo, archiveRel, name, "baseline-evidence.md")), specs });
|
|
940
|
+
}
|
|
941
|
+
const out = new Set();
|
|
942
|
+
deltas.forEach((d, i) => {
|
|
943
|
+
if (!d.baseline)
|
|
944
|
+
return;
|
|
945
|
+
const keys = new Set();
|
|
946
|
+
for (const sp of d.specs)
|
|
947
|
+
for (const r of parseRequirements(sp.text))
|
|
948
|
+
if (r.operation !== "REMOVED")
|
|
949
|
+
keys.add(`${sp.feature}::${r.name}`);
|
|
950
|
+
if (keys.size === 0)
|
|
951
|
+
return;
|
|
952
|
+
for (const later of deltas.slice(i + 1)) {
|
|
953
|
+
for (const sp of later.specs) {
|
|
954
|
+
for (const r of parseRequirements(sp.text))
|
|
955
|
+
if (r.operation === "REMOVED")
|
|
956
|
+
keys.delete(`${sp.feature}::${r.name}`);
|
|
957
|
+
for (const p of renamePairs(sp.text)) {
|
|
958
|
+
if (keys.delete(`${sp.feature}::${p.from}`))
|
|
959
|
+
keys.add(`${sp.feature}::${p.to}`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
if (![...keys].some((k) => living.has(k)))
|
|
964
|
+
out.add(d.name);
|
|
965
|
+
});
|
|
966
|
+
return out;
|
|
479
967
|
}
|
|
480
968
|
/** Per repository, the contracts the catalogue says it serves (repo_contract ⋈ contracts.spec_path); null when either file is missing. */
|
|
481
969
|
async function catalogueContracts(repo) {
|