@sdd-method/verify-cli 5.4.2 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/behaviour-store/archive.js +24 -12
- package/dist/lib/catalogue/builders/feature-groups-prune.js +19 -0
- package/dist/lib/catalogue/canonical-schema.yaml +9 -4
- package/dist/lib/catalogue/coverage-predicates.js +13 -2
- package/dist/lib/catalogue/csv-writer.js +1 -1
- package/dist/lib/structure/empty-groups.js +55 -0
- package/dist/lib/validate/behaviour-store.js +61 -3
- package/dist/lib/validate/structural-model.js +6 -1
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -173,7 +173,7 @@ export function observedInProblems(evidence) {
|
|
|
173
173
|
* admits no behaviour may make (the caller decides; `none` is true and the list is empty).
|
|
174
174
|
*/
|
|
175
175
|
/** What drives a service that serves no contract (sdm-xfln): the triggers `**Surfaces:** none served (...)` may name. */
|
|
176
|
-
export const NONE_SERVED_TRIGGERS = ["scheduled", "queue-triggered", "stream-triggered"];
|
|
176
|
+
export const NONE_SERVED_TRIGGERS = ["scheduled", "queue-triggered", "stream-triggered", "invoked"];
|
|
177
177
|
export function parseSurfaces(evidence) {
|
|
178
178
|
const lines = evidence.split("\n");
|
|
179
179
|
// The LAST standalone line: an evidence file may carry an earlier prose section under the same label, and
|
|
@@ -198,7 +198,7 @@ export function parseSurfaces(evidence) {
|
|
|
198
198
|
claim = "generated";
|
|
199
199
|
}
|
|
200
200
|
});
|
|
201
|
-
const base = { present: true, none: false, generated: false, noneServed: null, entries: [], problems: [] };
|
|
201
|
+
const base = { present: true, none: false, generated: false, noneServed: null, repoClaims: [], entries: [], problems: [] };
|
|
202
202
|
if (at < 0)
|
|
203
203
|
return { ...base, present: false };
|
|
204
204
|
if (claim === "none")
|
|
@@ -208,18 +208,12 @@ export function parseSurfaces(evidence) {
|
|
|
208
208
|
if (claim === "none served") {
|
|
209
209
|
// `**Surfaces:** none served (scheduled)`: the service serves no contract; what drives it is named, from a
|
|
210
210
|
// closed vocabulary, so the claim says why and cannot be left vague.
|
|
211
|
-
const m = /none\s+served\s*\(([^)]*)\)\s*\.?\s*$/i.exec(lines[at]);
|
|
212
|
-
const triggers = m ? m[1].split(",").map((t) => t.trim()).filter((t) => t !== "") : [];
|
|
213
211
|
const problems = [];
|
|
214
|
-
|
|
215
|
-
problems.push(`"none served" names what drives the service: "**Surfaces:** none served (${NONE_SERVED_TRIGGERS.join(" | ")})"`);
|
|
216
|
-
for (const t of triggers) {
|
|
217
|
-
if (!NONE_SERVED_TRIGGERS.includes(t))
|
|
218
|
-
problems.push(`"none served (${t})": "${t}" is not one of ${NONE_SERVED_TRIGGERS.join(", ")}`);
|
|
219
|
-
}
|
|
212
|
+
const triggers = noneServedTriggers(lines[at], "**Surfaces:** none served", problems);
|
|
220
213
|
return { ...base, noneServed: triggers, problems };
|
|
221
214
|
}
|
|
222
215
|
const entries = [];
|
|
216
|
+
const repoClaims = [];
|
|
223
217
|
const problems = [];
|
|
224
218
|
for (const line of lines.slice(at + 1)) {
|
|
225
219
|
if (line.trim() === "" && entries.length + problems.length === 0)
|
|
@@ -234,6 +228,12 @@ export function parseSurfaces(evidence) {
|
|
|
234
228
|
}
|
|
235
229
|
const contract = m[1].trim();
|
|
236
230
|
const status = m[2].split(";")[0].trim();
|
|
231
|
+
if (/^none\s+served\b/i.test(status)) {
|
|
232
|
+
// `- <repo>: none served (<trigger>)`: that observed repository serves no contract (sdm-wz6a); the list's other
|
|
233
|
+
// items still account for the contracts the other observed repositories serve.
|
|
234
|
+
repoClaims.push({ repo: contract, triggers: noneServedTriggers(status, `- ${contract}: none served`, problems) });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
237
|
const excluded = /^excluded\s*,\s*(.+)$/i.exec(status);
|
|
238
238
|
if (/^covered at transport$/i.test(status)) {
|
|
239
239
|
if (contract.includes("*"))
|
|
@@ -251,9 +251,21 @@ export function parseSurfaces(evidence) {
|
|
|
251
251
|
problems.push(`"${contract}: ${status}" is neither "covered at transport" nor "excluded, <reason>"`);
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
|
-
if (entries.length + problems.length === 0)
|
|
254
|
+
if (entries.length + repoClaims.length + problems.length === 0)
|
|
255
255
|
problems.push('the **Surfaces:** line lists no contract; a baseline that admits no behaviour writes "**Surfaces:** none"');
|
|
256
|
-
return { present: true, none: false, generated: false, noneServed: null, entries, problems };
|
|
256
|
+
return { present: true, none: false, generated: false, noneServed: null, repoClaims, entries, problems };
|
|
257
|
+
}
|
|
258
|
+
/** The triggers of a `none served (<trigger>[, <trigger>])` claim, reporting a missing or unknown one. */
|
|
259
|
+
function noneServedTriggers(text, form, problems) {
|
|
260
|
+
const m = /none\s+served\s*\(([^)]*)\)\s*\.?\s*$/i.exec(text);
|
|
261
|
+
const triggers = m ? m[1].split(",").map((t) => t.trim()).filter((t) => t !== "") : [];
|
|
262
|
+
if (!m || triggers.length === 0)
|
|
263
|
+
problems.push(`"none served" names what drives the service: "${form} (${NONE_SERVED_TRIGGERS.join(" | ")})"`);
|
|
264
|
+
for (const t of triggers) {
|
|
265
|
+
if (!NONE_SERVED_TRIGGERS.includes(t))
|
|
266
|
+
problems.push(`"none served (${t})": "${t}" is not one of ${NONE_SERVED_TRIGGERS.join(", ")}`);
|
|
267
|
+
}
|
|
268
|
+
return triggers;
|
|
257
269
|
}
|
|
258
270
|
/**
|
|
259
271
|
* A generator's baseline (agreement-record-schema.yaml, Baselines: generated behaviour) serves no contract; its
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function pruneEmptyGroups(groups, features) {
|
|
2
|
+
const byId = new Map(groups.map((g) => [String(g["id"] ?? ""), g]));
|
|
3
|
+
const keep = new Set();
|
|
4
|
+
const climb = (id) => {
|
|
5
|
+
// Up the parent chain from a group a feature names; a cycle stops where it repeats.
|
|
6
|
+
let cur = id;
|
|
7
|
+
while (cur !== undefined && cur !== "" && byId.has(cur) && !keep.has(cur)) {
|
|
8
|
+
keep.add(cur);
|
|
9
|
+
cur = String(byId.get(cur)["parent_group_id"] ?? "");
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
for (const f of features)
|
|
13
|
+
climb(String(f["parent_group_id"] ?? ""));
|
|
14
|
+
return {
|
|
15
|
+
kept: groups.filter((g) => keep.has(String(g["id"] ?? ""))),
|
|
16
|
+
sections: groups.filter((g) => !keep.has(String(g["id"] ?? ""))),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=feature-groups-prune.js.map
|
|
@@ -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.3.0"
|
|
11
11
|
schema_owner: "sdd-method"
|
|
12
12
|
schema_status: "generated"
|
|
13
13
|
enums:
|
|
@@ -2537,7 +2537,7 @@ catalogues:
|
|
|
2537
2537
|
type: string
|
|
2538
2538
|
nullable: false
|
|
2539
2539
|
description: publishes | consumes.
|
|
2540
|
-
description: "Edge: service publishes or consumes event."
|
|
2540
|
+
description: "Edge: service publishes or consumes event. event_id is external (7.3.0), as service_contract.contract_id is: an event is an interface like a contract, so a service consuming another SDD's event names it here and the reference resolves at aggregation, where it is validated as internal."
|
|
2541
2541
|
natural_key:
|
|
2542
2542
|
- service_id
|
|
2543
2543
|
- event_id
|
|
@@ -2549,6 +2549,7 @@ catalogues:
|
|
|
2549
2549
|
- column: event_id
|
|
2550
2550
|
references: events.id
|
|
2551
2551
|
nullable: false
|
|
2552
|
+
external: true
|
|
2552
2553
|
column_enums:
|
|
2553
2554
|
role: enums.event_participation
|
|
2554
2555
|
service_entity:
|
|
@@ -3150,7 +3151,7 @@ catalogues:
|
|
|
3150
3151
|
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.
|
|
3151
3152
|
source_of_truth: execution
|
|
3152
3153
|
description: |
|
|
3153
|
-
Edge: a C4 container depends on another INTERNAL container (the system-tier gap exactly one tier down: "no edge for internal" at L2). Derived by the aggregator at adjudication: an external-system candidate that resolves to an internal container was never external — each of its dependency edges whose member-resolved `from_container_id` is known is rewritten here, and a candidate with no remaining edges is dropped (it was a phantom duplicate of the container). A candidate with unconvertible edges is kept with the tier-contradiction finding, so no edge is ever destroyed to make a build quieter — the measured cost that stopped an adopter remediating 22 contradictions was eight real edges. ALSO emitted at member tier (2.33.1): the C4-correct authoring — Rel(container_a, container_b) between two Container(...) nodes — emits its edge directly; adjudication merges into a catalogue that already has content rather than being its only source, so the metamodel no longer rewards mis-declaration over correct authoring. Added in schema 2.33.0.
|
|
3154
|
+
Edge: a C4 container depends on another INTERNAL container (the system-tier gap exactly one tier down: "no edge for internal" at L2). Derived by the aggregator at adjudication: an external-system candidate that resolves to an internal container was never external — each of its dependency edges whose member-resolved `from_container_id` is known is rewritten here, and a candidate with no remaining edges is dropped (it was a phantom duplicate of the container). A candidate with unconvertible edges is kept with the tier-contradiction finding, so no edge is ever destroyed to make a build quieter — the measured cost that stopped an adopter remediating 22 contradictions was eight real edges. ALSO emitted at member tier (2.33.1): the C4-correct authoring — Rel(container_a, container_b) between two Container(...) nodes — emits its edge directly; adjudication merges into a catalogue that already has content rather than being its only source, so the metamodel no longer rewards mis-declaration over correct authoring. Added in schema 2.33.0. to_container_id is external (7.3.0): a container may depend on a container another SDD owns (a platform container calling an application's private API once the application owns that container); the reference resolves at aggregation, where it is validated as internal, so the C4 L2 keeps the arrow.
|
|
3154
3155
|
natural_key:
|
|
3155
3156
|
- from_container_id
|
|
3156
3157
|
- to_container_id
|
|
@@ -3161,6 +3162,7 @@ catalogues:
|
|
|
3161
3162
|
- column: to_container_id
|
|
3162
3163
|
references: c4_containers.id
|
|
3163
3164
|
nullable: false
|
|
3165
|
+
external: true
|
|
3164
3166
|
system_system_dependency:
|
|
3165
3167
|
primary_key: id
|
|
3166
3168
|
source_of_truth: derived
|
|
@@ -3286,7 +3288,7 @@ catalogues:
|
|
|
3286
3288
|
nullable: false
|
|
3287
3289
|
description: spec | high | medium.
|
|
3288
3290
|
source_of_truth: derived
|
|
3289
|
-
description: "Edge: ADR informs capability. Forward: declared in capability spec Architecture Decisions. Reverse: same plus heuristic match supplements with confidence."
|
|
3291
|
+
description: "Edge: ADR informs capability. Forward: declared in capability spec Architecture Decisions. Reverse: same plus heuristic match supplements with confidence. adr_id is external for application- and integration-product SDDs (7.3.0), as adrs.domain_id is: platform ADRs bind the applications built on the platform, so an application capability cites one the platform owns, and the owning platform's aggregate validates it."
|
|
3290
3292
|
natural_key:
|
|
3291
3293
|
- capability_id
|
|
3292
3294
|
- adr_id
|
|
@@ -3297,6 +3299,9 @@ catalogues:
|
|
|
3297
3299
|
- column: adr_id
|
|
3298
3300
|
references: adrs.id
|
|
3299
3301
|
nullable: false
|
|
3302
|
+
external_for_profiles:
|
|
3303
|
+
- application-product
|
|
3304
|
+
- integration-product
|
|
3300
3305
|
column_enums:
|
|
3301
3306
|
confidence: enums.capability_adr_confidence
|
|
3302
3307
|
adr_supersedes:
|
|
@@ -392,7 +392,15 @@ function classifyContract(reachable, excluded, coveredAsEvent) {
|
|
|
392
392
|
* refusal path (non-yaml extension, unreachable file, unparseable
|
|
393
393
|
* content, non-mapping document → False, never a guess).
|
|
394
394
|
*/
|
|
395
|
-
|
|
395
|
+
// The interface a contract describes, by its kind: an OpenAPI document's paths, a command manifest's commands
|
|
396
|
+
// (`cli`), a UI surface manifest's custom elements and messages (`ui`, schema 7.2.0). Reading every YAML contract
|
|
397
|
+
// as OpenAPI made a ui or cli manifest "not an interface" however much it described (reported by an adopter keying
|
|
398
|
+
// a ui contract exactly as instructed).
|
|
399
|
+
const NON_OPENAPI_INTERFACE = {
|
|
400
|
+
cli: ["commands"],
|
|
401
|
+
ui: ["elements", "messages"],
|
|
402
|
+
};
|
|
403
|
+
function hasNoOperations(sddRoot, specPath, kind = "") {
|
|
396
404
|
if (!specPath || !(specPath.endsWith(".yaml") || specPath.endsWith(".yml"))) {
|
|
397
405
|
return false;
|
|
398
406
|
}
|
|
@@ -410,6 +418,9 @@ function hasNoOperations(sddRoot, specPath) {
|
|
|
410
418
|
return false;
|
|
411
419
|
}
|
|
412
420
|
const d = doc;
|
|
421
|
+
const own = NON_OPENAPI_INTERFACE[kind];
|
|
422
|
+
if (own !== undefined)
|
|
423
|
+
return !own.some((k) => Array.isArray(d[k]) && d[k].length > 0);
|
|
413
424
|
if (!("openapi" in d) && !("swagger" in d) && !("paths" in d))
|
|
414
425
|
return true;
|
|
415
426
|
const paths = d["paths"];
|
|
@@ -541,7 +552,7 @@ export async function runContractCoverage(session, options) {
|
|
|
541
552
|
excluded = true;
|
|
542
553
|
exclusionKey = "provider-surface";
|
|
543
554
|
}
|
|
544
|
-
else if (hasNoOperations(options.sddRoot, sp)) {
|
|
555
|
+
else if (hasNoOperations(options.sddRoot, sp, kind)) {
|
|
545
556
|
excluded = true;
|
|
546
557
|
exclusionKey = "not-an-interface";
|
|
547
558
|
}
|
|
@@ -50,7 +50,7 @@ export async function writeCatalogue(opts) {
|
|
|
50
50
|
// Refuse to replace rows with a bare header. Emitting one is correct when the
|
|
51
51
|
// file is absent or already empty — that is the "consumers can rely on the
|
|
52
52
|
// file existing" case — and is data loss when it is not.
|
|
53
|
-
if (opts.rows.length === 0) {
|
|
53
|
+
if (opts.rows.length === 0 && opts.emptiedDeliberately !== true) {
|
|
54
54
|
const onDisk = await existingDataRows(filePath);
|
|
55
55
|
if (onDisk > 0) {
|
|
56
56
|
return { rowCount: onDisk, path: filePath, preserved: true, preservedRows: onDisk };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Feature groups an admitted structural model holds with no feature beneath them (sdm-1al0). The catalogue build no
|
|
2
|
+
// longer makes a group of such a heading, but a model extracted before that kept them, and a model changes only
|
|
3
|
+
// through a judged change. So the structural-model check names them, and `structure prune-groups` writes the
|
|
4
|
+
// structural delta that removes them into an open change. The change is judged and archived as any other.
|
|
5
|
+
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { stringify } from "yaml";
|
|
8
|
+
import { pruneEmptyGroups } from "../catalogue/builders/feature-groups-prune.js";
|
|
9
|
+
import { elementKey, FORMAT_VERSION } from "./model.js";
|
|
10
|
+
import { revisionOf } from "./view.js";
|
|
11
|
+
const rowsOf = (view, catalogue) => [...(view.get(catalogue)?.values() ?? [])].map((e) => ({ id: e.id, ...e.columns }));
|
|
12
|
+
/** The model's feature groups no feature names as its parent group, directly or through a kept child group. */
|
|
13
|
+
export function emptyGroups(view) {
|
|
14
|
+
const { sections } = pruneEmptyGroups(rowsOf(view, "feature_groups"), rowsOf(view, "features"));
|
|
15
|
+
const byId = view.get("feature_groups") ?? new Map();
|
|
16
|
+
return sections.map((s) => byId.get(String(s["id"]))).filter((e) => e !== undefined);
|
|
17
|
+
}
|
|
18
|
+
const REASON = "a heading with no feature beneath it is a section, not a feature group (sdm-1al0)";
|
|
19
|
+
/**
|
|
20
|
+
* Write the structural delta removing every empty group into the open change `change`, one file per partition.
|
|
21
|
+
* Refuses when the change is absent (it is opened the usual way, so it carries its own proposal and agreement), or
|
|
22
|
+
* already has a structural delta for that partition. Returns the files written, relative to the SDD.
|
|
23
|
+
*/
|
|
24
|
+
export async function writePruneDelta(sddPath, change, view) {
|
|
25
|
+
const dir = join("openspec", "changes", change);
|
|
26
|
+
if (!(await stat(join(sddPath, dir)).then((s) => s.isDirectory(), () => false))) {
|
|
27
|
+
return { files: [], removed: 0, refused: `${dir} does not exist: open the change first (its proposal states why), then write its delta` };
|
|
28
|
+
}
|
|
29
|
+
const empty = emptyGroups(view);
|
|
30
|
+
const byPartition = new Map();
|
|
31
|
+
for (const e of empty)
|
|
32
|
+
(byPartition.get(e.partition) ?? byPartition.set(e.partition, []).get(e.partition)).push(e);
|
|
33
|
+
const files = [];
|
|
34
|
+
for (const [partition, groups] of [...byPartition].sort(([a], [b]) => a.localeCompare(b))) {
|
|
35
|
+
const rel = join(dir, "structure", `${partition}.yaml`);
|
|
36
|
+
if (await stat(join(sddPath, rel)).then(() => true, () => false)) {
|
|
37
|
+
return { files, removed: 0, refused: `${rel} already exists; a change carries one delta per partition, so add the removals to it by hand or use another change` };
|
|
38
|
+
}
|
|
39
|
+
const sorted = [...groups].sort((a, b) => a.id.localeCompare(b.id));
|
|
40
|
+
const base = {};
|
|
41
|
+
for (const g of sorted)
|
|
42
|
+
base[elementKey(g.catalogue, g.id)] = revisionOf(g);
|
|
43
|
+
const text = stringify({
|
|
44
|
+
schema_version: FORMAT_VERSION,
|
|
45
|
+
partition,
|
|
46
|
+
REMOVED: { feature_groups: sorted.map((g) => ({ id: g.id, reason: REASON })) },
|
|
47
|
+
base,
|
|
48
|
+
});
|
|
49
|
+
await mkdir(join(sddPath, dir, "structure"), { recursive: true });
|
|
50
|
+
await writeFile(join(sddPath, rel), text);
|
|
51
|
+
files.push(rel);
|
|
52
|
+
}
|
|
53
|
+
return { files, removed: empty.length };
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=empty-groups.js.map
|
|
@@ -1010,8 +1010,25 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
1010
1010
|
const repos = observedIn(evidence);
|
|
1011
1011
|
if (repos.length === 0)
|
|
1012
1012
|
out.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: no **Observed in:** line names a repository`);
|
|
1013
|
+
// `- <repo>: none served (<trigger>)` items (sdm-wz6a): each names an observed repository the catalogue records
|
|
1014
|
+
// serving no contract; one serving a contract lists its contracts instead.
|
|
1015
|
+
const claimed = new Set();
|
|
1016
|
+
for (const c of parsed.repoClaims) {
|
|
1017
|
+
if (!repos.includes(c.repo)) {
|
|
1018
|
+
out.push(`${dir}/baseline-evidence.md: Surfaces: "- ${c.repo}: none served" names a repository **Observed in:** does not (${repos.join(", ") || "none"})`);
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
const served = servedBy.get(c.repo)?.served ?? [];
|
|
1022
|
+
if (served.length > 0) {
|
|
1023
|
+
out.push(`${dir}/baseline-evidence.md: Surfaces: "- ${c.repo}: none served", but ${c.repo} serves ${served.length} contract(s) (catalogues/repo_contract.csv: ${served.slice(0, 3).map((x) => x.id).join(", ")}${served.length > 3 ? ", ..." : ""}); list each, covered at transport or excluded with a reason`);
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
claimed.add(c.repo);
|
|
1027
|
+
}
|
|
1013
1028
|
for (const r of repos) {
|
|
1014
1029
|
const known = servedBy.get(r);
|
|
1030
|
+
if (claimed.has(r))
|
|
1031
|
+
continue;
|
|
1015
1032
|
if ((known?.served ?? []).length === 0) {
|
|
1016
1033
|
const consumed = known?.consumed ? ` (it consumes ${known.consumed}, which a baseline does not list)` : "";
|
|
1017
1034
|
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`);
|
|
@@ -1334,6 +1351,7 @@ export async function checkBehaviourStoreOpenspec(repo) {
|
|
|
1334
1351
|
}
|
|
1335
1352
|
const lines = [];
|
|
1336
1353
|
const failures = [];
|
|
1354
|
+
const warnings = [];
|
|
1337
1355
|
let measured = 0;
|
|
1338
1356
|
for (const [label, args] of [
|
|
1339
1357
|
["specs and open changes (--all --strict)", ["validate", "--all", "--strict", "--json", "--no-interactive"]],
|
|
@@ -1350,8 +1368,27 @@ export async function checkBehaviourStoreOpenspec(repo) {
|
|
|
1350
1368
|
for (const item of r.json?.items ?? []) {
|
|
1351
1369
|
if (item.valid)
|
|
1352
1370
|
continue;
|
|
1353
|
-
for (const i of item.issues ?? [])
|
|
1371
|
+
for (const i of item.issues ?? []) {
|
|
1372
|
+
// An archived change's task deliberately left undone, recorded as deferred with its reason and tracking id
|
|
1373
|
+
// (sdm-7dmx), is a warning, not a failure. OpenSpec counts `- [~]` as incomplete, so the count it reports is
|
|
1374
|
+
// compared with the deferred lines it can be accounted for by.
|
|
1375
|
+
const inc = args[1] === "--archived" && i.path === "tasks.md" ? INCOMPLETE_RE.exec(i.message ?? "") : null;
|
|
1376
|
+
if (inc) {
|
|
1377
|
+
const tasks = (await readText(join(repo, STORE_DIR, "changes", "archive", item.id, "tasks.md"))) ?? "";
|
|
1378
|
+
const d = deferredTasks(tasks);
|
|
1379
|
+
if (d.malformed.length === 0 && d.open === 0 && d.deferred.length === Number(inc[1])) {
|
|
1380
|
+
warnings.push(`${label}: ${item.id}: ${d.deferred.length} task(s) deferred: ${d.deferred.map((x) => `"${x.task}" (${x.reason}; ${x.id})`).join(", ")}; a deferred task is not done, and is no evidence of it`);
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1383
|
+
const why = [
|
|
1384
|
+
d.open > 0 ? `${d.open} unticked` : "",
|
|
1385
|
+
d.malformed.length > 0 ? `${d.malformed.length} "- [~]" line(s) without "(deferred: <reason>; <tracking id>)": ${d.malformed.map((m) => JSON.stringify(m)).join(", ")}` : "",
|
|
1386
|
+
].filter(Boolean).join("; ");
|
|
1387
|
+
failures.push(`${label}: ${item.id} (tasks.md): ${i.level} ${i.message}${why ? ` (${why}); a task deliberately left undone is written "- [~] <task> (deferred: <reason>; <tracking id>)"` : ""}`);
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1354
1390
|
failures.push(`${label}: ${item.id}${i.path ? ` (${i.path})` : ""}: ${i.level} ${i.message}`);
|
|
1391
|
+
}
|
|
1355
1392
|
if ((item.issues ?? []).length === 0)
|
|
1356
1393
|
failures.push(`${label}: ${item.id}: invalid`);
|
|
1357
1394
|
}
|
|
@@ -1400,10 +1437,31 @@ export async function checkBehaviourStoreOpenspec(repo) {
|
|
|
1400
1437
|
}
|
|
1401
1438
|
lines.push(`dry archive: ${dryArchived} open, agreed change(s) archived in a copy`);
|
|
1402
1439
|
const body = [` OpenSpec ${version}`, ...lines.map((l) => ` ${l}`)].join("\n") + "\n";
|
|
1440
|
+
const warn = warnings.map((w) => `[WARN] ${w}\n`).join("");
|
|
1403
1441
|
if (failures.length > 0)
|
|
1404
|
-
return { exitCode: 1, stdout: body + failures.map((f) => `[FAIL] ${f}`).join("\n") + "\n" };
|
|
1442
|
+
return { exitCode: 1, stdout: body + warn + failures.map((f) => `[FAIL] ${f}`).join("\n") + "\n" };
|
|
1405
1443
|
if (measured === 0)
|
|
1406
1444
|
return { exitCode: 0, stdout: body + "[N/A] OpenSpec found no specs and no changes to validate\n" };
|
|
1407
|
-
return { exitCode: 0, stdout: body +
|
|
1445
|
+
return { exitCode: 0, stdout: body + warn + `[PASS] OpenSpec strict validation holds${warnings.length ? ", with the deferred tasks above" : ""}\n` };
|
|
1446
|
+
}
|
|
1447
|
+
const INCOMPLETE_RE = /^(\d+) incomplete tasks?\b/;
|
|
1448
|
+
const DEFERRED_RE = /^\s*-\s+\[~\]\s+(.+?)\s+\(deferred:\s*([^;)]*?\S)\s*;\s*([^;)]*?\S)\s*\)\s*\.?\s*$/;
|
|
1449
|
+
/** A tasks.md's deferred tasks, its malformed `- [~]` lines, and how many tasks are simply unticked. */
|
|
1450
|
+
export function deferredTasks(tasks) {
|
|
1451
|
+
const deferred = [];
|
|
1452
|
+
const malformed = [];
|
|
1453
|
+
let open = 0;
|
|
1454
|
+
for (const line of tasks.split("\n")) {
|
|
1455
|
+
if (/^\s*-\s+\[ \]/.test(line))
|
|
1456
|
+
open++;
|
|
1457
|
+
else if (/^\s*-\s+\[~\]/.test(line)) {
|
|
1458
|
+
const m = DEFERRED_RE.exec(line);
|
|
1459
|
+
if (m)
|
|
1460
|
+
deferred.push({ task: m[1], reason: m[2], id: m[3] });
|
|
1461
|
+
else
|
|
1462
|
+
malformed.push(line.trim());
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
return { deferred, malformed, open };
|
|
1408
1466
|
}
|
|
1409
1467
|
//# sourceMappingURL=behaviour-store.js.map
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
// Enforced (the release that accepts M-ADR 0230): every finding is a [FAIL] and the check
|
|
38
38
|
// exits 1; with ENFORCED false it reports them as [WARN] and exits 0. Without openspec/structure/ and with no change
|
|
39
39
|
// carrying a structure/ directory it reports [N/A], so an SDD without a model is unaffected.
|
|
40
|
+
import { emptyGroups } from "../structure/empty-groups.js";
|
|
40
41
|
import { readdir, readFile } from "node:fs/promises";
|
|
41
42
|
import { join } from "node:path";
|
|
42
43
|
import { parseDocument } from "yaml";
|
|
@@ -274,7 +275,11 @@ export async function checkStructuralModel(repo, options = {}) {
|
|
|
274
275
|
if (raw > 0)
|
|
275
276
|
lines.push(`archived changes whose structure is not applied: ${raw}`);
|
|
276
277
|
lines.push(`views: current; at each of ${[...open.values()].filter((o) => o.structure.deltas.length > 0).length} change(s) with deltas; intended from ${agreed.length} agreed change(s)`);
|
|
277
|
-
|
|
278
|
+
// Feature groups the model holds with no feature beneath them: sections a pre-1al0 extraction made groups of. They
|
|
279
|
+
// leave through a judged change; `structure prune-groups --change <name>` writes its delta (sdm-1al0).
|
|
280
|
+
const empty = emptyGroups(view);
|
|
281
|
+
const emptyWarn = empty.length === 0 ? [] : [`model: ${empty.length} feature group(s) hold no feature (${empty.slice(0, 5).map((e) => e.id).join(", ")}${empty.length > 5 ? ", ..." : ""}): headings that are sections, not groups; remove them through a change (sdd-cli structure prune-groups --change <name> writes its delta)`];
|
|
282
|
+
const body = lines.map((l) => `${l}\n`).join("") + [...removals.findings, ...emptyWarn].map((w) => `[WARN] ${w}\n`).join("");
|
|
278
283
|
if (problems.length === 0)
|
|
279
284
|
return { exitCode: 0, stdout: `structural-model:\n${body}[PASS] structural-model: ok\n` };
|
|
280
285
|
const tag = enforced ? "FAIL" : "WARN";
|
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.5.0",
|
|
4
|
+
"sddCliVersion": "5.5.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",
|