@sdd-method/author-cli 0.16.2 → 0.17.1
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/catalogue/build.js +3 -0
- package/dist/lib/catalogue/builders/requirements.js +6 -2
- package/dist/lib/structure/archive.js +11 -5
- package/dist/lib/structure/extract.js +6 -0
- package/dist/lib/structure/view.js +8 -6
- 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/lib/validate/structural-model.js +4 -4
- 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,
|
|
@@ -110,6 +110,7 @@ export async function runCatalogueBuild(inputs, write) {
|
|
|
110
110
|
await reportCollapsedFamilies(outDir, join(sddPath, "catalogues"), rowsByCatalogue, findings);
|
|
111
111
|
// 4. Write CSVs (headers + rows).
|
|
112
112
|
const rowCounts = new Map();
|
|
113
|
+
const preserved = [];
|
|
113
114
|
const ownCatalogues = resolve(join(sddPath, "catalogues"));
|
|
114
115
|
for (const cat of schema.catalogues) {
|
|
115
116
|
const rows = rowsByCatalogue.get(cat.name) ?? [];
|
|
@@ -134,6 +135,7 @@ export async function runCatalogueBuild(inputs, write) {
|
|
|
134
135
|
// also silent, and that is why 26 SDDs lost their conformance evidence
|
|
135
136
|
// while their registries went on citing it.
|
|
136
137
|
if (result.preserved) {
|
|
138
|
+
preserved.push({ name: cat.name, rows: result.preservedRows ?? result.rowCount });
|
|
137
139
|
findings.add("convention", `catalogues/${cat.name}.csv: this build produced NO rows for it, and the file on disk ` +
|
|
138
140
|
`holds ${result.preservedRows}. The existing file was LEFT UNTOUCHED rather than ` +
|
|
139
141
|
`replaced with a bare header. Either the catalogue has no producer in this CLI — in ` +
|
|
@@ -237,6 +239,7 @@ export async function runCatalogueBuild(inputs, write) {
|
|
|
237
239
|
findingsCount: findings.count(),
|
|
238
240
|
pkConflicts: dedupe.conflicts.length,
|
|
239
241
|
unionedRows: dedupe.unionedRows,
|
|
242
|
+
preserved,
|
|
240
243
|
outDir,
|
|
241
244
|
findings: findings.all(),
|
|
242
245
|
};
|
|
@@ -37,12 +37,12 @@ import { readdir, readFile, stat } from "node:fs/promises";
|
|
|
37
37
|
import { join } from "node:path";
|
|
38
38
|
import { parse as parseYaml } from "yaml";
|
|
39
39
|
import { createHash } from "node:crypto";
|
|
40
|
-
import { observedIn, orderArchives, parseRequirements, renamePairs, resolveImplements } from "../../behaviour-store/archive.js";
|
|
40
|
+
import { observedIn, observedInProblems, orderArchives, parseRequirements, renamePairs, resolveImplements } from "../../behaviour-store/archive.js";
|
|
41
41
|
import { effectsOf, parseAppliedRecord } from "../../structure/effects.js";
|
|
42
42
|
import { APPLIED_FILE, loadChangeStructure, loadModel } from "../../structure/model.js";
|
|
43
43
|
import { viewOf } from "../../structure/view.js";
|
|
44
44
|
import { requirementBlock } from "../../validate/agreement-evidence.js";
|
|
45
|
-
export { observedIn, parseRequirements, resolveImplements };
|
|
45
|
+
export { observedIn, observedInProblems, parseRequirements, resolveImplements };
|
|
46
46
|
/** `sha256:` of a requirement block, its lines trimmed and blank lines dropped (M-ADR 0230 §4.7). */
|
|
47
47
|
export function requirementDigest(spec, name) {
|
|
48
48
|
const block = requirementBlock(spec, name);
|
|
@@ -276,6 +276,10 @@ export async function buildRequirements(inputs) {
|
|
|
276
276
|
structureEffects.push(effectRow(scoped(dir), "structure", e.element, e.operation, e.base ?? "", e.target ?? ""));
|
|
277
277
|
if (evidence !== null) {
|
|
278
278
|
const repos = observedIn(evidence);
|
|
279
|
+
const malformed = observedInProblems(evidence);
|
|
280
|
+
if (malformed.length > 0) {
|
|
281
|
+
findings.add("convention", `${rel(sddPath, join(changeDir, "baseline-evidence.md"))}: "**Observed in:**" has ${malformed.length} entr${malformed.length === 1 ? "y" : "ies"} not shaped "<repository> @ <commit>", not read as repositories; detail goes in the body`);
|
|
282
|
+
}
|
|
279
283
|
if (repos.length === 0 && delta.length > 0) {
|
|
280
284
|
findings.add("convention", `${rel(sddPath, join(changeDir, "baseline-evidence.md"))}: no "**Observed in:**" line, so its requirements carry no observed status`);
|
|
281
285
|
}
|
|
@@ -65,9 +65,9 @@ async function defaultBehaviourFindings(sddPath, archiveDir) {
|
|
|
65
65
|
const r = await checkBehaviourStore(sddPath);
|
|
66
66
|
return r.stdout.split("\n").filter((l) => l.startsWith("[FAIL]") && l.includes(archiveDir));
|
|
67
67
|
}
|
|
68
|
-
function viewProblems(schema, view) {
|
|
68
|
+
function viewProblems(schema, view, profile) {
|
|
69
69
|
const out = [];
|
|
70
|
-
validateView(schema, view, () => null, out, new Set());
|
|
70
|
+
validateView(schema, view, () => null, out, new Set(), () => undefined, profile);
|
|
71
71
|
return new Set(out);
|
|
72
72
|
}
|
|
73
73
|
/** Does the effect record hold at `commit`? Each added or modified element at its target, each removed one absent. */
|
|
@@ -98,6 +98,11 @@ async function verifyAt(root, relSdd, commit, effects) {
|
|
|
98
98
|
await rm(scratch, { recursive: true, force: true });
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
+
/** The SDD's profile from its manifest, "" when absent or unreadable. */
|
|
102
|
+
async function sddProfile(sddPath) {
|
|
103
|
+
const text = await readFile(join(sddPath, ".sdd", "sdd-manifest.yaml"), "utf8").catch(() => "");
|
|
104
|
+
return /^profile:\s*["']?([A-Za-z0-9-]+)["']?\s*$/m.exec(text)?.[1] ?? "";
|
|
105
|
+
}
|
|
101
106
|
export async function archiveChange(o) {
|
|
102
107
|
const say = (s) => o.write(`${s}\n`);
|
|
103
108
|
const refuse = (s) => {
|
|
@@ -241,6 +246,7 @@ async function publish(o, root, relSdd, schema, s, changeRel, mode) {
|
|
|
241
246
|
say(`[FAIL] change archive ${o.change}: ${x}`);
|
|
242
247
|
return 1;
|
|
243
248
|
};
|
|
249
|
+
const profile = await sddProfile(o.sddPath);
|
|
244
250
|
const storeRel = join(relSdd, "openspec");
|
|
245
251
|
const viewsRel = join(relSdd, "docs", "structure");
|
|
246
252
|
const dirty = await git(root, "status", "--porcelain", "--", storeRel, viewsRel);
|
|
@@ -250,9 +256,9 @@ async function publish(o, root, relSdd, schema, s, changeRel, mode) {
|
|
|
250
256
|
// §4.5 again, against the model as it now is.
|
|
251
257
|
const problems = [];
|
|
252
258
|
const current = viewOf((await loadModel(o.sddPath, problems)) ?? [], problems);
|
|
253
|
-
const before = viewProblems(schema, current);
|
|
259
|
+
const before = viewProblems(schema, current, profile);
|
|
254
260
|
const deltaProblems = [];
|
|
255
|
-
validateDeltas(schema, current, s.deltas, new Map(), deltaProblems);
|
|
261
|
+
validateDeltas(schema, current, s.deltas, new Map(), deltaProblems, profile);
|
|
256
262
|
if (deltaProblems.length > 0)
|
|
257
263
|
return refuse(`its structural deltas no longer apply to the model as it stands:\n ${deltaProblems.join("\n ")}`);
|
|
258
264
|
const effects = effectsOf(s, current);
|
|
@@ -280,7 +286,7 @@ async function publish(o, root, relSdd, schema, s, changeRel, mode) {
|
|
|
280
286
|
// 2. Validate the candidate as a whole.
|
|
281
287
|
const cp = [];
|
|
282
288
|
const candidate = viewOf((await loadModel(scratchSdd, cp)) ?? [], cp);
|
|
283
|
-
const after = viewProblems(schema, candidate);
|
|
289
|
+
const after = viewProblems(schema, candidate, profile);
|
|
284
290
|
const worse = [...cp, ...[...after].filter((p) => !before.has(p))];
|
|
285
291
|
if (worse.length > 0)
|
|
286
292
|
return refuse(`the model as it would stand fails where it did not:\n ${worse.join("\n ")}`);
|
|
@@ -75,6 +75,12 @@ export async function extractStructure(sddPath, build = runCatalogueBuild) {
|
|
|
75
75
|
if (built.exitCode !== 0) {
|
|
76
76
|
return { ...none, refused: `the sources do not build cleanly (sdd-cli catalogue build exits ${built.exitCode}: ${built.internalFkPass ? "" : "dangling references, "}${built.pkConflicts} key conflict(s)); a model extracted from them would carry the same faults. Fix the build first` };
|
|
77
77
|
}
|
|
78
|
+
// A catalogue the build produced no rows for keeps the SDD's file (catalogue build never replaces rows with a bare
|
|
79
|
+
// header). Those rows came from no source, so a model "extracted from its sources" must not carry them.
|
|
80
|
+
const kept = built.preserved.filter((p) => schema.catalogues.some((c) => c.name === p.name && c.sourceOfTruth === STRUCTURE));
|
|
81
|
+
if (kept.length > 0) {
|
|
82
|
+
return { ...none, refused: `${kept.map((k) => `catalogues/${k.name}.csv holds ${k.rows} row(s)`).join("; ")} that no source gives any more (the build produced none and kept the file); a model extracted now would carry them as if derived. Restore the source, or delete those rows from catalogues/ (keep the header), then extract` };
|
|
83
|
+
}
|
|
78
84
|
const rows = new Map();
|
|
79
85
|
for (const c of schema.catalogues) {
|
|
80
86
|
if (c.sourceOfTruth !== STRUCTURE)
|
|
@@ -90,13 +90,15 @@ function find(view, catalogue, column, value) {
|
|
|
90
90
|
return undefined;
|
|
91
91
|
}
|
|
92
92
|
/** The element's references: each non-external foreign key with a value. */
|
|
93
|
-
export function referencesOf(schema, e) {
|
|
93
|
+
export function referencesOf(schema, e, profile = "") {
|
|
94
94
|
const cat = catalogueOf(schema, e.catalogue);
|
|
95
95
|
if (!cat)
|
|
96
96
|
return [];
|
|
97
97
|
const out = [];
|
|
98
98
|
for (const fk of cat.foreignKeys) {
|
|
99
|
-
|
|
99
|
+
// An external key names another SDD's element; so does one external for this SDD's profile (an application or
|
|
100
|
+
// integration model naming a data item the platform model owns), as the FK validator and the graph projection read it.
|
|
101
|
+
if (fk.external || (profile !== "" && (fk.externalForProfiles ?? []).includes(profile)))
|
|
100
102
|
continue;
|
|
101
103
|
const v = e.columns[fk.column];
|
|
102
104
|
if (v === undefined || v === "")
|
|
@@ -121,7 +123,7 @@ function naturalKey(cat, e) {
|
|
|
121
123
|
* catalogue of another class, against its built rows), no cycle through a self-reference, natural keys unique, and
|
|
122
124
|
* domain invariants naming their entity's own attributes and relationships.
|
|
123
125
|
*/
|
|
124
|
-
export function validateView(schema, view, built, problems, unresolved, addedBy = () => undefined) {
|
|
126
|
+
export function validateView(schema, view, built, problems, unresolved, addedBy = () => undefined, profile = "") {
|
|
125
127
|
// A reference to an open change's addition, from outside that change and its dependants (§4.11 proof 3).
|
|
126
128
|
const outside = (catalogue, id) => {
|
|
127
129
|
const by = addedBy(catalogue, id);
|
|
@@ -135,7 +137,7 @@ export function validateView(schema, view, built, problems, unresolved, addedBy
|
|
|
135
137
|
problems.push(...shape);
|
|
136
138
|
if (!cat || cat.sourceOfTruth !== STRUCTURE)
|
|
137
139
|
continue;
|
|
138
|
-
for (const r of referencesOf(schema, e)) {
|
|
140
|
+
for (const r of referencesOf(schema, e, profile)) {
|
|
139
141
|
if (r.structural) {
|
|
140
142
|
if (!find(view, r.catalogue, r.targetColumn, r.value))
|
|
141
143
|
problems.push(`${e.file}: ${elementKey(catalogue, e.id)}: ${r.column} ${JSON.stringify(r.value)} names no ${r.catalogue} element in this view (${elementKey(r.catalogue, r.value)})${r.targetColumn === "id" ? outside(r.catalogue, r.value) : ""}`);
|
|
@@ -218,7 +220,7 @@ export function validateView(schema, view, built, problems, unresolved, addedBy
|
|
|
218
220
|
* A base revision that no longer matches the current element is reported as moved: the change is revalidated.
|
|
219
221
|
* `addedElsewhere` holds the elements other open changes add, for a reference into one of them.
|
|
220
222
|
*/
|
|
221
|
-
export function validateDeltas(schema, current, deltas, addedElsewhere, problems) {
|
|
223
|
+
export function validateDeltas(schema, current, deltas, addedElsewhere, problems, profile = "") {
|
|
222
224
|
const ownAdded = new Set();
|
|
223
225
|
const dependsOn = new Set();
|
|
224
226
|
for (const d of deltas) {
|
|
@@ -281,7 +283,7 @@ export function validateDeltas(schema, current, deltas, addedElsewhere, problems
|
|
|
281
283
|
}
|
|
282
284
|
// Reads: every element an added or modified element references.
|
|
283
285
|
for (const e of [...d.added, ...d.modified]) {
|
|
284
|
-
for (const r of referencesOf(schema, e)) {
|
|
286
|
+
for (const r of referencesOf(schema, e, profile)) {
|
|
285
287
|
if (!r.structural)
|
|
286
288
|
continue;
|
|
287
289
|
const target = r.targetColumn === "id" ? r.value : find(current, r.catalogue, r.targetColumn, r.value)?.id;
|
|
@@ -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",
|
|
@@ -162,7 +162,7 @@ export async function checkStructuralModel(repo, options = {}) {
|
|
|
162
162
|
// The current view.
|
|
163
163
|
const unresolved = new Set();
|
|
164
164
|
const currentProblems = [];
|
|
165
|
-
validateView(schema, view, builtIn(built, allNew, new Set()), currentProblems, unresolved, addedBy);
|
|
165
|
+
validateView(schema, view, builtIn(built, allNew, new Set()), currentProblems, unresolved, addedBy, profile);
|
|
166
166
|
problems.push(...currentProblems);
|
|
167
167
|
const elementCount = [...view.values()].reduce((n, m) => n + m.size, 0);
|
|
168
168
|
lines.push(files === null ? `model: none (${STRUCTURE_DIR}/ absent)` : `model: ${files.length} partition file(s), ${elementCount} element(s) in ${view.size} catalogue(s)`);
|
|
@@ -211,7 +211,7 @@ export async function checkStructuralModel(repo, options = {}) {
|
|
|
211
211
|
if (s.noOp && s.deltas.length > 0)
|
|
212
212
|
problems.push(`${s.noOp.file}: a no-op claim beside ${s.deltas.length} partition delta(s); state one or the other`);
|
|
213
213
|
const elsewhere = new Map([...addedByChange].filter(([, c]) => c !== s.change));
|
|
214
|
-
validateDeltas(schema, view, s.deltas, elsewhere, problems);
|
|
214
|
+
validateDeltas(schema, view, s.deltas, elsewhere, problems, profile);
|
|
215
215
|
// A behaviour delta for a feature the model does not hold needs that feature added here.
|
|
216
216
|
const adds = new Set(s.deltas.flatMap((d) => d.added.filter((e) => e.catalogue === "features").map((e) => e.id)));
|
|
217
217
|
for (const feature of await subdirs(join(repo, dir, "specs"))) {
|
|
@@ -238,7 +238,7 @@ export async function checkStructuralModel(repo, options = {}) {
|
|
|
238
238
|
if (oc.structure.deltas.length === 0)
|
|
239
239
|
continue;
|
|
240
240
|
const at = [];
|
|
241
|
-
validateView(schema, viewAt(view, name, open), builtIn(built, allNew, additions.contracts(closure(name, open))), at, new Set(), addedBy);
|
|
241
|
+
validateView(schema, viewAt(view, name, open), builtIn(built, allNew, additions.contracts(closure(name, open))), at, new Set(), addedBy, profile);
|
|
242
242
|
for (const p of at) {
|
|
243
243
|
if (known.has(p))
|
|
244
244
|
continue;
|
|
@@ -250,7 +250,7 @@ export async function checkStructuralModel(repo, options = {}) {
|
|
|
250
250
|
const agreed = agreedOrder(open);
|
|
251
251
|
if (agreed.length > 0) {
|
|
252
252
|
const inIntended = [];
|
|
253
|
-
validateView(schema, intended(view, open), builtIn(built, allNew, additions.contracts(agreed)), inIntended, new Set(), addedBy);
|
|
253
|
+
validateView(schema, intended(view, open), builtIn(built, allNew, additions.contracts(agreed)), inIntended, new Set(), addedBy, profile);
|
|
254
254
|
for (const p of inIntended) {
|
|
255
255
|
if (known.has(p) || atAgreed.has(p))
|
|
256
256
|
continue;
|
package/dist/verify-version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdd-method/author-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"sddCliVersion": "5.
|
|
3
|
+
"version": "0.17.1",
|
|
4
|
+
"sddCliVersion": "5.4.1",
|
|
5
5
|
"description": "Re-author one SDD's content — scaffold, plan, contracts, conformance, api-surface, snapshots, briefings, catalogue check — plus every read-only verify verb. Ships only the code those verbs reach; it cannot sync the baseline or rebuild the catalogues. Maintenance is in @sdd-method/maintain-cli, administration in @sdd-method/sdd-cli.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"type": "module",
|