@sdd-method/verify-cli 5.3.2 → 5.4.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 +62 -9
- package/dist/lib/validate/archetype-citations.js +15 -6
- package/dist/lib/validate/behaviour-store.js +45 -5
- package/dist/lib/validate/carve-citations.js +228 -0
- package/dist/lib/validate/dispatch.js +7 -0
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -136,15 +136,28 @@ export async function orderArchives(sddPath, archiveRel, names) {
|
|
|
136
136
|
export function observedNone(evidence) {
|
|
137
137
|
return /^\s*\*\*Observed in:\*\*\s*none\b/im.test(evidence);
|
|
138
138
|
}
|
|
139
|
-
/**
|
|
140
|
-
|
|
139
|
+
/** One `**Observed in:**` entry: `<repository> @ <commit>`, either side optionally in backticks. */
|
|
140
|
+
const OBSERVED_ENTRY = /^`?([A-Za-z0-9][A-Za-z0-9._/-]*)`?\s*@\s*`?([0-9a-fA-F]{7,40})`?$/;
|
|
141
|
+
function observedEntries(evidence) {
|
|
141
142
|
const m = /^\s*\*\*Observed in:\*\*\s*(.+)$/m.exec(evidence);
|
|
142
143
|
if (!m || observedNone(evidence))
|
|
143
144
|
return [];
|
|
144
|
-
return m[1]
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
return m[1].split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The repositories a baseline was observed in: its evidence file's `**Observed in:** <repository> @ <commit>[, ...]`
|
|
149
|
+
* line. Only entries of that shape are read; anything else on the line is reported by `observedInProblems` and never
|
|
150
|
+
* taken for a repository.
|
|
151
|
+
*/
|
|
152
|
+
export function observedIn(evidence) {
|
|
153
|
+
return observedEntries(evidence).flatMap((e) => {
|
|
154
|
+
const m = OBSERVED_ENTRY.exec(e);
|
|
155
|
+
return m ? [m[1]] : [];
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/** The `**Observed in:**` entries not shaped `<repository> @ <commit>` (detail belongs in the body, not on the line). */
|
|
159
|
+
export function observedInProblems(evidence) {
|
|
160
|
+
return observedEntries(evidence).filter((e) => !OBSERVED_ENTRY.test(e));
|
|
148
161
|
}
|
|
149
162
|
/**
|
|
150
163
|
* A baseline's `**Surfaces:**` list (M-ADR 0222 §4.3, §4.4; agreement-record-schema.yaml, Baselines): one entry per
|
|
@@ -165,20 +178,30 @@ export function parseSurfaces(evidence) {
|
|
|
165
178
|
// the list is what the file ends up asserting.
|
|
166
179
|
let at = -1;
|
|
167
180
|
let none = false;
|
|
181
|
+
let generated = false;
|
|
168
182
|
lines.forEach((l, i) => {
|
|
169
183
|
if (/^\s*\*\*Surfaces:\*\*\s*$/.test(l)) {
|
|
170
184
|
at = i;
|
|
171
185
|
none = false;
|
|
186
|
+
generated = false;
|
|
172
187
|
}
|
|
173
188
|
else if (/^\s*\*\*Surfaces:\*\*\s*none\b/i.test(l)) {
|
|
174
189
|
at = i;
|
|
175
190
|
none = true;
|
|
191
|
+
generated = false;
|
|
192
|
+
}
|
|
193
|
+
else if (/^\s*\*\*Surfaces:\*\*\s*generated\b/i.test(l)) {
|
|
194
|
+
at = i;
|
|
195
|
+
none = false;
|
|
196
|
+
generated = true;
|
|
176
197
|
}
|
|
177
198
|
});
|
|
178
199
|
if (at < 0)
|
|
179
|
-
return { present: false, none: false, entries: [], problems: [] };
|
|
200
|
+
return { present: false, none: false, generated: false, entries: [], problems: [] };
|
|
180
201
|
if (none)
|
|
181
|
-
return { present: true, none: true, entries: [], problems: [] };
|
|
202
|
+
return { present: true, none: true, generated: false, entries: [], problems: [] };
|
|
203
|
+
if (generated)
|
|
204
|
+
return { present: true, none: false, generated: true, entries: [], problems: [] };
|
|
182
205
|
const entries = [];
|
|
183
206
|
const problems = [];
|
|
184
207
|
for (const line of lines.slice(at + 1)) {
|
|
@@ -213,7 +236,37 @@ export function parseSurfaces(evidence) {
|
|
|
213
236
|
}
|
|
214
237
|
if (entries.length + problems.length === 0)
|
|
215
238
|
problems.push('the **Surfaces:** line lists no contract; a baseline that admits no behaviour writes "**Surfaces:** none"');
|
|
216
|
-
return { present: true, none: false, entries, problems };
|
|
239
|
+
return { present: true, none: false, generated: false, entries, problems };
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* A generator's baseline (agreement-record-schema.yaml, Baselines: generated behaviour) serves no contract; its
|
|
243
|
+
* transport is the generated tree, and the run that generated it is evidence only if another reader can repeat it.
|
|
244
|
+
* The run is recorded on a line of its own:
|
|
245
|
+
* **Generated by:** <repository> @ <commit>; run: `<command and options>`; tree: <hash>[, <hash>]
|
|
246
|
+
* where each tree hash is hex of 12 or more digits, optionally `sha256:`-prefixed and optionally labelled `<kit>=`.
|
|
247
|
+
* Returns what is wrong with the line, or that it is absent.
|
|
248
|
+
*/
|
|
249
|
+
export function generatedByProblems(evidence) {
|
|
250
|
+
const line = evidence.split("\n").filter((l) => /^\s*\*\*Generated by:\*\*/.test(l)).pop();
|
|
251
|
+
if (line === undefined)
|
|
252
|
+
return ["no **Generated by:** line"];
|
|
253
|
+
const body = line.replace(/^\s*\*\*Generated by:\*\*\s*/, "");
|
|
254
|
+
const parts = body.split(/;\s*(?=run:|tree:)/);
|
|
255
|
+
const out = [];
|
|
256
|
+
if (!/^`?[A-Za-z0-9][A-Za-z0-9._/-]*`?\s*@\s*`?[0-9a-fA-F]{7,40}`?\s*$/.test(parts[0] ?? ""))
|
|
257
|
+
out.push(`"${(parts[0] ?? "").trim()}" is not "<generator repository> @ <commit>"`);
|
|
258
|
+
const run = parts.find((p) => p.startsWith("run:"));
|
|
259
|
+
if (!run || !/^run:\s*`[^`]+`\s*$/.test(run.trim()))
|
|
260
|
+
out.push("no run: `<command and options>`");
|
|
261
|
+
const tree = parts.find((p) => p.startsWith("tree:"));
|
|
262
|
+
const hashes = tree ? tree.replace(/^tree:\s*/, "").split(",").map((h) => h.trim()).filter((h) => h !== "") : [];
|
|
263
|
+
if (hashes.length === 0)
|
|
264
|
+
out.push("no tree: <content hash of the generated tree>");
|
|
265
|
+
for (const h of hashes) {
|
|
266
|
+
if (!/^(?:[A-Za-z0-9._-]+=)?(?:sha256:)?[0-9a-fA-F]{12,}$/.test(h.replace(/`/g, "")))
|
|
267
|
+
out.push(`tree hash "${h}" is not hex of 12 or more digits`);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
217
270
|
}
|
|
218
271
|
/**
|
|
219
272
|
* A structural delta's `moved: {contracts: [{from, to, from_path?}]}` block. A contract's id is derived from its path,
|
|
@@ -77,22 +77,29 @@ export async function checkArchetypeCitations(repo) {
|
|
|
77
77
|
const lines = [];
|
|
78
78
|
let problems = 0;
|
|
79
79
|
let total = 0;
|
|
80
|
+
// A citation file that cannot be read as a whole (it does not parse, names no cached archetype, or lists nothing)
|
|
81
|
+
// has no citations to count, so it is counted apart: "1 of 0" would read as a failure with no population.
|
|
82
|
+
let fileProblems = 0;
|
|
80
83
|
const bad = (tag, msg) => {
|
|
81
84
|
lines.push(`[${tag}] ${msg}`);
|
|
82
85
|
problems++;
|
|
83
86
|
};
|
|
87
|
+
const badFile = (msg) => {
|
|
88
|
+
lines.push(`[unresolved] ${msg}`);
|
|
89
|
+
fileProblems++;
|
|
90
|
+
};
|
|
84
91
|
for (const [rel, text] of files) {
|
|
85
92
|
let doc;
|
|
86
93
|
try {
|
|
87
94
|
doc = (parseYaml(text) ?? {});
|
|
88
95
|
}
|
|
89
96
|
catch (e) {
|
|
90
|
-
|
|
97
|
+
badFile(`${rel}: does not parse: ${e.message.split("\n")[0]}`);
|
|
91
98
|
continue;
|
|
92
99
|
}
|
|
93
100
|
const archetype = archetypes.find((a) => a.id === doc.archetype && a.bundleName === doc.bundle);
|
|
94
101
|
if (!archetype) {
|
|
95
|
-
|
|
102
|
+
badFile(`${rel}: archetype ${String(doc.archetype)} of bundle ${String(doc.bundle)} is not in the bundle cache (sync the bundle)`);
|
|
96
103
|
continue;
|
|
97
104
|
}
|
|
98
105
|
const reqs = await loadArchetypeBehaviour(archetype.bundleCacheDir, archetype.seeds);
|
|
@@ -101,7 +108,7 @@ export async function checkArchetypeCitations(repo) {
|
|
|
101
108
|
const cites = Array.isArray(doc.citations) ? doc.citations : [];
|
|
102
109
|
const notAdopted = Array.isArray(doc.not_adopted) ? doc.not_adopted : [];
|
|
103
110
|
if (cites.length === 0 && notAdopted.length === 0)
|
|
104
|
-
|
|
111
|
+
badFile(`${rel}: lists no citations`);
|
|
105
112
|
const citedIds = new Set(cites.map((c) => String(c.cites)));
|
|
106
113
|
for (const n of notAdopted) {
|
|
107
114
|
total++;
|
|
@@ -161,9 +168,11 @@ export async function checkArchetypeCitations(repo) {
|
|
|
161
168
|
`(${[...citedIds].filter((id) => byId.has(id)).length} cited, ${notAdopted.length} not adopted)` +
|
|
162
169
|
(gaps.length ? `; not yet answered: ${gaps.join(", ")}` : ""));
|
|
163
170
|
}
|
|
164
|
-
const
|
|
165
|
-
|
|
171
|
+
const filePart = fileProblems ? `${fileProblems} of ${files.length} citation file(s) unresolved` : "";
|
|
172
|
+
const citePart = problems ? `${problems} of ${total} citation(s) unresolved or stale` : "";
|
|
173
|
+
const verdict = problems || fileProblems
|
|
174
|
+
? `[FAIL] archetype citations: ${[filePart, citePart].filter(Boolean).join("; ")}${problems ? "" : `; ${total} citation(s) checked resolve`}`
|
|
166
175
|
: `[PASS] archetype citations: ${total} resolve, none stale`;
|
|
167
|
-
return { exitCode: problems ? 1 : 0, stdout: lines.join("\n") + "\n" + verdict + "\n" };
|
|
176
|
+
return { exitCode: problems || fileProblems ? 1 : 0, stdout: lines.join("\n") + "\n" + verdict + "\n" };
|
|
168
177
|
}
|
|
169
178
|
//# sourceMappingURL=archetype-citations.js.map
|
|
@@ -8,7 +8,8 @@
|
|
|
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)
|
|
11
|
+
// ends CLEAR (the pre-apply check, M-ADR 0223 §4.3); an open baseline (baseline-evidence.md)
|
|
12
|
+
// has neither and is not asked for them. A record for an open change must
|
|
12
13
|
// declare agreement-record schema 1.1.0 or later.
|
|
13
14
|
// 3. stale: for each open, agreed change, each requirement it MODIFIES, REMOVES or RENAMES is, block by
|
|
14
15
|
// block, as it was at the agreed commit (the latest amendment's, else sdd.commit; M-ADR 0223
|
|
@@ -73,7 +74,7 @@ import { tmpdir } from "node:os";
|
|
|
73
74
|
import { basename, join } from "node:path";
|
|
74
75
|
import { promisify } from "node:util";
|
|
75
76
|
import { parseDocument } from "yaml";
|
|
76
|
-
import { contractMoves, observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, renamePairs, resolveImplements, surfaceMatches } from "../behaviour-store/archive.js";
|
|
77
|
+
import { contractMoves, generatedByProblems, observedIn, observedInProblems, orderArchives, parseRequirements, parseSurfaces, renamedFrom, renamePairs, resolveImplements, surfaceMatches } from "../behaviour-store/archive.js";
|
|
77
78
|
import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
78
79
|
import { boundFile, conditionProblems, structuralFile, isCommit, normaliseBound, requirementBlock, sameCommit, scenarioKind, taskGroups, testPlanHeader, testPlanMapping, verdictLine, versionAtLeast } from "./agreement-evidence.js";
|
|
79
80
|
import { ENFORCED as STRUCTURE_ENFORCED } from "./structural-model.js";
|
|
@@ -443,6 +444,7 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
443
444
|
// 2 and 3. open changes
|
|
444
445
|
const open = (await dirs(join(repo, STORE_DIR, "changes"))).filter((d) => d !== "archive");
|
|
445
446
|
let started = 0;
|
|
447
|
+
let baselinesTicked = 0;
|
|
446
448
|
let staleChecked = 0;
|
|
447
449
|
let contentChecked = 0;
|
|
448
450
|
let kindsChecked = 0;
|
|
@@ -450,7 +452,13 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
450
452
|
const dir = `${STORE_DIR}/changes/${change}`;
|
|
451
453
|
const tasks = await readText(join(repo, dir, "tasks.md"));
|
|
452
454
|
const record = records.get(change);
|
|
453
|
-
|
|
455
|
+
// A baseline (its baseline-evidence.md, the signal the archive side reads) has no agreement record and no Gate 0;
|
|
456
|
+
// its tasks are the characterization tests, ticked as each is written (sdd-baseline, M-ADR 0222 §4.3). Its gate is
|
|
457
|
+
// checked when it archives.
|
|
458
|
+
const baseline = await exists(join(repo, dir, "baseline-evidence.md"));
|
|
459
|
+
if (tasks !== null && TASK_DONE_RE.test(tasks) && baseline)
|
|
460
|
+
baselinesTicked++;
|
|
461
|
+
if (tasks !== null && TASK_DONE_RE.test(tasks) && !baseline) {
|
|
454
462
|
started++;
|
|
455
463
|
if (!record && recordFiles.some((f) => f.replace(/\.ya?ml$/, "") === change))
|
|
456
464
|
failures.push(`${dir}: has completed tasks but its agreement record (${AGREEMENTS_DIR}/${change}.yaml) is malformed; see its record failures`);
|
|
@@ -596,7 +604,7 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
596
604
|
}
|
|
597
605
|
}
|
|
598
606
|
}
|
|
599
|
-
lines.push(`agreement: ${open.length} open change(s), ${started} with completed tasks`);
|
|
607
|
+
lines.push(`agreement: ${open.length} open change(s), ${started} with completed tasks${baselinesTicked > 0 ? `; ${baselinesTicked} open baseline(s) with ticked characterization tests, which need no agreement or Gate 0` : ""}`);
|
|
600
608
|
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`);
|
|
601
609
|
lines.push(`agreed content: ${contentChecked} open agreed change(s) compared with their pinned commits; ${kindsChecked} test-plan scenario kind(s) checked`);
|
|
602
610
|
// 8. overlap: two open, agreed changes never name the same requirement of one living spec (M-ADR 0223 §4.10).
|
|
@@ -839,6 +847,7 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
839
847
|
const servedBy = baselines + openBaselines.length > 0 ? await catalogueContracts(repo) : null;
|
|
840
848
|
let surfacesChecked = 0;
|
|
841
849
|
let structureOnly = 0;
|
|
850
|
+
let generatedBaselines = 0;
|
|
842
851
|
// The contract moves each archived change records, in archive order. A contract's id derives from its path, so a
|
|
843
852
|
// move changes it; an archived baseline's list names the contract as it was, and is resolved through the moves
|
|
844
853
|
// archived after it rather than edited (M-ADR 0222 §4.4). A surface that vanished with no move still fails.
|
|
@@ -916,9 +925,16 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
916
925
|
/** What is wrong with a baseline's Surfaces list against the catalogue (M-ADR 0222 §4.3, §4.4). */
|
|
917
926
|
const surfaceProblems = async (dir, evidence, after = -1) => {
|
|
918
927
|
const out = [];
|
|
928
|
+
const malformed = observedInProblems(evidence);
|
|
929
|
+
if (malformed.length > 0) {
|
|
930
|
+
const shown = malformed.slice(0, 3).map((e) => JSON.stringify(e.length > 60 ? `${e.slice(0, 57)}...` : e)).join(", ");
|
|
931
|
+
out.push(`${dir}/baseline-evidence.md: **Observed in:** has ${malformed.length} entr${malformed.length === 1 ? "y" : "ies"} not shaped "<repository> @ <commit>" (${shown}${malformed.length > 3 ? ", ..." : ""}); the line holds only those, comma-separated, and detail goes in the body (agreement-record-schema.yaml, Baselines)`);
|
|
932
|
+
}
|
|
919
933
|
const parsed = parseSurfaces(evidence);
|
|
920
934
|
const admits = await admitsBehaviour(dir);
|
|
921
935
|
if (!admits) {
|
|
936
|
+
if (parsed.generated)
|
|
937
|
+
return [`${dir}/baseline-evidence.md: **Surfaces:** generated, but the baseline admits no behaviour (no requirement in its spec deltas): write "**Surfaces:** none"`];
|
|
922
938
|
if (parsed.none) {
|
|
923
939
|
structureOnly++;
|
|
924
940
|
return out;
|
|
@@ -930,6 +946,30 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
930
946
|
else if (parsed.none) {
|
|
931
947
|
return [`${dir}/baseline-evidence.md: **Surfaces:** none, but the baseline admits behaviour (requirements in ${dir}/specs/); name every contract the observed repositories serve, covered at transport or excluded with a reason`];
|
|
932
948
|
}
|
|
949
|
+
if (parsed.generated) {
|
|
950
|
+
// A generator serves nothing: its transport is the generated tree (agreement-record-schema.yaml, Baselines). The
|
|
951
|
+
// claim holds only where the catalogue confirms that no observed repository serves a contract, and where the run
|
|
952
|
+
// is recorded so another reader can repeat it; anything else fails, so the claim is never a way past the list.
|
|
953
|
+
for (const p of generatedByProblems(evidence)) {
|
|
954
|
+
out.push(`${dir}/baseline-evidence.md: **Surfaces:** generated needs the run recorded as "**Generated by:** <generator repository> @ <commit>; run: \`<command and options>\`; tree: <hash>": ${p}`);
|
|
955
|
+
}
|
|
956
|
+
if (servedBy === null) {
|
|
957
|
+
out.push(`${dir}/baseline-evidence.md: **Surfaces:** generated cannot be cross-checked: catalogues/repo_contract.csv or catalogues/contracts.csv is missing; build the catalogue`);
|
|
958
|
+
return out;
|
|
959
|
+
}
|
|
960
|
+
const repos = observedIn(evidence);
|
|
961
|
+
if (repos.length === 0)
|
|
962
|
+
out.push(`${dir}/baseline-evidence.md: **Surfaces:** generated cannot be cross-checked: no **Observed in:** line names a repository`);
|
|
963
|
+
for (const r of repos) {
|
|
964
|
+
const served = servedBy.get(r)?.served ?? [];
|
|
965
|
+
if (served.length > 0) {
|
|
966
|
+
out.push(`${dir}/baseline-evidence.md: **Surfaces:** generated, but ${r} serves ${served.length} contract(s) (catalogues/repo_contract.csv: ${served.slice(0, 3).map((c) => c.id).join(", ")}${served.length > 3 ? ", ..." : ""}); a repository that serves contracts lists each, covered at transport or excluded with a reason`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (out.length === 0)
|
|
970
|
+
generatedBaselines++;
|
|
971
|
+
return out;
|
|
972
|
+
}
|
|
933
973
|
if (!parsed.present)
|
|
934
974
|
out.push(`${dir}/baseline-evidence.md: no **Surfaces:** list; name every contract the observed repositories serve, covered at transport or excluded with a reason`);
|
|
935
975
|
for (const p of parsed.problems)
|
|
@@ -1048,7 +1088,7 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
1048
1088
|
if (openBaselines.length > 0)
|
|
1049
1089
|
lines.push(`open baselines: ${openBaselines.length}, asked the archive's questions as warnings`);
|
|
1050
1090
|
const unseen = servedBy === null ? "" : `; repositories with no catalogue contracts: ${[...new Set(await observedInAll(repo, archiveRel, order))].filter((r) => (servedBy.get(r)?.served ?? []).length === 0).join(", ") || "none"}`;
|
|
1051
|
-
lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv less what service_contract records only consumed${unseen}${structureOnly ? `; ${structureOnly} structure-only baseline(s) admitting no behaviour, Surfaces: none` : ""}${historical.size ? `; ${historical.size} historical baseline(s) not checked (every requirement they baselined has left the living specs)` : ""}`);
|
|
1091
|
+
lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv less what service_contract records only consumed${unseen}${structureOnly ? `; ${structureOnly} structure-only baseline(s) admitting no behaviour, Surfaces: none` : ""}${generatedBaselines ? `; ${generatedBaselines} generator baseline(s) serving no contract, Surfaces: generated` : ""}${historical.size ? `; ${historical.size} historical baseline(s) not checked (every requirement they baselined has left the living specs)` : ""}`);
|
|
1052
1092
|
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`);
|
|
1053
1093
|
if (!history && archived.length > 0)
|
|
1054
1094
|
lines.push("order: no git history (not a git work tree, or git log failed), so same-day archives cannot be ordered");
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// carve-citations: behaviour carved out of one SDD into another keeps one home at a time
|
|
2
|
+
// (docs/method/methods/guides/carving-behaviour-into-a-new-sdd.md; M-ADR 0221, 0222).
|
|
3
|
+
//
|
|
4
|
+
// The new SDD's requirement names what it replaces, on a line of its own:
|
|
5
|
+
// Replaces <sdd-id>:<feature>::<requirement name>.
|
|
6
|
+
// Departs from <sdd-id>:<feature>::<requirement name>: <why>.
|
|
7
|
+
// At cutover the old SDD removes the requirement, its Reason naming the replacement:
|
|
8
|
+
// replaced by <sdd-id>:<feature>::<requirement name>
|
|
9
|
+
//
|
|
10
|
+
// The other SDD is found beside this one: a sibling directory whose .sdd/sdd-manifest.yaml has that sdd_id. Checks:
|
|
11
|
+
// replaces: each Replaces / Departs line parses, and names a requirement the other SDD has: living there (in
|
|
12
|
+
// flight), or removed by an archived change (carved).
|
|
13
|
+
// replaced: each removal's `replaced by` names a requirement living in the other SDD.
|
|
14
|
+
// one home: a requirement removed here as replaced by another SDD is not living here again; and no two living
|
|
15
|
+
// requirements here replace the same requirement.
|
|
16
|
+
// A citation in a living spec or an archived change fails; one in an open change is a [WARN] (it is not agreed yet),
|
|
17
|
+
// as is any citation whose other SDD has no checkout beside this one (unresolved, failed under --strict). Cutover
|
|
18
|
+
// itself is an event of the estate's, not recorded in the store, so a requirement both SDDs hold before the old one is
|
|
19
|
+
// removed is "in flight" and is reported in the inventory, not failed.
|
|
20
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
21
|
+
import { basename, dirname, join } from "node:path";
|
|
22
|
+
const STORE = "openspec";
|
|
23
|
+
const REQ_HEAD = /^###\s+Requirement:\s*(.+?)\s*$/;
|
|
24
|
+
const SECTION = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
|
|
25
|
+
const CITE_START = /^\s*(Replaces|Departs from)\s/;
|
|
26
|
+
const REPLACES = /^\s*Replaces\s+([A-Za-z0-9._-]+):([A-Za-z0-9._-]+)::(.+?)\.\s*$/;
|
|
27
|
+
const DEPARTS = /^\s*Departs from\s+([A-Za-z0-9._-]+):([A-Za-z0-9._-]+)::(.+?):\s+\S.*$/;
|
|
28
|
+
const REPLACED_BY = /replaced by\s+`?([A-Za-z0-9._-]+):([A-Za-z0-9._-]+)::([^`\n]+?)`?\s*(?:[.;,)]\s*)?$/im;
|
|
29
|
+
const key = (r) => `${r.sdd}:${r.feature}::${r.name}`;
|
|
30
|
+
async function read(p) {
|
|
31
|
+
return readFile(p, "utf8").catch(() => null);
|
|
32
|
+
}
|
|
33
|
+
async function dirs(p) {
|
|
34
|
+
try {
|
|
35
|
+
return (await readdir(p, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Requirement blocks of a spec or delta file, each with the section it sits in. */
|
|
42
|
+
function blocks(text, living) {
|
|
43
|
+
const out = [];
|
|
44
|
+
let op = living ? "living" : "";
|
|
45
|
+
let cur = null;
|
|
46
|
+
for (const line of text.split("\n")) {
|
|
47
|
+
const s = SECTION.exec(line);
|
|
48
|
+
if (s) {
|
|
49
|
+
if (cur)
|
|
50
|
+
out.push(cur);
|
|
51
|
+
cur = null;
|
|
52
|
+
op = s[1].toUpperCase();
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const h = REQ_HEAD.exec(line);
|
|
56
|
+
if (h) {
|
|
57
|
+
if (cur)
|
|
58
|
+
out.push(cur);
|
|
59
|
+
cur = { name: h[1], op, text: "" };
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (/^##\s/.test(line) && cur) {
|
|
63
|
+
out.push(cur);
|
|
64
|
+
cur = null;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (cur)
|
|
68
|
+
cur.text += `${line}\n`;
|
|
69
|
+
}
|
|
70
|
+
if (cur)
|
|
71
|
+
out.push(cur);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
async function sddId(repo) {
|
|
75
|
+
const m = /^sdd_id:\s*["']?([^"'\n]+?)["']?\s*$/m.exec((await read(join(repo, ".sdd", "sdd-manifest.yaml"))) ?? "");
|
|
76
|
+
return m ? m[1] : "";
|
|
77
|
+
}
|
|
78
|
+
async function view(repo) {
|
|
79
|
+
const living = new Map();
|
|
80
|
+
for (const f of await dirs(join(repo, STORE, "specs"))) {
|
|
81
|
+
const t = await read(join(repo, STORE, "specs", f, "spec.md"));
|
|
82
|
+
if (t !== null)
|
|
83
|
+
living.set(f, new Set(blocks(t, true).map((b) => b.name)));
|
|
84
|
+
}
|
|
85
|
+
const removed = new Map();
|
|
86
|
+
for (const c of await dirs(join(repo, STORE, "changes", "archive"))) {
|
|
87
|
+
for (const f of await dirs(join(repo, STORE, "changes", "archive", c, "specs"))) {
|
|
88
|
+
const t = await read(join(repo, STORE, "changes", "archive", c, "specs", f, "spec.md"));
|
|
89
|
+
for (const b of t === null ? [] : blocks(t, false)) {
|
|
90
|
+
if (b.op === "REMOVED")
|
|
91
|
+
(removed.get(f) ?? removed.set(f, new Set()).get(f)).add(b.name);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { living, removed };
|
|
96
|
+
}
|
|
97
|
+
export async function checkCarveCitations(repo) {
|
|
98
|
+
const own = await sddId(repo);
|
|
99
|
+
const failures = [];
|
|
100
|
+
const warnings = [];
|
|
101
|
+
const cites = [];
|
|
102
|
+
const removals = [];
|
|
103
|
+
const collect = (rel, text, living, open, removalsOnly = false) => {
|
|
104
|
+
for (const b of blocks(text, living)) {
|
|
105
|
+
if (removalsOnly && b.op !== "REMOVED")
|
|
106
|
+
continue;
|
|
107
|
+
if (b.op === "REMOVED") {
|
|
108
|
+
const m = REPLACED_BY.exec(b.text);
|
|
109
|
+
if (m)
|
|
110
|
+
removals.push({ where: `${rel}: '${b.name}'`, open, ref: { sdd: m[1], feature: m[2], name: m[3].trim() }, feature: basename(dirname(rel)), name: b.name });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
for (const line of b.text.split("\n")) {
|
|
114
|
+
if (!CITE_START.test(line))
|
|
115
|
+
continue;
|
|
116
|
+
const r = REPLACES.exec(line);
|
|
117
|
+
const d = r ? null : DEPARTS.exec(line);
|
|
118
|
+
const m = r ?? d;
|
|
119
|
+
cites.push({
|
|
120
|
+
where: `${rel}: '${b.name}'`,
|
|
121
|
+
open,
|
|
122
|
+
line: line.trim(),
|
|
123
|
+
ref: m ? { sdd: m[1], feature: m[2], name: m[3].trim() } : null,
|
|
124
|
+
kind: /^\s*Departs/.test(line) ? "departs" : "replaces",
|
|
125
|
+
owner: living ? `${basename(dirname(rel))}::${b.name}` : "",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
for (const f of await dirs(join(repo, STORE, "specs"))) {
|
|
131
|
+
const rel = `${STORE}/specs/${f}/spec.md`;
|
|
132
|
+
const t = await read(join(repo, rel));
|
|
133
|
+
if (t !== null)
|
|
134
|
+
collect(rel, t, true, false);
|
|
135
|
+
}
|
|
136
|
+
for (const c of await dirs(join(repo, STORE, "changes"))) {
|
|
137
|
+
const archived = c === "archive";
|
|
138
|
+
for (const ch of archived ? await dirs(join(repo, STORE, "changes", "archive")) : [c]) {
|
|
139
|
+
const base = archived ? `${STORE}/changes/archive/${ch}` : `${STORE}/changes/${ch}`;
|
|
140
|
+
for (const f of await dirs(join(repo, base, "specs"))) {
|
|
141
|
+
const rel = `${base}/specs/${f}/spec.md`;
|
|
142
|
+
const t = await read(join(repo, rel));
|
|
143
|
+
// An archived delta's Replaces lines live on in the living spec it produced; only its removals are read here.
|
|
144
|
+
if (t !== null)
|
|
145
|
+
collect(rel, t, false, !archived, archived);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (cites.length === 0 && removals.length === 0) {
|
|
150
|
+
return { exitCode: 0, stdout: "[N/A] no carve citations (Replaces / Departs from lines, or removals replaced by another SDD) in this repository\n" };
|
|
151
|
+
}
|
|
152
|
+
// The other SDDs, found beside this one by their sdd_id.
|
|
153
|
+
const siblings = new Map();
|
|
154
|
+
const parent = dirname(repo);
|
|
155
|
+
for (const d of await dirs(parent)) {
|
|
156
|
+
const p = join(parent, d);
|
|
157
|
+
const id = await sddId(p);
|
|
158
|
+
if (id !== "" && !siblings.has(id))
|
|
159
|
+
siblings.set(id, p);
|
|
160
|
+
}
|
|
161
|
+
const self = await view(repo);
|
|
162
|
+
if (own !== "")
|
|
163
|
+
siblings.set(own, repo);
|
|
164
|
+
const views = new Map();
|
|
165
|
+
const other = async (id) => {
|
|
166
|
+
const p = siblings.get(id);
|
|
167
|
+
if (!p)
|
|
168
|
+
return null;
|
|
169
|
+
if (!views.has(id))
|
|
170
|
+
views.set(id, p === repo ? self : await view(p));
|
|
171
|
+
return views.get(id);
|
|
172
|
+
};
|
|
173
|
+
const report = (open, msg) => (open ? warnings : failures).push(msg);
|
|
174
|
+
let inFlight = 0;
|
|
175
|
+
let carved = 0;
|
|
176
|
+
let unresolved = 0;
|
|
177
|
+
const replacedBy = new Map();
|
|
178
|
+
for (const c of cites) {
|
|
179
|
+
if (c.ref === null) {
|
|
180
|
+
report(c.open, `${c.where}: "${c.line}" is not "Replaces <sdd-id>:<feature>::<requirement name>." or "Departs from <sdd-id>:<feature>::<requirement name>: <why>."`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const v = await other(c.ref.sdd);
|
|
184
|
+
if (v === null) {
|
|
185
|
+
unresolved++;
|
|
186
|
+
warnings.push(`${c.where}: ${key(c.ref)} unresolved: no checkout beside this SDD has sdd_id "${c.ref.sdd}"; check it out beside this one (failed under --strict)`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (v.living.get(c.ref.feature)?.has(c.ref.name))
|
|
190
|
+
inFlight++;
|
|
191
|
+
else if (v.removed.get(c.ref.feature)?.has(c.ref.name))
|
|
192
|
+
carved++;
|
|
193
|
+
else {
|
|
194
|
+
report(c.open, `${c.where}: ${c.kind === "departs" ? "Departs from" : "Replaces"} ${key(c.ref)}, which ${c.ref.sdd} neither has nor removed (feature ${c.ref.feature}${v.living.has(c.ref.feature) ? "" : ", which it has no living spec for"})`);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (c.owner !== "")
|
|
198
|
+
(replacedBy.get(key(c.ref)) ?? replacedBy.set(key(c.ref), []).get(key(c.ref))).push(c.owner);
|
|
199
|
+
}
|
|
200
|
+
for (const [k, owners] of replacedBy) {
|
|
201
|
+
if (owners.length > 1)
|
|
202
|
+
failures.push(`one home: ${k} is replaced by ${owners.length} living requirements here (${owners.join(", ")}); one requirement carries it`);
|
|
203
|
+
}
|
|
204
|
+
for (const r of removals) {
|
|
205
|
+
const v = await other(r.ref.sdd);
|
|
206
|
+
if (v === null) {
|
|
207
|
+
unresolved++;
|
|
208
|
+
warnings.push(`${r.where}: replaced by ${key(r.ref)} unresolved: no checkout beside this SDD has sdd_id "${r.ref.sdd}" (failed under --strict)`);
|
|
209
|
+
}
|
|
210
|
+
else if (!v.living.get(r.ref.feature)?.has(r.ref.name)) {
|
|
211
|
+
report(r.open, `${r.where}: removed as replaced by ${key(r.ref)}, which is not a living requirement of ${r.ref.sdd}`);
|
|
212
|
+
}
|
|
213
|
+
if (!r.open && self.living.get(r.feature)?.has(r.name)) {
|
|
214
|
+
failures.push(`one home: ${r.where}: removed here as replaced by ${key(r.ref)}, but '${r.name}' is living in ${STORE}/specs/${r.feature}/spec.md again`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const lines = [
|
|
218
|
+
` citations: ${cites.length} Replaces / Departs line(s): ${inFlight} in flight (still living where they came from), ${carved} carved (removed there), ${unresolved} unresolved`,
|
|
219
|
+
` removals: ${removals.length} replaced by another SDD`,
|
|
220
|
+
];
|
|
221
|
+
const body = lines.join("\n") + "\n";
|
|
222
|
+
const warn = warnings.map((w) => `[WARN] ${w}\n`).join("");
|
|
223
|
+
if (failures.length > 0) {
|
|
224
|
+
return { exitCode: 1, stdout: body + failures.map((f) => `[FAIL] ${f}\n`).join("") + warn + `[FAIL] carve citations: ${failures.length} problem(s)\n` };
|
|
225
|
+
}
|
|
226
|
+
return { exitCode: 0, stdout: body + warn + `[PASS] carve citations: one home each${warnings.length ? ", with the warnings above" : ""}\n` };
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=carve-citations.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { checkArchetypeCitations } from "./archetype-citations.js";
|
|
2
2
|
import { checkBehaviourStore, checkBehaviourStoreOpenspec } from "./behaviour-store.js";
|
|
3
3
|
import { checkBundleCoherence } from "./bundle-coherence.js";
|
|
4
|
+
import { checkCarveCitations } from "./carve-citations.js";
|
|
4
5
|
import { checkFeatureSpecPointers } from "./feature-spec-pointers.js";
|
|
5
6
|
import { checkIntents } from "./intents.js";
|
|
6
7
|
import { checkJourneyImpact } from "./journey-impact.js";
|
|
@@ -25,6 +26,12 @@ const BEHAVIOUR_STORE_VALIDATORS = [
|
|
|
25
26
|
source: { kind: "native", run: async (ctx) => checkArchetypeCitations(ctx.targetRepo) },
|
|
26
27
|
build: () => ({ status: "run", args: [] }),
|
|
27
28
|
},
|
|
29
|
+
// Behaviour carved into another SDD keeps one home at a time (the carving guide); without citations it reports [N/A].
|
|
30
|
+
{
|
|
31
|
+
name: "carve-citations",
|
|
32
|
+
source: { kind: "native", run: async (ctx) => checkCarveCitations(ctx.targetRepo) },
|
|
33
|
+
build: () => ({ status: "run", args: [] }),
|
|
34
|
+
},
|
|
28
35
|
// Intents (M-ADR 0227) can sit in an SDD of any profile; without any the check reports [N/A].
|
|
29
36
|
{
|
|
30
37
|
name: "intents",
|
package/dist/verify-version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdd-method/verify-cli",
|
|
3
|
-
"version": "5.
|
|
4
|
-
"sddCliVersion": "5.
|
|
3
|
+
"version": "5.4.0",
|
|
4
|
+
"sddCliVersion": "5.4.0",
|
|
5
5
|
"description": "Verify an SDD repository without changing it — validate, list, and read-only catalogue and pack checks. Ships only the code those verbs reach; it cannot write to the repository it is pointed at. Authoring and maintenance are in @sdd-method/sdd-cli.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"type": "module",
|