@sdd-method/author-cli 0.15.1 → 0.16.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 +52 -5
- package/dist/lib/behaviour-store/schema.js +24 -12
- package/dist/lib/catalogue/aggregation-manifest-loader.js +173 -0
- package/dist/lib/catalogue/build.js +3 -0
- package/dist/lib/catalogue/builders/adrs.js +1 -1
- package/dist/lib/catalogue/builders/contracts.js +5 -0
- package/dist/lib/catalogue/builders/features.js +34 -4
- package/dist/lib/catalogue/builders/repositories.js +5 -3
- package/dist/lib/catalogue/builders/requirements.js +1 -1
- package/dist/lib/catalogue/canonical-schema.yaml +3 -2
- package/dist/lib/catalogue/contract-id.js +1 -1
- package/dist/lib/catalogue/impact-run.js +6 -1
- package/dist/lib/catalogue/traceability-loader.js +3 -0
- package/dist/lib/contracts-availability/index.js +4 -1
- package/dist/lib/plan/read-sdd.js +2 -1
- package/dist/lib/scaffold/behaviour-store.js +5 -3
- package/dist/lib/sdd-index/repo-config.js +1 -0
- package/dist/lib/snapshot/advance.js +13 -5
- package/dist/lib/snapshot/fold.js +3 -3
- package/dist/lib/snapshot/manifest.js +18 -2
- package/dist/lib/structure/model.js +6 -1
- package/dist/lib/structure/removals.js +121 -0
- package/dist/lib/validate/behaviour-store.js +228 -38
- package/dist/lib/validate/feature-spec-pointers.js +7 -0
- package/dist/lib/validate/structural-model.js +7 -1
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
- package/scripts/validate-app-docs.sh +18 -2
|
@@ -29,7 +29,7 @@ import { parse, parseDocument, stringify } from "yaml";
|
|
|
29
29
|
// unrelated churn in lines the tool had no business touching.
|
|
30
30
|
import { deltaPath } from "./diff.js";
|
|
31
31
|
import { loadCanonicalSchema } from "../catalogue/schema-loader.js";
|
|
32
|
-
import { SnapshotError, credentialNotice, exists, isIsoDate, readManifest, splitFolder, } from "./manifest.js";
|
|
32
|
+
import { SnapshotError, credentialNotice, exists, isIsoDate, parseSnapshotYaml, readManifest, splitFolder, } from "./manifest.js";
|
|
33
33
|
export const REVERSE_STATUSES = ["classified", "reversed"];
|
|
34
34
|
export const IMPACTS = ["none", "repo-minor", "repo-major", "product-major"];
|
|
35
35
|
/**
|
|
@@ -179,7 +179,7 @@ export async function advance(inputs, write) {
|
|
|
179
179
|
if (!(await exists(dp))) {
|
|
180
180
|
throw new SnapshotError(`no ${dp}: the pin advances after the delta is recorded (run snapshot diff, then classify)`);
|
|
181
181
|
}
|
|
182
|
-
const delta =
|
|
182
|
+
const delta = parseSnapshotYaml(await readFile(dp, "utf8"));
|
|
183
183
|
const provenance = delta.t0_provenance?.result;
|
|
184
184
|
const provenanceNote = provenance ? ` T0 provenance: ${provenance}${delta.t0_provenance?.waiver?.who ? ` (waiver: ${delta.t0_provenance.waiver.who})` : ""}.` : "";
|
|
185
185
|
if (provenance && !/^pass$/.test(provenance)) {
|
|
@@ -215,8 +215,8 @@ export async function advance(inputs, write) {
|
|
|
215
215
|
const hFile = join(dir, "reverse-history.yaml");
|
|
216
216
|
const mFile = join(dir, "reverse-metadata.yaml");
|
|
217
217
|
const metadataText = await readFile(mFile, "utf8");
|
|
218
|
-
const metadata =
|
|
219
|
-
let history = (await exists(hFile)) ?
|
|
218
|
+
const metadata = parseSnapshotYaml(metadataText);
|
|
219
|
+
let history = (await exists(hFile)) ? parseSnapshotYaml(await readFile(hFile, "utf8")) : { canonical_repo_key: key, history: [] };
|
|
220
220
|
let migrated = false;
|
|
221
221
|
if (Array.isArray(history.entries)) {
|
|
222
222
|
if (!inputs.migrateHistory) {
|
|
@@ -315,6 +315,14 @@ export async function advance(inputs, write) {
|
|
|
315
315
|
}
|
|
316
316
|
// A classification is one of the bound vocabulary (M-ADR 0214 §4.5), read from the
|
|
317
317
|
// schema rather than restated here: a typo is not a classification.
|
|
318
|
+
// Both effects are stated, `none` or the baseline change that carries it (delta-reverse.md §7): a refresh that
|
|
319
|
+
// changed nothing must read differently from one nobody checked.
|
|
320
|
+
const silent = mine
|
|
321
|
+
.filter((f) => f.classification !== null && f.classification !== undefined)
|
|
322
|
+
.flatMap((f) => ["structural_effect", "behavioural_effect"].filter((k) => typeof f[k] !== "string" || f[k].trim() === "").map((k) => `${f.id} ${k}`));
|
|
323
|
+
if (silent.length > 0) {
|
|
324
|
+
problems.push(`${key}: ${silent.join(", ")} not stated — each classified finding says none, or names the baseline change that carries it (delta-reverse.md §7)`);
|
|
325
|
+
}
|
|
318
326
|
const outOfVocabulary = mine.filter((f) => !dispositions.has(String(f.classification)));
|
|
319
327
|
if (outOfVocabulary.length > 0) {
|
|
320
328
|
problems.push(`${key}: ${outOfVocabulary.map((f) => `${f.id} '${String(f.classification)}'`).join(", ")} — not a delta_disposition (${[...dispositions].join(", ")}; M-ADR 0214 §4.5)`);
|
|
@@ -371,7 +379,7 @@ export async function advance(inputs, write) {
|
|
|
371
379
|
else {
|
|
372
380
|
await writeFile(hFile, appendHistoryEntry(await readFile(hFile, "utf8"), p.entry));
|
|
373
381
|
}
|
|
374
|
-
const after =
|
|
382
|
+
const after = parseSnapshotYaml(await readFile(hFile, "utf8")).history;
|
|
375
383
|
if (after.length !== before.length + 1 || !isDeepStrictEqual(after.slice(0, before.length), before)) {
|
|
376
384
|
throw new Error(`${hFile}: post-write check failed — prior entries changed or not exactly one appended`);
|
|
377
385
|
}
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
// (`support-repo` → `ancillary`, delta-reverse.md §1) and left unset otherwise.
|
|
16
16
|
import { mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
17
17
|
import { join, relative, sep } from "node:path";
|
|
18
|
-
import {
|
|
18
|
+
import { stringify } from "yaml";
|
|
19
19
|
import { setTopLevelScalars } from "./advance.js";
|
|
20
|
-
import { SnapshotError, baselineIds, deriveKey, knownRepositoryIds, exists, isIsoDate, manifestPath, memberFolders, splitFolder, } from "./manifest.js";
|
|
20
|
+
import { SnapshotError, baselineIds, deriveKey, knownRepositoryIds, exists, isIsoDate, manifestPath, memberFolders, parseSnapshotYaml, splitFolder, } from "./manifest.js";
|
|
21
21
|
const ROLE_BY_PROFILE = { "support-repo": "ancillary" };
|
|
22
22
|
/** The same mapping by M-ADR 0213's scope, which replaces the per-repo profile: a support repo is ancillary. */
|
|
23
23
|
const ROLE_BY_SCOPE = { support: "ancillary" };
|
|
@@ -32,7 +32,7 @@ async function pinsByFolder(sddPath) {
|
|
|
32
32
|
if (!e.isDirectory() || !(await exists(file)))
|
|
33
33
|
continue;
|
|
34
34
|
const text = await readFile(file, "utf8");
|
|
35
|
-
const meta = (
|
|
35
|
+
const meta = (parseSnapshotYaml(text) ?? {});
|
|
36
36
|
const folder = String(meta.source_path ?? "").split("/").pop() || String(meta.exported_folder_name ?? "");
|
|
37
37
|
if (folder)
|
|
38
38
|
out.set(folder, { file, text, meta });
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
11
11
|
import { join, relative, sep } from "node:path";
|
|
12
|
-
import {
|
|
12
|
+
import { isScalar, parseDocument, visit } from "yaml";
|
|
13
13
|
/** The line every later verb prints while a baseline carries unresolved credential findings. */
|
|
14
14
|
export function credentialNotice(m) {
|
|
15
15
|
const s = m.credential_scan;
|
|
@@ -60,6 +60,22 @@ export async function exists(p) {
|
|
|
60
60
|
return false;
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Parse snapshot YAML with every `*revision` value read as the text written. A revision is an identifier, and YAML
|
|
65
|
+
* 1.2 reads an unquoted all-digit one as an integer: `08985180587` became 8985180587, losing the leading zero,
|
|
66
|
+
* so a pin no longer matched its folder's `@<rev>`. Writers quote such a value; this covers files written by hand.
|
|
67
|
+
*/
|
|
68
|
+
export function parseSnapshotYaml(text) {
|
|
69
|
+
const doc = parseDocument(text);
|
|
70
|
+
visit(doc, {
|
|
71
|
+
Pair(_, pair) {
|
|
72
|
+
if (isScalar(pair.key) && /(^|_)revision$/.test(String(pair.key.value)) && isScalar(pair.value) && typeof pair.value.value !== "string" && pair.value.value !== null) {
|
|
73
|
+
pair.value.value = pair.value.source ?? String(pair.value.value);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
return doc.toJS();
|
|
78
|
+
}
|
|
63
79
|
export function manifestPath(areaRoot, id) {
|
|
64
80
|
return join(areaRoot, id, "snapshot.yaml");
|
|
65
81
|
}
|
|
@@ -67,7 +83,7 @@ export async function readManifest(areaRoot, id) {
|
|
|
67
83
|
const p = manifestPath(areaRoot, id);
|
|
68
84
|
if (!(await exists(p)))
|
|
69
85
|
throw new SnapshotError(`no manifest at ${p}`);
|
|
70
|
-
const raw =
|
|
86
|
+
const raw = parseSnapshotYaml(await readFile(p, "utf8"));
|
|
71
87
|
if (raw === null || typeof raw !== "object")
|
|
72
88
|
throw new SnapshotError(`${p}: not a mapping`);
|
|
73
89
|
const problems = [];
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// openspec/changes/<change>/structure/none.yaml a change's typed claim of no structural effect
|
|
6
6
|
// An element is a catalogue row of a `structure` catalogue, keyed by its immutable id. Its revision is the
|
|
7
7
|
// SHA-256 of its canonical JSON, so two readers agree on it without agreeing on anything else.
|
|
8
|
+
import { contractMoves } from "../behaviour-store/archive.js";
|
|
8
9
|
import { createHash } from "node:crypto";
|
|
9
10
|
import { readdir, readFile } from "node:fs/promises";
|
|
10
11
|
import { join } from "node:path";
|
|
@@ -193,9 +194,12 @@ export async function loadChangeStructure(repo, change, problems) {
|
|
|
193
194
|
removed.push({ catalogue: r.catalogue, id: r.id, reason: typeof reason === "string" ? reason : "" });
|
|
194
195
|
}
|
|
195
196
|
for (const k of Object.keys(m)) {
|
|
196
|
-
if (!["schema_version", "partition", "depends_on", "ADDED", "MODIFIED", "REMOVED", "base"].includes(k))
|
|
197
|
+
if (!["schema_version", "partition", "depends_on", "ADDED", "MODIFIED", "REMOVED", "base", "moved"].includes(k))
|
|
197
198
|
problems.push(`${file}: unknown key ${k}`);
|
|
198
199
|
}
|
|
200
|
+
const moved = contractMoves(m["moved"]);
|
|
201
|
+
for (const p of moved.problems)
|
|
202
|
+
problems.push(`${file}: ${p}`);
|
|
199
203
|
deltas.push({
|
|
200
204
|
change,
|
|
201
205
|
partition,
|
|
@@ -205,6 +209,7 @@ export async function loadChangeStructure(repo, change, problems) {
|
|
|
205
209
|
modified: readElements(m["MODIFIED"], `${file}: MODIFIED`, partition, file, problems),
|
|
206
210
|
removed,
|
|
207
211
|
base,
|
|
212
|
+
...(moved.moves.length > 0 ? { moved: moved.moves } : {}),
|
|
208
213
|
});
|
|
209
214
|
}
|
|
210
215
|
return { change, deltas, noOp, present: true };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// A removed id against what references it outside the view (M-ADR 0230 §4.3, §4.11 negative proof 1: "a removal
|
|
2
|
+
// against a reference to it"). The view's own references are checked by validateView; these are the rest:
|
|
3
|
+
// this SDD its catalogues built from its records, not its model: an archived change's work orders and declared
|
|
4
|
+
// assertions name repositories, and a row whose target is removed drops from the next build
|
|
5
|
+
// members the catalogues of the SDDs its aggregation manifest names, where they are checked out: a member's row
|
|
6
|
+
// naming the removed id, and no row of its own holding it, dangles in the aggregate
|
|
7
|
+
// A rename is not a removal: ids never change, so a rename keeps the id and modifies the element's name.
|
|
8
|
+
import { access, readFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { parseDocument } from "yaml";
|
|
11
|
+
import { loadAggregationManifest, DEFAULT_AGGREGATION_MANIFEST_REL } from "../catalogue/aggregation-manifest-loader.js";
|
|
12
|
+
import { CLI_VERSION } from "../catalogue/cli-version.js";
|
|
13
|
+
import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
14
|
+
async function exists(p) {
|
|
15
|
+
try {
|
|
16
|
+
await access(p);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** A catalogue directory's rows, read once each. */
|
|
24
|
+
function reader(dir) {
|
|
25
|
+
const cache = new Map();
|
|
26
|
+
return async (catalogue) => {
|
|
27
|
+
if (!cache.has(catalogue)) {
|
|
28
|
+
let rows = null;
|
|
29
|
+
try {
|
|
30
|
+
rows = parseCatalogueCsv(await readFile(join(dir, `${catalogue}.csv`), "utf8")).rows;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
rows = null;
|
|
34
|
+
}
|
|
35
|
+
cache.set(catalogue, rows);
|
|
36
|
+
}
|
|
37
|
+
return cache.get(catalogue) ?? null;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** `<catalogue>.<column> (<n>)` for each foreign key into `<target>.id` whose rows name `id`. */
|
|
41
|
+
async function referencesTo(schema, rows, target, id, include) {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const c of schema.catalogues) {
|
|
44
|
+
if (!include(c.name))
|
|
45
|
+
continue;
|
|
46
|
+
for (const fk of c.foreignKeys) {
|
|
47
|
+
if (fk.references !== `${target}.id`)
|
|
48
|
+
continue;
|
|
49
|
+
const rs = await rows(c.name);
|
|
50
|
+
const n = rs?.filter((r) => r[fk.column] === id).length ?? 0;
|
|
51
|
+
if (n > 0)
|
|
52
|
+
out.push(`${c.name}.${fk.column} (${n})`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/** The aggregation manifest's path: .sdd/sdd-manifest.yaml#aggregation_manifest_path, or the default. */
|
|
58
|
+
async function manifestPath(repo) {
|
|
59
|
+
try {
|
|
60
|
+
const m = parseDocument(await readFile(join(repo, ".sdd", "sdd-manifest.yaml"), "utf8")).toJS();
|
|
61
|
+
if (typeof m?.aggregation_manifest_path === "string" && m.aggregation_manifest_path !== "")
|
|
62
|
+
return join(repo, m.aggregation_manifest_path);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// the manifest check reports an unparseable manifest
|
|
66
|
+
}
|
|
67
|
+
return join(repo, DEFAULT_AGGREGATION_MANIFEST_REL);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Each open change's removals against their references outside the view. Returns the findings, one per removal with
|
|
71
|
+
* references, and the lines saying what was not looked at.
|
|
72
|
+
*/
|
|
73
|
+
export async function checkRemovals(repo, schema, structures) {
|
|
74
|
+
const findings = [];
|
|
75
|
+
const lines = [];
|
|
76
|
+
const removals = structures.flatMap((s) => s.deltas.flatMap((d) => d.removed.map((r) => ({ change: s.change, file: d.file, ...r }))));
|
|
77
|
+
if (removals.length === 0)
|
|
78
|
+
return { findings, lines };
|
|
79
|
+
const fromModel = new Set(schema.catalogues.filter((c) => c.sourceOfTruth === "structure").map((c) => c.name));
|
|
80
|
+
const own = reader(join(repo, "catalogues"));
|
|
81
|
+
const members = [];
|
|
82
|
+
const path = await manifestPath(repo);
|
|
83
|
+
if (await exists(path)) {
|
|
84
|
+
try {
|
|
85
|
+
const manifest = await loadAggregationManifest(path, { cliVersion: CLI_VERSION, ecosystemRootPath: repo });
|
|
86
|
+
const absent = [];
|
|
87
|
+
for (const s of manifest.siblings) {
|
|
88
|
+
if (await exists(s.cataloguesPath))
|
|
89
|
+
members.push({ sddId: s.sddId, rows: reader(s.cataloguesPath) });
|
|
90
|
+
else
|
|
91
|
+
absent.push(s.sddId);
|
|
92
|
+
}
|
|
93
|
+
if (absent.length > 0)
|
|
94
|
+
lines.push(`removed ids: ${absent.length} of ${manifest.siblings.length} member SDD(s) not checked, no checkout of their catalogues (${absent.join(", ")})`);
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
lines.push(`removed ids: member SDDs not checked (${String(e.message).split("\n")[0]})`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const r of removals) {
|
|
101
|
+
const where = [];
|
|
102
|
+
const mine = await referencesTo(schema, own, r.catalogue, r.id, (c) => !fromModel.has(c));
|
|
103
|
+
if (mine.length > 0)
|
|
104
|
+
where.push(`this SDD's ${mine.join(", ")}`);
|
|
105
|
+
for (const m of members) {
|
|
106
|
+
// A member that holds the id itself resolves its references to its own row.
|
|
107
|
+
if ((await m.rows(r.catalogue))?.some((row) => row.id === r.id))
|
|
108
|
+
continue;
|
|
109
|
+
const theirs = await referencesTo(schema, m.rows, r.catalogue, r.id, () => true);
|
|
110
|
+
if (theirs.length > 0)
|
|
111
|
+
where.push(`${m.sddId}'s ${theirs.join(", ")}`);
|
|
112
|
+
}
|
|
113
|
+
if (where.length === 0)
|
|
114
|
+
continue;
|
|
115
|
+
findings.push(`${r.file}: removes ${r.catalogue}/${r.id}, which rows outside the model still reference: ${where.join("; ")}. ` +
|
|
116
|
+
`A rename keeps the id and modifies the element's name (ids never change, M-ADR 0230 §4.3); a removal needs ` +
|
|
117
|
+
`those references gone, or each row naming it drops from the next build and dangles in the aggregate`);
|
|
118
|
+
}
|
|
119
|
+
return { findings, lines };
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=removals.js.map
|
|
@@ -34,13 +34,17 @@
|
|
|
34
34
|
// change was withdrawn or replaced (M-ADR 0222 §4.6).
|
|
35
35
|
// 5. surfaces: every archived baseline's evidence lists, under **Surfaces:**, each contract the
|
|
36
36
|
// repositories it was observed in serve, as covered at transport or excluded with a
|
|
37
|
-
// reason, and the list is checked against the catalogue (catalogues/repo_contract.csv
|
|
38
|
-
//
|
|
37
|
+
// reason, and the list is checked against the catalogue (catalogues/repo_contract.csv, less
|
|
38
|
+
// a contract service_contract records the repository's services consuming and none exposing:
|
|
39
|
+
// repo_contract has no direction), never against itself (M-ADR 0222 §4.3, §4.4). A list the catalogue gives nothing to
|
|
39
40
|
// check against (no Observed in line, or a repository with no catalogue contracts) fails:
|
|
40
41
|
// a cross-check of zero contracts is unresolved, not a pass.
|
|
41
42
|
// A baseline whose every requirement has since left the living specs (removed, or its
|
|
42
43
|
// spec retired) is history: its list is not checked, so a retired surface can drop its
|
|
43
|
-
// contract (M-ADR 0222 §4.4).
|
|
44
|
+
// contract (M-ADR 0222 §4.4). A baseline archived where the SDD ships record schema 1.4.0
|
|
45
|
+
// or later records its gate's PASS (**Baseline gate:**), so `change archive` refuses one
|
|
46
|
+
// that has not passed (M-ADR 0222 §4.3). An open baseline is asked these questions, and
|
|
47
|
+
// what it may change and which features key it, as warnings while it can still be fixed.
|
|
44
48
|
// 7. features: every living spec is keyed by a catalogue feature (M-ADR 0221 §4.7). A spec keyed by no
|
|
45
49
|
// feature is dropped from the catalogue by the requirements builder, which reports it only as a
|
|
46
50
|
// build finding; this check makes it a failure, so a shipped requirement cannot vanish from the
|
|
@@ -69,11 +73,11 @@ import { tmpdir } from "node:os";
|
|
|
69
73
|
import { basename, join } from "node:path";
|
|
70
74
|
import { promisify } from "node:util";
|
|
71
75
|
import { parseDocument } from "yaml";
|
|
72
|
-
import { observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, renamePairs, resolveImplements, surfaceMatches } from "../behaviour-store/archive.js";
|
|
76
|
+
import { contractMoves, observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, renamePairs, resolveImplements, surfaceMatches } from "../behaviour-store/archive.js";
|
|
73
77
|
import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
74
78
|
import { boundFile, conditionProblems, structuralFile, isCommit, normaliseBound, requirementBlock, sameCommit, scenarioKind, taskGroups, testPlanHeader, testPlanMapping, verdictLine, versionAtLeast } from "./agreement-evidence.js";
|
|
75
79
|
import { ENFORCED as STRUCTURE_ENFORCED } from "./structural-model.js";
|
|
76
|
-
import { compareStoreSchema, SCHEMA_NAME, SHIPPED_SCHEMA_DIR, STORE_CONFIG, STORE_SCHEMA_DIR, storeSchemaName } from "../behaviour-store/schema.js";
|
|
80
|
+
import { BASELINE_SCHEMA_NAME, compareStoreSchema, SCHEMA_NAME, SHIPPED_SCHEMA_DIR, shippedSchemaDir, STORE_CONFIG, STORE_SCHEMA_DIR, storeSchemaDir, storeSchemaName } from "../behaviour-store/schema.js";
|
|
77
81
|
const execFileAsync = promisify(execFile);
|
|
78
82
|
/** The OpenSpec release the method depends on (M-ADR 0223 ruling 2). The module installs exactly this. */
|
|
79
83
|
export const OPENSPEC_VERSION = "1.14.3";
|
|
@@ -828,8 +832,133 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
828
832
|
}
|
|
829
833
|
const orderDecides = (name, key) => [...(peers.get(name) ?? [])].some((p) => p !== name && touches.get(p)?.has(key));
|
|
830
834
|
let scopeUnresolved = 0;
|
|
831
|
-
const
|
|
835
|
+
const openBaselines = [];
|
|
836
|
+
for (const change of open)
|
|
837
|
+
if (await exists(join(repo, STORE_DIR, "changes", change, "baseline-evidence.md")))
|
|
838
|
+
openBaselines.push(change);
|
|
839
|
+
const servedBy = baselines + openBaselines.length > 0 ? await catalogueContracts(repo) : null;
|
|
832
840
|
let surfacesChecked = 0;
|
|
841
|
+
let structureOnly = 0;
|
|
842
|
+
// The contract moves each archived change records, in archive order. A contract's id derives from its path, so a
|
|
843
|
+
// move changes it; an archived baseline's list names the contract as it was, and is resolved through the moves
|
|
844
|
+
// archived after it rather than edited (M-ADR 0222 §4.4). A surface that vanished with no move still fails.
|
|
845
|
+
const movesAt = [];
|
|
846
|
+
for (const name of order) {
|
|
847
|
+
const found = [];
|
|
848
|
+
let names = [];
|
|
849
|
+
try {
|
|
850
|
+
names = (await readdir(join(repo, archiveRel, name, "structure"))).filter((f) => f.endsWith(".yaml")).sort();
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
names = [];
|
|
854
|
+
}
|
|
855
|
+
for (const f of names) {
|
|
856
|
+
try {
|
|
857
|
+
found.push(...contractMoves(parseDocument((await readText(join(repo, archiveRel, name, "structure", f))) ?? "").toJS()?.["moved"]).moves);
|
|
858
|
+
}
|
|
859
|
+
catch {
|
|
860
|
+
// the structural-model check reports an unreadable delta
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
movesAt.push(found);
|
|
864
|
+
}
|
|
865
|
+
// An open change's moves count too, after every archive: the catalogue it is built with already holds the new
|
|
866
|
+
// identity, so the lists that named the old one follow the move while it is open, not only once it archives.
|
|
867
|
+
const openMoves = [];
|
|
868
|
+
for (const change of open) {
|
|
869
|
+
let names = [];
|
|
870
|
+
try {
|
|
871
|
+
names = (await readdir(join(repo, STORE_DIR, "changes", change, "structure"))).filter((f) => f.endsWith(".yaml")).sort();
|
|
872
|
+
}
|
|
873
|
+
catch {
|
|
874
|
+
names = [];
|
|
875
|
+
}
|
|
876
|
+
for (const f of names) {
|
|
877
|
+
try {
|
|
878
|
+
openMoves.push(...contractMoves(parseDocument((await readText(join(repo, STORE_DIR, "changes", change, "structure", f))) ?? "").toJS()?.["moved"]).moves);
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
// the structural-model check reports an unreadable delta
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/** The identities a contract had before the moves archived after position `after` (-1: every archived move). */
|
|
886
|
+
const formerIdentities = (id, after) => {
|
|
887
|
+
const back = new Map();
|
|
888
|
+
for (const m of [...movesAt.slice(after + 1).flat(), ...openMoves])
|
|
889
|
+
back.set(m.to, [...(back.get(m.to) ?? []), m]);
|
|
890
|
+
const out = [];
|
|
891
|
+
const seen = new Set([id]);
|
|
892
|
+
const queue = [id];
|
|
893
|
+
while (queue.length > 0) {
|
|
894
|
+
for (const m of back.get(queue.shift()) ?? []) {
|
|
895
|
+
if (seen.has(m.from))
|
|
896
|
+
continue;
|
|
897
|
+
seen.add(m.from);
|
|
898
|
+
out.push({ id: m.from, path: m.fromPath ?? "" });
|
|
899
|
+
queue.push(m.from);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return out;
|
|
903
|
+
};
|
|
904
|
+
/**
|
|
905
|
+
* Whether a baseline admits behaviour: any requirement in its spec deltas. One that admits none (a structure-only
|
|
906
|
+
* baseline, such as the removal of structure carved into another SDD) observes no served behaviour, so it has no
|
|
907
|
+
* surface to account for, and says so with the typed claim "**Surfaces:** none".
|
|
908
|
+
*/
|
|
909
|
+
const admitsBehaviour = async (dir) => {
|
|
910
|
+
for (const feature of await dirs(join(repo, dir, "specs"))) {
|
|
911
|
+
if (parseRequirements((await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "").length > 0)
|
|
912
|
+
return true;
|
|
913
|
+
}
|
|
914
|
+
return false;
|
|
915
|
+
};
|
|
916
|
+
/** What is wrong with a baseline's Surfaces list against the catalogue (M-ADR 0222 §4.3, §4.4). */
|
|
917
|
+
const surfaceProblems = async (dir, evidence, after = -1) => {
|
|
918
|
+
const out = [];
|
|
919
|
+
const parsed = parseSurfaces(evidence);
|
|
920
|
+
const admits = await admitsBehaviour(dir);
|
|
921
|
+
if (!admits) {
|
|
922
|
+
if (parsed.none) {
|
|
923
|
+
structureOnly++;
|
|
924
|
+
return out;
|
|
925
|
+
}
|
|
926
|
+
if (parsed.entries.length === 0) {
|
|
927
|
+
return [`${dir}/baseline-evidence.md: the baseline admits no behaviour (no requirement in its spec deltas), so no surface is observed: write "**Surfaces:** none" (and "**Observed in:** none" where no repository is observed)`];
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
else if (parsed.none) {
|
|
931
|
+
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
|
+
}
|
|
933
|
+
if (!parsed.present)
|
|
934
|
+
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
|
+
for (const p of parsed.problems)
|
|
936
|
+
out.push(`${dir}/baseline-evidence.md: Surfaces: ${p}`);
|
|
937
|
+
if (!parsed.present)
|
|
938
|
+
return out;
|
|
939
|
+
if (servedBy === null) {
|
|
940
|
+
out.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: catalogues/repo_contract.csv or catalogues/contracts.csv is missing; build the catalogue`);
|
|
941
|
+
return out;
|
|
942
|
+
}
|
|
943
|
+
const repos = observedIn(evidence);
|
|
944
|
+
if (repos.length === 0)
|
|
945
|
+
out.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: no **Observed in:** line names a repository`);
|
|
946
|
+
for (const r of repos) {
|
|
947
|
+
const known = servedBy.get(r);
|
|
948
|
+
if ((known?.served ?? []).length === 0) {
|
|
949
|
+
const consumed = known?.consumed ? ` (it consumes ${known.consumed}, which a baseline does not list)` : "";
|
|
950
|
+
out.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: the catalogue records no contract that ${r} serves${consumed}; declare the contracts it serves in its repo-config`);
|
|
951
|
+
}
|
|
952
|
+
for (const c of known?.served ?? []) {
|
|
953
|
+
surfacesChecked++;
|
|
954
|
+
const former = formerIdentities(c.id, after);
|
|
955
|
+
if (!parsed.entries.some((e) => surfaceMatches(e.contract, c.id, c.specPath) || former.some((f) => surfaceMatches(e.contract, f.id, f.path)))) {
|
|
956
|
+
out.push(`${dir}/baseline-evidence.md: Surfaces does not list ${c.specPath || c.id} (${c.id}), which ${r} serves (catalogues/repo_contract.csv, less what service_contract records it only consuming); list it as covered at transport or excluded with a reason`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return out;
|
|
961
|
+
};
|
|
833
962
|
// A baseline whose every requirement has since left the living specs (removed, or its spec retired) records a
|
|
834
963
|
// surface that no longer exists; its Surfaces list is history, not checked against today's catalogue, or a
|
|
835
964
|
// retired surface could never drop its contract.
|
|
@@ -841,33 +970,23 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
841
970
|
const evidence = await readText(join(repo, dir, "baseline-evidence.md"));
|
|
842
971
|
const isBaseline = evidence !== null;
|
|
843
972
|
if (isBaseline) {
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
973
|
+
if (historical.has(name)) {
|
|
974
|
+
// history: see historicalBaselines; its gate and Surfaces list are not checked
|
|
975
|
+
const parsed = parseSurfaces(evidence);
|
|
976
|
+
if (!parsed.present)
|
|
977
|
+
failures.push(`${dir}/baseline-evidence.md: no **Surfaces:** list; name every contract the observed repositories serve, covered at transport or excluded with a reason`);
|
|
978
|
+
for (const p of parsed.problems)
|
|
979
|
+
failures.push(`${dir}/baseline-evidence.md: Surfaces: ${p}`);
|
|
851
980
|
}
|
|
852
|
-
else
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
if ((servedBy.get(r) ?? []).length === 0) {
|
|
862
|
-
failures.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: catalogues/repo_contract.csv records no contract for ${r}; declare the contracts it serves in its repo-config`);
|
|
863
|
-
}
|
|
864
|
-
for (const c of servedBy.get(r) ?? []) {
|
|
865
|
-
surfacesChecked++;
|
|
866
|
-
if (!parsed.entries.some((e) => surfaceMatches(e.contract, c.id, c.specPath))) {
|
|
867
|
-
failures.push(`${dir}/baseline-evidence.md: Surfaces does not list ${c.specPath || c.id} (${c.id}), which ${r} serves (catalogues/repo_contract.csv); list it as covered at transport or excluded with a reason`);
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
}
|
|
981
|
+
else {
|
|
982
|
+
failures.push(...(await surfaceProblems(dir, evidence, order.indexOf(name))));
|
|
983
|
+
// The gate's outcome is recorded where the SDD ships record schema 1.4.0 or later when the baseline archives;
|
|
984
|
+
// a baseline archived earlier carries none and is not held to it.
|
|
985
|
+
const archivedAt = await integratedAt(repo, dir);
|
|
986
|
+
if (versionAtLeast(await shippedRecordSchema(repo, archivedAt), "1.4.0")) {
|
|
987
|
+
const gate = baselineGate(evidence);
|
|
988
|
+
if (gate !== "PASS")
|
|
989
|
+
failures.push(`${dir}/baseline-evidence.md: archived ${gate === null ? "with no **Baseline gate:** line" : `with **Baseline gate:** ${gate}`}; a baseline archives once its gate passes: every requirement cites its evidence, characterization tests exist where the code has none, and every covered surface is evidenced at its transport (M-ADR 0222 §4.3)`);
|
|
871
990
|
}
|
|
872
991
|
}
|
|
873
992
|
}
|
|
@@ -902,8 +1021,34 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
902
1021
|
lastTouch.delete(`${feature}::${from}`);
|
|
903
1022
|
}
|
|
904
1023
|
}
|
|
905
|
-
|
|
906
|
-
|
|
1024
|
+
// An open baseline is asked the archive's questions while it can still be fixed: its Surfaces list, what it may
|
|
1025
|
+
// change given the lineage so far, the features its deltas are keyed by, and its gate. Warnings, since it is in
|
|
1026
|
+
// progress; `change archive` refuses what is still wrong when it archives.
|
|
1027
|
+
const featureIds = await readText(join(repo, "catalogues", "features.csv")).then((t) => (t === null ? null : new Set(parseCatalogueCsv(t).rows.map((r) => r["id"] ?? ""))));
|
|
1028
|
+
for (const change of openBaselines) {
|
|
1029
|
+
const dir = `${STORE_DIR}/changes/${change}`;
|
|
1030
|
+
const evidence = (await readText(join(repo, dir, "baseline-evidence.md"))) ?? "";
|
|
1031
|
+
const found = [...(await surfaceProblems(dir, evidence))];
|
|
1032
|
+
for (const feature of await dirs(join(repo, dir, "specs"))) {
|
|
1033
|
+
if (featureIds !== null && !featureIds.has(feature))
|
|
1034
|
+
found.push(`${dir}/specs/${feature}: no catalogue feature has the id "${feature}"; give the behaviour a feature first (M-ADR 0222 §4.4)`);
|
|
1035
|
+
const text = (await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "";
|
|
1036
|
+
for (const target of [...parseRequirements(text).filter((r) => r.operation === "MODIFIED" || r.operation === "REMOVED").map((r) => r.name), ...renamedFrom(text)]) {
|
|
1037
|
+
scopeChecked++;
|
|
1038
|
+
const last = lastTouch.get(`${feature}::${target}`);
|
|
1039
|
+
if (last !== "baseline")
|
|
1040
|
+
found.push(`${dir}: a baseline changes "${target}" (${feature}), which ${last === "change" ? "a judged change last shipped" : "no earlier archived change admitted"}; a baseline may remove or modify only what a baseline admitted (M-ADR 0222 §4.4)`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
const gate = baselineGate(evidence);
|
|
1044
|
+
if (gate !== "PASS")
|
|
1045
|
+
found.push(`${dir}/baseline-evidence.md: ${gate === null ? "no **Baseline gate:** line yet" : `**Baseline gate:** ${gate}`}; it archives once the gate passes (M-ADR 0222 §4.3)`);
|
|
1046
|
+
warnings.push(...found.map((w) => `open baseline: ${w}`));
|
|
1047
|
+
}
|
|
1048
|
+
if (openBaselines.length > 0)
|
|
1049
|
+
lines.push(`open baselines: ${openBaselines.length}, asked the archive's questions as warnings`);
|
|
1050
|
+
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)` : ""}`);
|
|
907
1052
|
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`);
|
|
908
1053
|
if (!history && archived.length > 0)
|
|
909
1054
|
lines.push("order: no git history (not a git work tree, or git log failed), so same-day archives cannot be ordered");
|
|
@@ -954,6 +1099,29 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
954
1099
|
lines.push(drift === 0 ? `schema: ${SCHEMA_NAME}, identical to the shipped copy` : `schema: ${SCHEMA_NAME}, ${drift} file(s) differ from the shipped copy`);
|
|
955
1100
|
}
|
|
956
1101
|
}
|
|
1102
|
+
// The baseline schema: checked as sdd-change is where the bundle ships it (a bundle from before it shipped has none),
|
|
1103
|
+
// and an open baseline selects it rather than sdd-change, whose Gate 0, test plan and agreement a baseline lacks.
|
|
1104
|
+
if (selected === SCHEMA_NAME) {
|
|
1105
|
+
const b = await compareStoreSchema(repo, BASELINE_SCHEMA_NAME);
|
|
1106
|
+
if (b.shipped) {
|
|
1107
|
+
const fix = "sdd-cli sync refreshes it; put adopter guidance in the store's config.yaml rules, not in the schema";
|
|
1108
|
+
for (const f of b.missing)
|
|
1109
|
+
failures.push(`${storeSchemaDir(BASELINE_SCHEMA_NAME)}/${f}: missing from the store's copy of the schema; ${fix}`);
|
|
1110
|
+
for (const f of b.differing)
|
|
1111
|
+
failures.push(`${storeSchemaDir(BASELINE_SCHEMA_NAME)}/${f}: differs from the shipped ${shippedSchemaDir(BASELINE_SCHEMA_NAME)}/${f}; ${fix}`);
|
|
1112
|
+
for (const f of b.extra)
|
|
1113
|
+
failures.push(`${storeSchemaDir(BASELINE_SCHEMA_NAME)}/${f}: not in the shipped schema; ${fix}`);
|
|
1114
|
+
const drift = b.missing.length + b.differing.length + b.extra.length;
|
|
1115
|
+
lines.push(drift === 0 ? `schema: ${BASELINE_SCHEMA_NAME}, identical to the shipped copy` : `schema: ${BASELINE_SCHEMA_NAME}, ${drift} file(s) differ from the shipped copy`);
|
|
1116
|
+
for (const change of openBaselines) {
|
|
1117
|
+
const meta = await readText(join(repo, STORE_DIR, "changes", change, ".openspec.yaml"));
|
|
1118
|
+
const chosen = meta === null ? null : parseDocument(meta).toJS()?.schema;
|
|
1119
|
+
if (chosen !== BASELINE_SCHEMA_NAME) {
|
|
1120
|
+
warnings.push(`open baseline: ${STORE_DIR}/changes/${change}/.openspec.yaml selects ${chosen === null || chosen === undefined ? "no schema" : `"${String(chosen)}"`}; a baseline selects ${BASELINE_SCHEMA_NAME} (schema: ${BASELINE_SCHEMA_NAME}), whose artifacts are a baseline's rather than a change's Gate 0, test plan and agreement`);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
957
1125
|
const population = recordFiles.length + open.length + archived.length;
|
|
958
1126
|
const body = lines.map((l) => ` ${l}`).join("\n") + "\n";
|
|
959
1127
|
if (failures.length > 0) {
|
|
@@ -1012,25 +1180,47 @@ async function historicalBaselines(repo, archiveRel, order) {
|
|
|
1012
1180
|
});
|
|
1013
1181
|
return out;
|
|
1014
1182
|
}
|
|
1015
|
-
/**
|
|
1183
|
+
/**
|
|
1184
|
+
* Per repository, the contracts the catalogue says it serves, with how many it only consumes; null when repo_contract or
|
|
1185
|
+
* contracts is missing. repo_contract binds a repository to a contract in either direction, so a row is dropped where
|
|
1186
|
+
* service_contract records the repository's services consuming that contract and none exposing it. Rows
|
|
1187
|
+
* service_contract says nothing about stay: a model that omits a service's exposes rows does not shrink what is checked.
|
|
1188
|
+
*/
|
|
1016
1189
|
async function catalogueContracts(repo) {
|
|
1017
1190
|
const rc = await readText(join(repo, "catalogues", "repo_contract.csv"));
|
|
1018
1191
|
const cs = await readText(join(repo, "catalogues", "contracts.csv"));
|
|
1019
1192
|
if (rc === null || cs === null)
|
|
1020
1193
|
return null;
|
|
1021
1194
|
const spec = new Map(parseCatalogueCsv(cs).rows.map((r) => [r["id"] ?? "", r["spec_path"] ?? ""]));
|
|
1195
|
+
const repoOf = new Map(parseCatalogueCsv((await readText(join(repo, "catalogues", "services.csv"))) ?? "").rows.map((r) => [r["id"] ?? "", r["primary_repo_id"] ?? ""]));
|
|
1196
|
+
const roles = new Map();
|
|
1197
|
+
for (const r of parseCatalogueCsv((await readText(join(repo, "catalogues", "service_contract.csv"))) ?? "").rows) {
|
|
1198
|
+
const key = `${repoOf.get(r["service_id"] ?? "") ?? ""}\u0000${r["contract_id"] ?? ""}`;
|
|
1199
|
+
roles.set(key, (roles.get(key) ?? new Set()).add(r["role"] ?? ""));
|
|
1200
|
+
}
|
|
1022
1201
|
const out = new Map();
|
|
1023
1202
|
for (const r of parseCatalogueCsv(rc).rows) {
|
|
1024
1203
|
const repoId = r["repo_id"] ?? "";
|
|
1025
1204
|
const id = r["contract_id"] ?? "";
|
|
1026
1205
|
if (repoId === "" || id === "")
|
|
1027
1206
|
continue;
|
|
1028
|
-
const
|
|
1029
|
-
|
|
1030
|
-
|
|
1207
|
+
const entry = out.get(repoId) ?? { served: [], consumed: 0 };
|
|
1208
|
+
const role = roles.get(`${repoId}\u0000${id}`);
|
|
1209
|
+
if (role?.has("consumes") && !role.has("exposes"))
|
|
1210
|
+
entry.consumed++;
|
|
1211
|
+
else
|
|
1212
|
+
entry.served.push({ id, specPath: spec.get(id) ?? "" });
|
|
1213
|
+
out.set(repoId, entry);
|
|
1031
1214
|
}
|
|
1032
1215
|
return out;
|
|
1033
1216
|
}
|
|
1217
|
+
/** A baseline's recorded gate outcome: the last `**Baseline gate:** <outcome>` line's first word, upper-cased; null when there is none. */
|
|
1218
|
+
function baselineGate(evidence) {
|
|
1219
|
+
let out = null;
|
|
1220
|
+
for (const m of evidence.matchAll(/^\s*\*\*Baseline gate:\*\*\s*(\S+)/gm))
|
|
1221
|
+
out = m[1].replace(/[^A-Za-z]/g, "").toUpperCase();
|
|
1222
|
+
return out;
|
|
1223
|
+
}
|
|
1034
1224
|
async function observedInAll(repo, archiveRel, names) {
|
|
1035
1225
|
const out = [];
|
|
1036
1226
|
for (const n of names) {
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
// gherkin Given/When/Then lines anywhere else;
|
|
15
15
|
// sections content lines under a heading that states behaviour: Happy Path, Edge Cases, Error Handling,
|
|
16
16
|
// Business Rules, Validation Rules, Acceptance Criteria.
|
|
17
|
+
// A pointer is not a restatement: under a behaviour heading, a line naming `openspec/specs/<feature-id>/` is the
|
|
18
|
+
// pointer the template asks for there ("## Acceptance Criteria" with "shipped, see openspec/specs/…: <names>"), unless
|
|
19
|
+
// it is itself a criterion (a checkbox) or a Given/When/Then line.
|
|
17
20
|
// Intent not yet shipped is allowed where it is marked. A heading reading "Not yet shipped or agreed" covers its
|
|
18
21
|
// section, to the next heading of the same or a higher level. A bold line reading so covers what follows it, to the
|
|
19
22
|
// next heading of any level. Open Questions' checkboxes are not
|
|
@@ -29,6 +32,8 @@ const NOT_YET = /not yet (shipped|agreed)/i;
|
|
|
29
32
|
const CRITERIA_START = /^\*\*Acceptance Criteria:?\*\*/i;
|
|
30
33
|
const GHERKIN = /^\s*(?:[-*]\s+(?:\[[ xX]\]\s+)?)?(?:\*\*)?(Given|When|Then)\b/;
|
|
31
34
|
const LIST_LINE = /^\s*(?:[-*]|\d+\.)\s+\S/;
|
|
35
|
+
const POINTER_LINE = /openspec\/specs\/[^/\s`)]+/;
|
|
36
|
+
const CHECKBOX = /^\s*(?:[-*]|\d+\.)\s+\[[ xX]\]/;
|
|
32
37
|
/** "## 3. Edge Cases (Revised)" -> "edge cases". */
|
|
33
38
|
function headingKey(h) {
|
|
34
39
|
return h.replace(/^\s*\d+(\.\d+)*\.?\s*/, "").replace(/\((revised|updated)\)\s*$/i, "").trim().toLowerCase();
|
|
@@ -72,6 +77,8 @@ export function scanRestated(text) {
|
|
|
72
77
|
if (!line.trim() || /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line))
|
|
73
78
|
continue; // blank, or a table separator
|
|
74
79
|
if (stack.some((s) => BEHAVIOUR_HEADING.test(s.key))) {
|
|
80
|
+
if (POINTER_LINE.test(line) && !CHECKBOX.test(line) && !GHERKIN.test(line))
|
|
81
|
+
continue; // the pointer itself
|
|
75
82
|
out.sections.push(n);
|
|
76
83
|
continue;
|
|
77
84
|
}
|