@sdd-method/maintain-cli 0.17.2 → 0.18.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/catalogue/build.js +3 -0
- package/dist/lib/catalogue/builders/requirements.js +6 -2
- package/dist/lib/sync/restore-cache.js +66 -0
- 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/verbs/sync.js +49 -0
- 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
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// sync --restore-cache: rebuild a repository's bundle cache from the bundle its lock records, touching nothing else.
|
|
2
|
+
//
|
|
3
|
+
// The cache (.sdd/bundle-cache/, M-ADR 0176 §4.2) is gitignored, and only sync fills it, so a fresh clone has none and
|
|
4
|
+
// every check that resolves against it (archetype-citations, scaffold --archetype) fails there. A full sync in the clone
|
|
5
|
+
// restores it, but a sync may also move the tree, and a gate wants to know that the tree is what was pushed. A restore
|
|
6
|
+
// therefore takes exactly the bundle the lock records (the same tarball, by the sha256 the lock holds) and writes only
|
|
7
|
+
// the cache: no tracked file, no lock, no .gitignore. Any other bundle is refused, because extracting it would make the
|
|
8
|
+
// cache disagree with the lock; moving to it is a sync.
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { access, readFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { loadBundle } from "../bundle.js";
|
|
13
|
+
import { findBundle, LOCK_FILE, readLockFile } from "../lock-file.js";
|
|
14
|
+
import { BUNDLE_CACHE_REL, cacheBundle } from "./bundle-cache.js";
|
|
15
|
+
async function exists(p) {
|
|
16
|
+
return access(p).then(() => true, () => false);
|
|
17
|
+
}
|
|
18
|
+
/** Whether the repository's .gitignore already excludes the cache (a restore never writes it). */
|
|
19
|
+
async function cacheIgnored(targetRepo) {
|
|
20
|
+
const text = await readFile(join(targetRepo, ".gitignore"), "utf8").catch(() => "");
|
|
21
|
+
return text.split("\n").some((l) => l.trim() === `${BUNDLE_CACHE_REL}/` || l.trim() === BUNDLE_CACHE_REL);
|
|
22
|
+
}
|
|
23
|
+
export async function restoreBundleCache(opts, stdout = (line) => process.stdout.write(line)) {
|
|
24
|
+
const fail = (msg) => {
|
|
25
|
+
stdout(`[FAIL] restore-cache: ${msg}\n`);
|
|
26
|
+
return { exitCode: 1 };
|
|
27
|
+
};
|
|
28
|
+
if (!(await exists(opts.bundle)))
|
|
29
|
+
return fail(`bundle not found: ${opts.bundle}`);
|
|
30
|
+
if (!(await exists(opts.targetRepo)))
|
|
31
|
+
return fail(`target repo not found: ${opts.targetRepo}`);
|
|
32
|
+
const lock = await readLockFile(opts.targetRepo);
|
|
33
|
+
if (lock === null)
|
|
34
|
+
return fail(`${LOCK_FILE} is absent; a restore rebuilds the cache a sync recorded, so sync first`);
|
|
35
|
+
const sha = createHash("sha256").update(await readFile(opts.bundle)).digest("hex");
|
|
36
|
+
const bundle = await loadBundle(opts.bundle);
|
|
37
|
+
try {
|
|
38
|
+
const { bundleName, bundleVersion } = bundle.manifest;
|
|
39
|
+
const entry = findBundle(lock, bundleName);
|
|
40
|
+
if (entry === null)
|
|
41
|
+
return fail(`${bundleName} ${bundleVersion} is not in ${LOCK_FILE}; a restore takes only a bundle the lock records`);
|
|
42
|
+
if (entry.bundleSha256 !== sha) {
|
|
43
|
+
return fail(`${bundleName} ${bundleVersion} (sha256 ${sha.slice(0, 12)}) is not the bundle ${LOCK_FILE} records ` +
|
|
44
|
+
`(${entry.bundleVersion}, sha256 ${entry.bundleSha256.slice(0, 12)}); a restore takes exactly the locked bundle, and moving to another is a sync`);
|
|
45
|
+
}
|
|
46
|
+
if (bundle.manifest.isForwardBase) {
|
|
47
|
+
stdout(`[INFO] restore-cache: ${bundleName} ${bundleVersion} is a forward base, which sync never caches; nothing to restore\n`);
|
|
48
|
+
return { exitCode: 0 };
|
|
49
|
+
}
|
|
50
|
+
if (!(await cacheIgnored(opts.targetRepo))) {
|
|
51
|
+
return fail(`.gitignore does not exclude ${BUNDLE_CACHE_REL}/; a restore writes no tracked file, so the line a sync adds is missing here: sync, or add it`);
|
|
52
|
+
}
|
|
53
|
+
const dest = `${BUNDLE_CACHE_REL}/${bundleName}@${bundleVersion}`;
|
|
54
|
+
if (opts.dryRun) {
|
|
55
|
+
stdout(`[INFO] restore-cache: would cache ${bundleName} ${bundleVersion} at ${dest} (${bundle.managedPaths.length} file(s)); the bundle is the one ${LOCK_FILE} records\n`);
|
|
56
|
+
return { exitCode: 0 };
|
|
57
|
+
}
|
|
58
|
+
await cacheBundle({ targetRepo: opts.targetRepo, bundleName, bundleVersion, managedPaths: bundle.managedPaths, payloadDir: bundle.payloadDir });
|
|
59
|
+
stdout(`[PASS] restore-cache: ${bundleName} ${bundleVersion} cached at ${dest} (${bundle.managedPaths.length} file(s)), the bundle ${LOCK_FILE} records; no tracked file written\n`);
|
|
60
|
+
return { exitCode: 0 };
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await bundle.cleanup();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=restore-cache.js.map
|
|
@@ -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/verbs/sync.js
CHANGED
|
@@ -2,6 +2,7 @@ import { AGENT_HARNESSES, resolveAgentHarness, } from "../lib/agent-harness.js";
|
|
|
2
2
|
import { loadSyncConfig } from "../lib/sync/config.js";
|
|
3
3
|
import { materialiseBundle } from "../lib/sync/bundle-source.js";
|
|
4
4
|
import { runSync } from "../lib/sync/index.js";
|
|
5
|
+
import { restoreBundleCache } from "../lib/sync/restore-cache.js";
|
|
5
6
|
import { CI_BINDINGS, CI_RUNNER_DEFAULT, isCiBinding, parseCiRunner } from "../lib/ci-binding/index.js";
|
|
6
7
|
import { CLI_SOURCES, DEFAULT_VERIFY_CLI_PACKAGE, NPM_PACKAGE_NAME } from "../lib/hooks-seed/index.js";
|
|
7
8
|
import { resolvePathOption } from "./path-option.js";
|
|
@@ -13,6 +14,7 @@ export function registerSync(program) {
|
|
|
13
14
|
.option("--config <path>", "path to a multi-bundle sync config (iterates bundles in declared order; runs each through the single-bundle sync engine)")
|
|
14
15
|
.option("--target-repo <dir>", "target SDD repo", resolvePathOption, process.cwd())
|
|
15
16
|
.option("--dry-run", "report planned actions without writing")
|
|
17
|
+
.option("--restore-cache", "rebuild .sdd/bundle-cache from the bundle(s) the lock records, writing no tracked file, lock or .gitignore: for a fresh clone, whose cache is gitignored. Each bundle must be the exact tarball the lock records (by sha256); any other is refused, since moving to it is a sync")
|
|
16
18
|
.option("--replace-bundle <name>", "migrate: drop the named bundle's lock block and release its paths to the incoming bundle (single --bundle mode only; prints the shared/arriving/orphaned split; requires --yes outside --dry-run)")
|
|
17
19
|
.option("--yes", "confirm a --replace-bundle migration outside --dry-run")
|
|
18
20
|
.option("--allow-overwrite", "accept overwrites of locally-modified method content")
|
|
@@ -63,6 +65,53 @@ export function registerSync(program) {
|
|
|
63
65
|
process.stderr.write("[FAIL] --replace-bundle requires single-bundle mode (--bundle <tarball>) — a migration is one deliberate act, not a config iteration\n");
|
|
64
66
|
process.exit(1);
|
|
65
67
|
}
|
|
68
|
+
if (opts.restoreCache) {
|
|
69
|
+
const lockChoices = [
|
|
70
|
+
opts.replaceBundle ? "--replace-bundle" : "",
|
|
71
|
+
opts.yes ? "--yes" : "",
|
|
72
|
+
opts.allowOverwrite ? "--allow-overwrite" : "",
|
|
73
|
+
opts.delete === false ? "--no-delete" : "",
|
|
74
|
+
opts.ciBinding ? "--ci-binding" : "",
|
|
75
|
+
opts.ciRunner ? "--ci-runner" : "",
|
|
76
|
+
opts.cliSource ? "--cli-source" : "",
|
|
77
|
+
opts.cliPackage ? "--cli-package" : "",
|
|
78
|
+
].filter(Boolean);
|
|
79
|
+
if (lockChoices.length > 0) {
|
|
80
|
+
process.stderr.write(`[FAIL] --restore-cache writes only the bundle cache; ${lockChoices.join(", ")} change(s) the tree or the lock, which is a sync\n`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
const one = async (bundle) => (await restoreBundleCache({ bundle, targetRepo: opts.targetRepo, dryRun: opts.dryRun === true })).exitCode;
|
|
84
|
+
if (opts.bundle) {
|
|
85
|
+
const code = await one(opts.bundle);
|
|
86
|
+
if (code !== 0)
|
|
87
|
+
process.exit(code);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!opts.config) {
|
|
91
|
+
process.stderr.write("[FAIL] one of --bundle or --config is required\n");
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const config = await loadSyncConfig(opts.config);
|
|
96
|
+
for (const entry of config.bundles) {
|
|
97
|
+
const resolved = await materialiseBundle(entry);
|
|
98
|
+
let code;
|
|
99
|
+
try {
|
|
100
|
+
code = await one(resolved.localPath);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
await resolved.cleanup();
|
|
104
|
+
}
|
|
105
|
+
if (code !== 0)
|
|
106
|
+
process.exit(code);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
process.stderr.write(`[FAIL] ${err.message}\n`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
66
115
|
// Single-bundle path (legacy and explicit).
|
|
67
116
|
if (opts.bundle) {
|
|
68
117
|
const { exitCode } = await runSync({
|
package/dist/verify-version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdd-method/maintain-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"sddCliVersion": "5.
|
|
3
|
+
"version": "0.18.0",
|
|
4
|
+
"sddCliVersion": "5.4.0",
|
|
5
5
|
"description": "Bring an SDD repository current and rebuild its derived model — sync, briefing, catalogue build and aggregate, pack build, context pull — plus every read-only verify verb. Ships only the code those verbs reach; it cannot author specification content. Authoring is in @sdd-method/sdd-cli.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"type": "module",
|