@sdd-method/verify-cli 5.4.3 → 5.5.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.
@@ -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
@@ -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.2.0"
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
- function hasNoOperations(sddRoot, specPath) {
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
@@ -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 {
@@ -1133,6 +1152,46 @@ export async function checkBehaviourStore(repo, options = {}) {
1133
1152
  lines.push(`open baselines: ${openBaselines.length}, asked the archive's questions as warnings`);
1134
1153
  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
1154
  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)` : ""}`);
1155
+ // Archive integrity (sdm-9sk1): what MERGED is not edited. An archived change's spec deltas and structural deltas
1156
+ // (specs/**, structure/** with applied.yaml) are what entered the living model and what the history catalogues are
1157
+ // derived from (requirement_lineage, revisions, change_effects); editing one after it archived makes the history
1158
+ // claim something entered that never did. A mistake there is fixed by a new change (a corrective baseline, for a
1159
+ // baseline). Every other file of an archived change is bookkeeping (evidence notes, errata, tasks, a deferral) and may
1160
+ // be corrected; git is the record of who changed what. Each archived change is compared with its tree at the commit
1161
+ // that added it; without git history the check is unresolved, never a pass.
1162
+ if (history) {
1163
+ let checkedArchives = 0;
1164
+ let payloadFiles = 0;
1165
+ let bookkeepingEdits = 0;
1166
+ const payload = (f) => f.startsWith("specs/") || f.startsWith("structure/");
1167
+ for (const name of order) {
1168
+ const dir = `${archiveRel}/${name}`;
1169
+ const at = await integratedAt(repo, dir);
1170
+ if (at === null)
1171
+ continue; // not yet committed: nothing archived to compare with
1172
+ checkedArchives++;
1173
+ const then = new Set(await gitFiles(repo, at, dir));
1174
+ const now = new Set(await filesUnder(join(repo, dir)));
1175
+ for (const f of [...new Set([...then, ...now])].sort()) {
1176
+ const before = then.has(f) ? await gitShow(repo, at, `${dir}/${f}`) : null;
1177
+ const after = now.has(f) ? await readText(join(repo, dir, f)) : null;
1178
+ if (payload(f))
1179
+ payloadFiles++;
1180
+ if (before === after)
1181
+ continue;
1182
+ if (!payload(f)) {
1183
+ bookkeepingEdits++;
1184
+ continue;
1185
+ }
1186
+ const what = before === null ? "added to" : after === null ? "removed from" : "edited in";
1187
+ 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`);
1188
+ }
1189
+ }
1190
+ 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` : ""}`);
1191
+ }
1192
+ else if (archived.length > 0) {
1193
+ lines.push("archive integrity: not checked (no git history: run from a clone)");
1194
+ }
1136
1195
  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
1196
  if (!history && archived.length > 0)
1138
1197
  lines.push("order: no git history (not a git work tree, or git log failed), so same-day archives cannot be ordered");
@@ -1351,6 +1410,7 @@ export async function checkBehaviourStoreOpenspec(repo) {
1351
1410
  }
1352
1411
  const lines = [];
1353
1412
  const failures = [];
1413
+ const warnings = [];
1354
1414
  let measured = 0;
1355
1415
  for (const [label, args] of [
1356
1416
  ["specs and open changes (--all --strict)", ["validate", "--all", "--strict", "--json", "--no-interactive"]],
@@ -1367,8 +1427,27 @@ export async function checkBehaviourStoreOpenspec(repo) {
1367
1427
  for (const item of r.json?.items ?? []) {
1368
1428
  if (item.valid)
1369
1429
  continue;
1370
- for (const i of item.issues ?? [])
1430
+ for (const i of item.issues ?? []) {
1431
+ // An archived change's task deliberately left undone, recorded as deferred with its reason and tracking id
1432
+ // (sdm-7dmx), is a warning, not a failure. OpenSpec counts `- [~]` as incomplete, so the count it reports is
1433
+ // compared with the deferred lines it can be accounted for by.
1434
+ const inc = args[1] === "--archived" && i.path === "tasks.md" ? INCOMPLETE_RE.exec(i.message ?? "") : null;
1435
+ if (inc) {
1436
+ const tasks = (await readText(join(repo, STORE_DIR, "changes", "archive", item.id, "tasks.md"))) ?? "";
1437
+ const d = deferredTasks(tasks);
1438
+ if (d.malformed.length === 0 && d.open === 0 && d.deferred.length === Number(inc[1])) {
1439
+ 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`);
1440
+ continue;
1441
+ }
1442
+ const why = [
1443
+ d.open > 0 ? `${d.open} unticked` : "",
1444
+ d.malformed.length > 0 ? `${d.malformed.length} "- [~]" line(s) without "(deferred: <reason>; <tracking id>)": ${d.malformed.map((m) => JSON.stringify(m)).join(", ")}` : "",
1445
+ ].filter(Boolean).join("; ");
1446
+ 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>)"` : ""}`);
1447
+ continue;
1448
+ }
1371
1449
  failures.push(`${label}: ${item.id}${i.path ? ` (${i.path})` : ""}: ${i.level} ${i.message}`);
