@sdd-method/verify-cli 5.5.0 → 5.6.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/catalogue/canonical-schema.yaml +19 -1
- package/dist/lib/catalogue/coverage-predicates.js +19 -2
- package/dist/lib/catalogue/coverage-render.js +10 -1
- package/dist/lib/catalogue/impact-run.js +54 -2
- package/dist/lib/catalogue/rubric-coverage-map.yaml +4 -0
- package/dist/lib/structure/catalogue.js +7 -0
- package/dist/lib/validate/behaviour-store.js +68 -2
- package/dist/lib/validate/carve-citations.js +64 -5
- package/dist/lib/validate/dispatch.js +1 -1
- package/dist/maintain-version.json +2 -2
- package/dist/verbs/catalogue-verify.js +2 -0
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# Emitted by sdd-cli's YAML emitter with no line folding; the three header scalars are quoted for the
|
|
8
8
|
# textual readers downstream. The one deliberate difference from the authored form is schema_status:
|
|
9
9
|
# the authored form says canonical; a projection never does.
|
|
10
|
-
schema_version: "7.
|
|
10
|
+
schema_version: "7.4.0"
|
|
11
11
|
schema_owner: "sdd-method"
|
|
12
12
|
schema_status: "generated"
|
|
13
13
|
enums:
|
|
@@ -785,6 +785,11 @@ enums:
|
|
|
785
785
|
- value: description
|
|
786
786
|
description: A catalogue-entry description that many things share.
|
|
787
787
|
description: An entity's Party-Place-Thing archetype, as the capability spec template classifies it (schema 7.1.0).
|
|
788
|
+
repository_status:
|
|
789
|
+
values:
|
|
790
|
+
- value: empty
|
|
791
|
+
description: "Registered, and observed empty on the date its repo-config gives: nothing to snapshot, scan or attribute yet."
|
|
792
|
+
description: Whether a registered repository holds anything yet (7.4.0).
|
|
788
793
|
catalogues:
|
|
789
794
|
domains:
|
|
790
795
|
primary_key: id
|
|
@@ -1599,6 +1604,10 @@ catalogues:
|
|
|
1599
1604
|
nullable: true
|
|
1600
1605
|
description: The platform this row belongs to — sdd_id of its platform-root. Stamped by the aggregator per M-ADR 0208 §4.4 (preserve-if-set, fill rule by pull path); left empty by per-SDD builders. Added in 3.7.0.
|
|
1601
1606
|
source_of_truth: execution
|
|
1607
|
+
- name: variant
|
|
1608
|
+
type: string
|
|
1609
|
+
nullable: true
|
|
1610
|
+
description: "The variant a feature group belongs to, from a [variant: <id>] marker on its heading (or inherited from the heading above it), as features.variant. Added in 7.4.0."
|
|
1602
1611
|
description: |
|
|
1603
1612
|
Hierarchical grouping for features. First-class entity — replaces the previous reverse-only synthetic `feature-tree-group` rows in features.csv. Both forward and reverse can use feature_groups: forward application-product SDDs whose `feature-tree.md` has H2/H3 structure, reverse SDDs whose extracted feature trees are inherently hierarchical, and any SDD that promotes groups to first-class with authored metadata. Groups can nest via `parent_group_id`.
|
|
1604
1613
|
foreign_keys:
|
|
@@ -1673,6 +1682,10 @@ catalogues:
|
|
|
1673
1682
|
nullable: true
|
|
1674
1683
|
description: The platform this row belongs to — sdd_id of its platform-root. Stamped by the aggregator per M-ADR 0208 §4.4 (preserve-if-set, fill rule by pull path); left empty by per-SDD builders. Added in 3.7.0.
|
|
1675
1684
|
source_of_truth: execution
|
|
1685
|
+
- name: variant
|
|
1686
|
+
type: string
|
|
1687
|
+
nullable: true
|
|
1688
|
+
description: "The customer-specific or segment variant the feature belongs to, from a [variant: <id>] marker in feature-tree.md on its bullet or on a group heading above it (inherited by everything beneath a marked group). The id is one the SDD declares in feature-variant-vocabulary.yaml at its root; an undeclared id is a convention finding and is not recorded. Empty for a feature of the product itself. Added in 7.4.0."
|
|
1676
1689
|
foreign_keys:
|
|
1677
1690
|
- column: product_id
|
|
1678
1691
|
references: products.id
|
|
@@ -2160,6 +2173,10 @@ catalogues:
|
|
|
2160
2173
|
type: string
|
|
2161
2174
|
nullable: true
|
|
2162
2175
|
description: "Whether the repository is inside the scope of what THIS SDD specifies, from its reverse-metadata.yaml (M-ADR 0213 §4.2): component (part of what the SDD specifies) or support (supports a component without being one). Empty for a repository with no reverse metadata. Its kind is component_type; the SDD's profile is products/applications.profile."
|
|
2176
|
+
- name: status
|
|
2177
|
+
type: string
|
|
2178
|
+
nullable: true
|
|
2179
|
+
description: "Whether the repository holds anything yet, from its repo-config: empty for a repository registered while it is empty (status: empty, with the date it was observed so in observed_empty_at, required), which coverage checks report as not applicable rather than as gaps; otherwise empty. A delta-reverse re-checks it: a repository declared empty that is exported with content is a finding. Added in 7.4.0."
|
|
2163
2180
|
foreign_keys:
|
|
2164
2181
|
- column: domain_id
|
|
2165
2182
|
references: domains.id
|
|
@@ -2170,6 +2187,7 @@ catalogues:
|
|
|
2170
2187
|
column_enums:
|
|
2171
2188
|
component_type: enums.service_type
|
|
2172
2189
|
repo_scope: enums.repo_scope
|
|
2190
|
+
status: enums.repository_status
|
|
2173
2191
|
domain_models:
|
|
2174
2192
|
primary_key: id
|
|
2175
2193
|
source_of_truth: structure
|
|
@@ -62,12 +62,15 @@ SELECT
|
|
|
62
62
|
WHERE s."primary_repo_id" = r."id"
|
|
63
63
|
AND s."id" IS NOT NULL AND s."id" <> ''
|
|
64
64
|
AND NOT EXISTS (SELECT 1 FROM "capability_service" cs
|
|
65
|
-
WHERE cs."service_id" = s."id")) AS unclaimed_ids
|
|
65
|
+
WHERE cs."service_id" = s."id")) AS unclaimed_ids,
|
|
66
|
+
__IS_EMPTY__ AS is_empty
|
|
66
67
|
FROM "repositories" r
|
|
67
68
|
WHERE r."id" IS NOT NULL AND r."id" <> ''
|
|
68
69
|
ORDER BY repo_id;`;
|
|
69
70
|
export async function runRepoCapabilityCoverage(session) {
|
|
70
|
-
|
|
71
|
+
// repositories.status arrived in schema 7.4.0; a catalogue set built earlier has no such column.
|
|
72
|
+
const hasStatus = (await session.conn.runAndReadAll(`SELECT 1 FROM information_schema.columns WHERE table_name = 'repositories' AND column_name = 'status'`)).getRows().length > 0;
|
|
73
|
+
const reader = await session.conn.runAndReadAll(REPO_FLAGS_SQL.replace("__IS_EMPTY__", hasStatus ? `COALESCE(r."status", '') = 'empty'` : "FALSE"));
|
|
71
74
|
const rows = reader.getRows().map((r) => ({
|
|
72
75
|
repoId: String(r[0]),
|
|
73
76
|
hasCap: r[1] === true,
|
|
@@ -78,6 +81,7 @@ export async function runRepoCapabilityCoverage(session) {
|
|
|
78
81
|
unclaimedIds: r[6] === null || r[6] === undefined
|
|
79
82
|
? []
|
|
80
83
|
: String(r[6]).split("\x1f"),
|
|
84
|
+
empty: r[7] === true,
|
|
81
85
|
}));
|
|
82
86
|
const findings = [];
|
|
83
87
|
let byCapability = 0;
|
|
@@ -86,7 +90,19 @@ export async function runRepoCapabilityCoverage(session) {
|
|
|
86
90
|
let unresolved = 0;
|
|
87
91
|
let shapeA = 0;
|
|
88
92
|
let shapeB = 0;
|
|
93
|
+
let registeredEmpty = 0;
|
|
89
94
|
for (const row of rows) {
|
|
95
|
+
// A repository registered while empty has nothing to resolve to a capability yet (sdm-py85); one that claims a
|
|
96
|
+
// capability while declaring itself empty contradicts itself, and is reported.
|
|
97
|
+
if (row.empty && !row.hasCap) {
|
|
98
|
+
registeredEmpty++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (row.empty && row.hasCap) {
|
|
102
|
+
conflicting++;
|
|
103
|
+
findings.push({ predicate: "repo_capability", kind: "divergence", subjectId: row.repoId, classification: "empty_with_capability" });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
90
106
|
if (row.hasCap && row.hasExempt) {
|
|
91
107
|
conflicting++;
|
|
92
108
|
findings.push({
|
|
@@ -146,6 +162,7 @@ export async function runRepoCapabilityCoverage(session) {
|
|
|
146
162
|
shapeA,
|
|
147
163
|
shapeB,
|
|
148
164
|
other: unresolved - shapeA - shapeB,
|
|
165
|
+
...(registeredEmpty > 0 ? { registeredEmpty } : {}),
|
|
149
166
|
},
|
|
150
167
|
};
|
|
151
168
|
}
|
|
@@ -47,6 +47,14 @@ export function renderRepoCapability(findings, metrics) {
|
|
|
47
47
|
`must not claim a capability (per M-ADR 0188 §4.1)`,
|
|
48
48
|
}));
|
|
49
49
|
}
|
|
50
|
+
else if (f.classification === "empty_with_capability") {
|
|
51
|
+
lines.push(renderFindingLine({
|
|
52
|
+
category: "divergence",
|
|
53
|
+
subject,
|
|
54
|
+
summary: `repo ${f.subjectId} is registered empty (repo-config status: empty) but has a repo_capability edge — ` +
|
|
55
|
+
`an empty repository implements nothing yet; drop the status once it has content`,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
50
58
|
else if (f.classification === "shape_a") {
|
|
51
59
|
const names = (f.unclaimedServices ?? []).join(", ") +
|
|
52
60
|
((f.unclaimedCount ?? 0) > 3 ? " …" : "");
|
|
@@ -82,10 +90,11 @@ export function renderRepoCapability(findings, metrics) {
|
|
|
82
90
|
lines.push(`[metric] repo_capability_resolution: n/a — 0 repository rows, nothing to resolve`);
|
|
83
91
|
}
|
|
84
92
|
if (metrics.total > 0) {
|
|
85
|
-
const resolved = metrics.byCapability + metrics.byExemption;
|
|
93
|
+
const resolved = metrics.byCapability + metrics.byExemption + (metrics.registeredEmpty ?? 0);
|
|
86
94
|
const pct = fmt1f((100.0 * resolved) / metrics.total);
|
|
87
95
|
lines.push(`[metric] repo_capability_resolution: ${resolved}/${metrics.total} (${pct}%) ` +
|
|
88
96
|
`— ${metrics.byCapability} by capability, ${metrics.byExemption} by exemption, ` +
|
|
97
|
+
`${metrics.registeredEmpty ? `${metrics.registeredEmpty} registered empty (not applicable), ` : ""}` +
|
|
89
98
|
`${metrics.conflicting} conflicting, ${metrics.unresolved} unresolved`);
|
|
90
99
|
if (metrics.unresolved > 0) {
|
|
91
100
|
lines.push(`[metric] repo_capability_orphan_shape: ${metrics.shapeA} with no local ` +
|
|
@@ -82,11 +82,11 @@ export function buildImpactPropagation(edges, semantics) {
|
|
|
82
82
|
continue;
|
|
83
83
|
}
|
|
84
84
|
if (flow === "toward-source" || flow === "both") {
|
|
85
|
-
out.push({ from: e.target, to: e.source, type: e.type });
|
|
85
|
+
out.push({ from: e.target, to: e.source, type: e.type, flow });
|
|
86
86
|
notePair(e.target, e.source, e.type);
|
|
87
87
|
}
|
|
88
88
|
if (flow === "toward-target" || flow === "both") {
|
|
89
|
-
out.push({ from: e.source, to: e.target, type: e.type });
|
|
89
|
+
out.push({ from: e.source, to: e.target, type: e.type, flow });
|
|
90
90
|
notePair(e.source, e.target, e.type);
|
|
91
91
|
}
|
|
92
92
|
}
|
|
@@ -159,6 +159,43 @@ export async function computeImpactClosure(propagation, seed) {
|
|
|
159
159
|
conn.disconnectSync();
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Consumers of `seed` (sdm-6npk): who depends on it, directly or through a chain of dependencies. Only dependency-
|
|
164
|
+
* shaped steps (declared flow toward-source: a change at the target reaches the source that depends on it) are
|
|
165
|
+
* followed; membership (toward-target) and realisation (both) are not, so a shared hub's parts and wholes do not flood
|
|
166
|
+
* the answer. Breadth-first, so each consumer is reported with a shortest dependency path from the seed.
|
|
167
|
+
*/
|
|
168
|
+
export function consumersOf(propagation, seed) {
|
|
169
|
+
const next = new Map();
|
|
170
|
+
for (const e of propagation) {
|
|
171
|
+
if (e.flow !== "toward-source")
|
|
172
|
+
continue;
|
|
173
|
+
(next.get(e.from) ?? next.set(e.from, []).get(e.from)).push(e);
|
|
174
|
+
}
|
|
175
|
+
const prev = new Map();
|
|
176
|
+
const seen = new Set([seed]);
|
|
177
|
+
const queue = [seed];
|
|
178
|
+
const order = [];
|
|
179
|
+
while (queue.length > 0) {
|
|
180
|
+
const cur = queue.shift();
|
|
181
|
+
for (const e of (next.get(cur) ?? []).slice().sort((a, b) => a.to.localeCompare(b.to))) {
|
|
182
|
+
if (seen.has(e.to))
|
|
183
|
+
continue;
|
|
184
|
+
seen.add(e.to);
|
|
185
|
+
prev.set(e.to, cur);
|
|
186
|
+
order.push(e.to);
|
|
187
|
+
queue.push(e.to);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return order
|
|
191
|
+
.map((id) => {
|
|
192
|
+
const path = [id];
|
|
193
|
+
for (let p = prev.get(id); p !== undefined; p = prev.get(p))
|
|
194
|
+
path.unshift(p);
|
|
195
|
+
return { id, path };
|
|
196
|
+
})
|
|
197
|
+
.sort((a, b) => a.path.length - b.path.length || a.id.localeCompare(b.id));
|
|
198
|
+
}
|
|
162
199
|
export async function runCatalogueImpact(inputs, write) {
|
|
163
200
|
const sddRoot = resolve(inputs.sddPath ?? process.cwd());
|
|
164
201
|
const tier = inputs.tier ?? "member";
|
|
@@ -200,6 +237,21 @@ export async function runCatalogueImpact(inputs, write) {
|
|
|
200
237
|
return { exitCode: 2 };
|
|
201
238
|
}
|
|
202
239
|
const prop = buildImpactPropagation(edges, semantics);
|
|
240
|
+
if (inputs.consumers === true) {
|
|
241
|
+
const found = consumersOf(prop.edges, inputs.of);
|
|
242
|
+
const byCat = new Map();
|
|
243
|
+
for (const c of found)
|
|
244
|
+
byCat.set(c.id.slice(0, c.id.indexOf("/")), (byCat.get(c.id.slice(0, c.id.indexOf("/"))) ?? 0) + 1);
|
|
245
|
+
write(`consumers of ${inputs.of} (${tier} tier, schema ${schema.schemaVersion})\n`);
|
|
246
|
+
write(` semantics: who depends on it, directly or through dependencies: only dependency-shaped relationships\n` +
|
|
247
|
+
` (declared flow toward-source) are followed, never membership or realisation; each consumer with a shortest path\n`);
|
|
248
|
+
write(` consumers: ${found.length}\n`);
|
|
249
|
+
for (const [cat, n] of [...byCat.entries()].sort())
|
|
250
|
+
write(` ${cat}: ${n}\n`);
|
|
251
|
+
for (const c of found)
|
|
252
|
+
write(` ${c.id} (${c.path.join(" <- ")})\n`);
|
|
253
|
+
return { exitCode: 0, result: { seed: inputs.of, closure: found.map((c) => c.id), byCatalogue: byCat } };
|
|
254
|
+
}
|
|
203
255
|
const closure = await computeImpactClosure(prop.edges, inputs.of);
|
|
204
256
|
const byCatalogue = new Map();
|
|
205
257
|
for (const id of closure) {
|
|
@@ -26,7 +26,14 @@ function modelDerived(catalogue, e, view) {
|
|
|
26
26
|
switch (catalogue) {
|
|
27
27
|
case "features":
|
|
28
28
|
case "capabilities":
|
|
29
|
+
case "feature_groups":
|
|
29
30
|
return { source: "structural-model" };
|
|
31
|
+
// A link the model states is a declared one (sdm-0enc): an ADR the capability cites, an event it publishes or
|
|
32
|
+
// consumes. The heuristic values (high, medium; derived) belong to links a builder infers from the sources.
|
|
33
|
+
case "capability_adr":
|
|
34
|
+
return { confidence: "spec" };
|
|
35
|
+
case "capability_event":
|
|
36
|
+
return { source: "authored" };
|
|
30
37
|
case "domain_models":
|
|
31
38
|
return { doc_path: VIEW_PATHS.dataModel };
|
|
32
39
|
case "c4_systems":
|
|
@@ -273,6 +273,25 @@ async function gitShow(repo, commit, path) {
|
|
|
273
273
|
return null;
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
|
+
/** Files under a directory on disk, relative to it, sorted. */
|
|
277
|
+
async function filesUnder(root, rel = "") {
|
|
278
|
+
const out = [];
|
|
279
|
+
let entries;
|
|
280
|
+
try {
|
|
281
|
+
entries = await readdir(join(root, rel), { withFileTypes: true });
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
for (const e of entries) {
|
|
287
|
+
const p = rel ? `${rel}/${e.name}` : e.name;
|
|
288
|
+
if (e.isDirectory())
|
|
289
|
+
out.push(...(await filesUnder(root, p)));
|
|
290
|
+
else
|
|
291
|
+
out.push(p);
|
|
292
|
+
}
|
|
293
|
+
return out.sort();
|
|
294
|
+
}
|
|
276
295
|
/** Paths under a directory at a commit, relative to that directory. */
|
|
277
296
|
async function gitFiles(repo, commit, dir) {
|
|
278
297
|
try {
|
|
@@ -923,6 +942,13 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
923
942
|
}
|
|
924
943
|
return false;
|
|
925
944
|
};
|
|
945
|
+
/**
|
|
946
|
+
* What the gate asks of this baseline (sdm-bjy3). Every baseline records **Baseline gate:**, a structure-only one
|
|
947
|
+
* too; for it the gate is its structural deltas evidenced in the code it observes, not requirements and tests.
|
|
948
|
+
*/
|
|
949
|
+
const gateRule = async (dir) => (await admitsBehaviour(dir))
|
|
950
|
+
? "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"
|
|
951
|
+
: "every baseline records **Baseline gate:**, a structure-only one too (it admits no behaviour): PASS once its structural deltas are evidenced in the code it observes";
|
|
926
952
|
/** What is wrong with a baseline's Surfaces list against the catalogue (M-ADR 0222 §4.3, §4.4). */
|
|
927
953
|
const surfaceProblems = async (dir, evidence, after = -1) => {
|
|
928
954
|
const out = [];
|
|
@@ -1070,7 +1096,7 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
1070
1096
|
if (versionAtLeast(await shippedRecordSchema(repo, archivedAt), "1.4.0")) {
|
|
1071
1097
|
const gate = baselineGate(evidence);
|
|
1072
1098
|
if (gate !== "PASS")
|
|
1073
|
-
failures.push(`${dir}/baseline-evidence.md: archived ${gate === null ? "with no **Baseline gate:** line" : `with **Baseline gate:** ${gate}`};
|
|
1099
|
+
failures.push(`${dir}/baseline-evidence.md: archived ${gate === null ? "with no **Baseline gate:** line" : `with **Baseline gate:** ${gate}`}; ${await gateRule(dir)} (M-ADR 0222 §4.3)`);
|
|
1074
1100
|
}
|
|
1075
1101
|
}
|
|
1076
1102
|
}
|
|
@@ -1126,13 +1152,53 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
1126
1152
|
}
|
|
1127
1153
|
const gate = baselineGate(evidence);
|
|
1128
1154
|
if (gate !== "PASS")
|
|
1129
|
-
found.push(`${dir}/baseline-evidence.md: ${gate === null ? "no **Baseline gate:** line yet" : `**Baseline gate:** ${gate}`};
|
|
1155
|
+
found.push(`${dir}/baseline-evidence.md: ${gate === null ? "no **Baseline gate:** line yet" : `**Baseline gate:** ${gate}`}; ${await gateRule(dir)} (M-ADR 0222 §4.3)`);
|
|
1130
1156
|
warnings.push(...found.map((w) => `open baseline: ${w}`));
|
|
1131
1157
|
}
|
|
1132
1158
|
if (openBaselines.length > 0)
|
|
1133
1159
|
lines.push(`open baselines: ${openBaselines.length}, asked the archive's questions as warnings`);
|
|
1134
1160
|
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"}`;
|
|
1135
1161
|
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` : ""}${noneServedBaselines ? `; ${noneServedBaselines} baseline(s) of services serving no contract, Surfaces: none served` : ""}${historical.size ? `; ${historical.size} historical baseline(s) not checked (every requirement they baselined has left the living specs)` : ""}`);
|
|
1162
|
+
// Archive integrity (sdm-9sk1): what MERGED is not edited. An archived change's spec deltas and structural deltas
|
|
1163
|
+
// (specs/**, structure/** with applied.yaml) are what entered the living model and what the history catalogues are
|
|
1164
|
+
// derived from (requirement_lineage, revisions, change_effects); editing one after it archived makes the history
|
|
1165
|
+
// claim something entered that never did. A mistake there is fixed by a new change (a corrective baseline, for a
|
|
1166
|
+
// baseline). Every other file of an archived change is bookkeeping (evidence notes, errata, tasks, a deferral) and may
|
|
1167
|
+
// be corrected; git is the record of who changed what. Each archived change is compared with its tree at the commit
|
|
1168
|
+
// that added it; without git history the check is unresolved, never a pass.
|
|
1169
|
+
if (history) {
|
|
1170
|
+
let checkedArchives = 0;
|
|
1171
|
+
let payloadFiles = 0;
|
|
1172
|
+
let bookkeepingEdits = 0;
|
|
1173
|
+
const payload = (f) => f.startsWith("specs/") || f.startsWith("structure/");
|
|
1174
|
+
for (const name of order) {
|
|
1175
|
+
const dir = `${archiveRel}/${name}`;
|
|
1176
|
+
const at = await integratedAt(repo, dir);
|
|
1177
|
+
if (at === null)
|
|
1178
|
+
continue; // not yet committed: nothing archived to compare with
|
|
1179
|
+
checkedArchives++;
|
|
1180
|
+
const then = new Set(await gitFiles(repo, at, dir));
|
|
1181
|
+
const now = new Set(await filesUnder(join(repo, dir)));
|
|
1182
|
+
for (const f of [...new Set([...then, ...now])].sort()) {
|
|
1183
|
+
const before = then.has(f) ? await gitShow(repo, at, `${dir}/${f}`) : null;
|
|
1184
|
+
const after = now.has(f) ? await readText(join(repo, dir, f)) : null;
|
|
1185
|
+
if (payload(f))
|
|
1186
|
+
payloadFiles++;
|
|
1187
|
+
if (before === after)
|
|
1188
|
+
continue;
|
|
1189
|
+
if (!payload(f)) {
|
|
1190
|
+
bookkeepingEdits++;
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
const what = before === null ? "added to" : after === null ? "removed from" : "edited in";
|
|
1194
|
+
failures.push(`${dir}/${f}: ${what} an archived change after it archived (at ${at.slice(0, 12)}); its spec and structural deltas are what merged into the living model, so a mistake there is fixed by a new change (a corrective baseline, for a baseline), not by editing the archive`);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
lines.push(`archive integrity: ${checkedArchives} archived change(s), ${payloadFiles} merged delta file(s) compared with the commit that archived them${bookkeepingEdits ? `; ${bookkeepingEdits} later correction(s) to bookkeeping files (evidence, tasks, notes), which may be corrected` : ""}`);
|
|
1198
|
+
}
|
|
1199
|
+
else if (archived.length > 0) {
|
|
1200
|
+
lines.push("archive integrity: not checked (no git history: run from a clone)");
|
|
1201
|
+
}
|
|
1136
1202
|
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`);
|
|
1137
1203
|
if (!history && archived.length > 0)
|
|
1138
1204
|
lines.push("order: no git history (not a git work tree, or git log failed), so same-day archives cannot be ordered");
|
|
@@ -18,13 +18,17 @@
|
|
|
18
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
19
|
// removed is "in flight" and is reported in the inventory, not failed.
|
|
20
20
|
import { readdir, readFile } from "node:fs/promises";
|
|
21
|
+
import { parse as parseYaml } from "yaml";
|
|
21
22
|
import { basename, dirname, join } from "node:path";
|
|
22
23
|
const STORE = "openspec";
|
|
23
24
|
const REQ_HEAD = /^###\s+Requirement:\s*(.+?)\s*$/;
|
|
24
25
|
const SECTION = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
|
|
25
|
-
const CITE_START = /^\s*(Replaces|Departs from)\s/;
|
|
26
|
+
const CITE_START = /^\s*(Replaces|Departs from|Succeeds)\s/;
|
|
26
27
|
const REPLACES = /^\s*Replaces\s+([A-Za-z0-9._-]+):([A-Za-z0-9._-]+)::(.+?)\.\s*$/;
|
|
27
28
|
const DEPARTS = /^\s*Departs from\s+([A-Za-z0-9._-]+):([A-Za-z0-9._-]+)::(.+?):\s+\S.*$/;
|
|
29
|
+
// A feature that left the other SDD before it was baselined has no requirement to cite (sdm-a31j): the citation is
|
|
30
|
+
// feature-level and names the delta finding that records the removal, as <baseline-id>/<finding-id>.
|
|
31
|
+
const SUCCEEDS = /^\s*Succeeds\s+([A-Za-z0-9._-]+):([A-Za-z0-9._-]+)\s+\(removed before baseline;\s*([^)\s]+)\s*\)\.?\s*$/;
|
|
28
32
|
const REPLACED_BY = /replaced by\s+`?([A-Za-z0-9._-]+):([A-Za-z0-9._-]+)::([^`\n]+?)`?\s*(?:[.;,)]\s*)?$/im;
|
|
29
33
|
const key = (r) => `${r.sdd}:${r.feature}::${r.name}`;
|
|
30
34
|
async function read(p) {
|
|
@@ -94,7 +98,24 @@ async function view(repo) {
|
|
|
94
98
|
}
|
|
95
99
|
return { living, removed };
|
|
96
100
|
}
|
|
97
|
-
|
|
101
|
+
/** The classification of finding `<baseline>/<id>` in an SDD's snapshot area, "" when absent, null when unreachable. */
|
|
102
|
+
async function deltaFinding(repo, ref, store) {
|
|
103
|
+
const area = /^snapshot_area:\s*["']?([^"'\n]+?)["']?\s*$/m.exec((await read(join(repo, ".sdd", "implementation-binding.yaml"))) ?? "")?.[1];
|
|
104
|
+
if (!area || store === "")
|
|
105
|
+
return null;
|
|
106
|
+
const slash = ref.indexOf("/");
|
|
107
|
+
if (slash <= 0)
|
|
108
|
+
return "";
|
|
109
|
+
const text = await read(join(store, area, ref.slice(0, slash), "delta.yaml"));
|
|
110
|
+
if (text === null)
|
|
111
|
+
return "";
|
|
112
|
+
const y = parseYaml(text);
|
|
113
|
+
const f = (y?.findings ?? []).find((x) => x?.id === ref.slice(slash + 1));
|
|
114
|
+
return f === undefined ? "" : String(f.classification ?? "");
|
|
115
|
+
}
|
|
116
|
+
export async function checkCarveCitations(repo,
|
|
117
|
+
/** Snapshot store root the other SDD's snapshot_area is relative to (the verb passes $SDD_SNAPSHOT_STORE). */
|
|
118
|
+
snapshotStore = "") {
|
|
98
119
|
const own = await sddId(repo);
|
|
99
120
|
const failures = [];
|
|
100
121
|
const warnings = [];
|
|
@@ -113,6 +134,19 @@ export async function checkCarveCitations(repo) {
|
|
|
113
134
|
for (const line of b.text.split("\n")) {
|
|
114
135
|
if (!CITE_START.test(line))
|
|
115
136
|
continue;
|
|
137
|
+
const sc = SUCCEEDS.exec(line);
|
|
138
|
+
if (/^\s*Succeeds\s/.test(line)) {
|
|
139
|
+
cites.push({
|
|
140
|
+
where: `${rel}: '${b.name}'`,
|
|
141
|
+
open,
|
|
142
|
+
line: line.trim(),
|
|
143
|
+
ref: sc ? { sdd: sc[1], feature: sc[2], name: "" } : null,
|
|
144
|
+
kind: "succeeds",
|
|
145
|
+
owner: "",
|
|
146
|
+
...(sc ? { evidence: sc[3] } : {}),
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
116
150
|
const r = REPLACES.exec(line);
|
|
117
151
|
const d = r ? null : DEPARTS.exec(line);
|
|
118
152
|
const m = r ?? d;
|
|
@@ -147,7 +181,7 @@ export async function checkCarveCitations(repo) {
|
|
|
147
181
|
}
|
|
148
182
|
}
|
|
149
183
|
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" };
|
|
184
|
+
return { exitCode: 0, stdout: "[N/A] no carve citations (Replaces / Departs from / Succeeds lines, or removals replaced by another SDD) in this repository\n" };
|
|
151
185
|
}
|
|
152
186
|
// The other SDDs, found beside this one by their sdd_id.
|
|
153
187
|
const siblings = new Map();
|
|
@@ -173,11 +207,36 @@ export async function checkCarveCitations(repo) {
|
|
|
173
207
|
const report = (open, msg) => (open ? warnings : failures).push(msg);
|
|
174
208
|
let inFlight = 0;
|
|
175
209
|
let carved = 0;
|
|
210
|
+
let succeeded = 0;
|
|
176
211
|
let unresolved = 0;
|
|
177
212
|
const replacedBy = new Map();
|
|
178
213
|
for (const c of cites) {
|
|
179
214
|
if (c.ref === null) {
|
|
180
|
-
report(c.open, `${c.where}: "${c.line}" is not "Replaces <sdd-id>:<feature>::<requirement name>."
|
|
215
|
+
report(c.open, `${c.where}: "${c.line}" is not "Replaces <sdd-id>:<feature>::<requirement name>.", "Departs from <sdd-id>:<feature>::<requirement name>: <why>." or "Succeeds <sdd-id>:<feature> (removed before baseline; <baseline-id>/<finding-id>)."`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (c.kind === "succeeds") {
|
|
219
|
+
const p = siblings.get(c.ref.sdd);
|
|
220
|
+
const v = await other(c.ref.sdd);
|
|
221
|
+
if (!p || v === null) {
|
|
222
|
+
unresolved++;
|
|
223
|
+
warnings.push(`${c.where}: Succeeds ${c.ref.sdd}:${c.ref.feature} unresolved: no checkout beside this SDD has sdd_id "${c.ref.sdd}"; check it out beside this one (failed under --strict)`);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (v.living.has(c.ref.feature)) {
|
|
227
|
+
report(c.open, `${c.where}: Succeeds ${c.ref.sdd}:${c.ref.feature}, but ${c.ref.sdd} still has a living spec for ${c.ref.feature}: cite its requirement with Replaces`);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const cls = await deltaFinding(p, c.evidence, snapshotStore);
|
|
231
|
+
if (cls === null) {
|
|
232
|
+
unresolved++;
|
|
233
|
+
warnings.push(`${c.where}: Succeeds ${c.ref.sdd}:${c.ref.feature}: its evidence ${c.evidence} is not checked: ${c.ref.sdd} declares no snapshot_area, or no snapshot store was given (SDD_SNAPSHOT_STORE) (failed under --strict)`);
|
|
234
|
+
}
|
|
235
|
+
else if (cls === "" || !/^capability-removed/.test(cls)) {
|
|
236
|
+
report(c.open, `${c.where}: Succeeds ${c.ref.sdd}:${c.ref.feature}: ${c.evidence} ${cls === "" ? `is not a finding in ${c.ref.sdd}'s snapshot area` : `is a ${cls} finding, not capability-removed`}`);
|
|
237
|
+
}
|
|
238
|
+
else
|
|
239
|
+
succeeded++;
|
|
181
240
|
continue;
|
|
182
241
|
}
|
|
183
242
|
const v = await other(c.ref.sdd);
|
|
@@ -215,7 +274,7 @@ export async function checkCarveCitations(repo) {
|
|
|
215
274
|
}
|
|
216
275
|
}
|
|
217
276
|
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`,
|
|
277
|
+
` citations: ${cites.length} Replaces / Departs / Succeeds line(s): ${inFlight} in flight (still living where they came from), ${carved} carved (removed there), ${succeeded ? `${succeeded} succeeded (removed there before its baseline), ` : ""}${unresolved} unresolved`,
|
|
219
278
|
` removals: ${removals.length} replaced by another SDD`,
|
|
220
279
|
];
|
|
221
280
|
const body = lines.join("\n") + "\n";
|
|
@@ -29,7 +29,7 @@ const BEHAVIOUR_STORE_VALIDATORS = [
|
|
|
29
29
|
// Behaviour carved into another SDD keeps one home at a time (the carving guide); without citations it reports [N/A].
|
|
30
30
|
{
|
|
31
31
|
name: "carve-citations",
|
|
32
|
-
source: { kind: "native", run: async (ctx) => checkCarveCitations(ctx.targetRepo) },
|
|
32
|
+
source: { kind: "native", run: async (ctx) => checkCarveCitations(ctx.targetRepo, process.env.SDD_SNAPSHOT_STORE ?? "") },
|
|
33
33
|
build: () => ({ status: "run", args: [] }),
|
|
34
34
|
},
|
|
35
35
|
// Intents (M-ADR 0227) can sit in an SDD of any profile; without any the check reports [N/A].
|
|
@@ -59,6 +59,7 @@ export function registerCatalogueImpact(catalogue) {
|
|
|
59
59
|
.option("--sdd-path <path>", "Path to the SDD repo root (defaults to cwd)")
|
|
60
60
|
.option("--catalogues-dir <dir>", "Catalogues directory relative to the SDD root (default: catalogues, or catalogues-aggregated at --tier aggregate)")
|
|
61
61
|
.option("--tier <tier>", "Tier, explicit never inferred (member | aggregate; default member)")
|
|
62
|
+
.option("--consumers", "Report the node's consumers instead: only dependency relationships, in the consumer direction, each with a shortest path", false)
|
|
62
63
|
.action(async (opts) => {
|
|
63
64
|
if (opts.tier !== undefined &&
|
|
64
65
|
opts.tier !== "member" &&
|
|
@@ -75,6 +76,7 @@ export function registerCatalogueImpact(catalogue) {
|
|
|
75
76
|
...(opts.tier !== undefined
|
|
76
77
|
? { tier: opts.tier }
|
|
77
78
|
: {}),
|
|
79
|
+
...(opts.consumers ? { consumers: true } : {}),
|
|
78
80
|
}, (s) => process.stdout.write(s));
|
|
79
81
|
if (result.exitCode !== 0)
|
|
80
82
|
process.exit(result.exitCode);
|
package/dist/verify-version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdd-method/verify-cli",
|
|
3
|
-
"version": "5.
|
|
4
|
-
"sddCliVersion": "5.
|
|
3
|
+
"version": "5.6.0",
|
|
4
|
+
"sddCliVersion": "5.6.0",
|
|
5
5
|
"description": "Verify an SDD repository without changing it — validate, list, and read-only catalogue and pack checks. Ships only the code those verbs reach; it cannot write to the repository it is pointed at. Authoring and maintenance are in @sdd-method/sdd-cli.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"type": "module",
|