@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.
@@ -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
- /** The repositories a baseline was observed in: its evidence file's `**Observed in:** repo[@sha], repo[@sha]` line. */
140
- export function observedIn(evidence) {
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
- .split(",")
146
- .map((s) => s.trim().split(/\s*@\s*/)[0].replace(/`/g, "").trim())
147
- .filter((s) => s !== "");
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
- bad("unresolved", `${rel}: does not parse: ${e.message.split("\n")[0]}`);
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
- bad("unresolved", `${rel}: archetype ${String(doc.archetype)} of bundle ${String(doc.bundle)} is not in the bundle cache (sync the bundle)`);
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
- bad("unresolved", `${rel}: lists no citations`);
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 verdict = problems
165
- ? `[FAIL] archetype citations: ${problems} of ${total} unresolved or stale`
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). A record for an open change must
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
- if (tasks !== null && TASK_DONE_RE.test(tasks)) {
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",
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.17.2",
3
- "built_against_sdd_cli": "5.3.2"
2
+ "version": "0.18.0",
3
+ "built_against_sdd_cli": "5.4.0"
4
4
  }
@@ -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({
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "5.3.2",
3
- "built_against_sdd_cli": "5.3.2"
2
+ "version": "5.4.0",
3
+ "built_against_sdd_cli": "5.4.0"
4
4
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sdd-method/maintain-cli",
3
- "version": "0.17.2",
4
- "sddCliVersion": "5.3.2",
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",