1450
+ }
1372
1451
  if ((item.issues ?? []).length === 0)
1373
1452
  failures.push(`${label}: ${item.id}: invalid`);
1374
1453
  }
@@ -1417,10 +1496,31 @@ export async function checkBehaviourStoreOpenspec(repo) {
1417
1496
  }
1418
1497
  lines.push(`dry archive: ${dryArchived} open, agreed change(s) archived in a copy`);
1419
1498
  const body = [` OpenSpec ${version}`, ...lines.map((l) => ` ${l}`)].join("\n") + "\n";
1499
+ const warn = warnings.map((w) => `[WARN] ${w}\n`).join("");
1420
1500
  if (failures.length > 0)
1421
- return { exitCode: 1, stdout: body + failures.map((f) => `[FAIL] ${f}`).join("\n") + "\n" };
1501
+ return { exitCode: 1, stdout: body + warn + failures.map((f) => `[FAIL] ${f}`).join("\n") + "\n" };
1422
1502
  if (measured === 0)
1423
1503
  return { exitCode: 0, stdout: body + "[N/A] OpenSpec found no specs and no changes to validate\n" };
1424
- return { exitCode: 0, stdout: body + "[PASS] OpenSpec strict validation holds\n" };
1504
+ return { exitCode: 0, stdout: body + warn + `[PASS] OpenSpec strict validation holds${warnings.length ? ", with the deferred tasks above" : ""}\n` };
1505
+ }
1506
+ const INCOMPLETE_RE = /^(\d+) incomplete tasks?\b/;
1507
+ const DEFERRED_RE = /^\s*-\s+\[~\]\s+(.+?)\s+\(deferred:\s*([^;)]*?\S)\s*;\s*([^;)]*?\S)\s*\)\s*\.?\s*$/;
1508
+ /** A tasks.md's deferred tasks, its malformed `- [~]` lines, and how many tasks are simply unticked. */
1509
+ export function deferredTasks(tasks) {
1510
+ const deferred = [];
1511
+ const malformed = [];
1512
+ let open = 0;
1513
+ for (const line of tasks.split("\n")) {
1514
+ if (/^\s*-\s+\[ \]/.test(line))
1515
+ open++;
1516
+ else if (/^\s*-\s+\[~\]/.test(line)) {
1517
+ const m = DEFERRED_RE.exec(line);
1518
+ if (m)
1519
+ deferred.push({ task: m[1], reason: m[2], id: m[3] });
1520
+ else
1521
+ malformed.push(line.trim());
1522
+ }
1523
+ }
1524
+ return { deferred, malformed, open };
1425
1525
  }
1426
1526
  //# 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
- const body = lines.map((l) => `${l}\n`).join("") + removals.findings.map((w) => `[WARN] ${w}\n`).join("");
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";
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.18.3",
3
- "built_against_sdd_cli": "5.4.3"
2
+ "version": "0.19.1",
3
+ "built_against_sdd_cli": "5.5.1"
4
4
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "5.4.3",
3
- "built_against_sdd_cli": "5.4.3"
2
+ "version": "5.5.1",
3
+ "built_against_sdd_cli": "5.5.1"
4
4
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sdd-method/verify-cli",
3
- "version": "5.4.3",
4
- "sddCliVersion": "5.4.3",
3
+ "version": "5.5.1",
4
+ "sddCliVersion": "5.5.1",
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",