@sdd-method/verify-cli 0.1.1 → 0.2.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.
@@ -219,4 +219,21 @@ async function findTypeNonconformances(conn, schema, rowsByCatalogue) {
219
219
  }
220
220
  return out;
221
221
  }
222
+ /**
223
+ * The population a PASS was measured over, for the verdict line. An FK or enum
224
+ * PASS over zero rows is true of nothing (empty-set sweep, R2): the count makes
225
+ * the zero visible. The `PASS` token itself is kept for anything that greps it.
226
+ */
227
+ export function validatedPopulation(rowsByCatalogue) {
228
+ let rows = 0;
229
+ let nonEmpty = 0;
230
+ for (const r of rowsByCatalogue.values()) {
231
+ rows += r.length;
232
+ if (r.length > 0)
233
+ nonEmpty += 1;
234
+ }
235
+ return rows === 0
236
+ ? "0 rows — nothing was validated"
237
+ : `${rows} row(s) across ${nonEmpty} non-empty catalogue(s)`;
238
+ }
222
239
  //# sourceMappingURL=engine-validator.js.map
@@ -58,7 +58,24 @@ const THREE_FK_ENDPOINTS = {
58
58
  // capability→service dependency — the contract it is against — not an endpoint. The
59
59
  // edge stays capability→service; the contract rides as a property.
60
60
  capability_service: ["capability_id", "service_id"],
61
+ // schema 3.9.0 (M-ADR 0211) added entity_id as a third FK. M-ADR 0158 decides the
62
+ // edge: "entity_glossary is entity-to-term". domain_id is the row's scope, a
63
+ // property; a row with no entity_id degrades to a property of its glossary term.
64
+ entity_glossary: ["entity_id", "glossary_item_id"],
65
+ // schema 3.18.0 (M-ADR 0216): coverage is feature→integration; limiting_item_id is the
66
+ // PROPERTY that says why an unsupported pair is unsupported, not an endpoint.
67
+ feature_integration_support: ["feature_id", "integration_id"],
61
68
  };
69
+ /**
70
+ * The catalogues a tier reads when no directory is named. The aggregate tier's
71
+ * data is the aggregator's output, `catalogues-aggregated/`: defaulting it to the
72
+ * member `catalogues/` made `--tier aggregate` run the aggregate rules over MEMBER
73
+ * rows — measured at a platform root, 1 reachable node instead of 2,316, a clean-
74
+ * looking miss of every cross-SDD consumer.
75
+ */
76
+ export function defaultCataloguesDir(tier) {
77
+ return tier === "aggregate" ? "catalogues-aggregated" : "catalogues";
78
+ }
62
79
  /**
63
80
  * Kinds must cover every schema catalogue — a catalogue without a kind
64
81
  * cannot be classified and the projection must not guess (the map is
@@ -34,7 +34,7 @@ import { join, resolve } from "node:path";
34
34
  import { readCatalogueCsv } from "./csv-reader.js";
35
35
  import { loadCanonicalSchema } from "./schema-loader.js";
36
36
  import { loadCatalogueKinds, loadRelationshipSemantics, } from "./relationship-semantics.js";
37
- import { projectGraph } from "./graph-projection.js";
37
+ import { defaultCataloguesDir, projectGraph, } from "./graph-projection.js";
38
38
  function resolveFlow(edge, semantics) {
39
39
  const decl = edge.type.includes(".")
40
40
  ? semantics.entityFks.get(edge.type)
@@ -156,8 +156,8 @@ export async function computeImpactClosure(propagation, seed) {
156
156
  }
157
157
  export async function runCatalogueImpact(inputs, write) {
158
158
  const sddRoot = resolve(inputs.sddPath ?? process.cwd());
159
- const dir = resolve(sddRoot, inputs.cataloguesDir ?? "catalogues");
160
159
  const tier = inputs.tier ?? "member";
160
+ const dir = resolve(sddRoot, inputs.cataloguesDir ?? defaultCataloguesDir(tier));
161
161
  if (!existsSync(dir)) {
162
162
  write(`catalogues directory not found at ${dir}\n` +
163
163
  ` Run \`sdd-cli catalogue build\` (or aggregate) first.\n`);
@@ -164,6 +164,12 @@ const ManifestSchema = z.object({
164
164
  // M-ADR 0208 §4.3: the platform this member is contained by. Canonical name;
165
165
  // `ecosystem_root` above is its deprecated alias (schema 1.10.0, removed at 2.0.0).
166
166
  platform_sdd_id: z.string().optional(),
167
+ // M-ADR 0056 (amended 2026-09-29, manifest schema 1.11.0): the SDD whose
168
+ // feature-tree.md holds this SDD's runway classifications, when it has none.
169
+ feature_tree_anchor: z
170
+ .string()
171
+ .regex(/^[a-z][a-z0-9-]*$/)
172
+ .optional(),
167
173
  aggregation_manifest_path: z.string().optional(),
168
174
  description: z.string().optional(),
169
175
  // Optional canonical C4 system id. Defaults to `{sdd_id}-system`. Set this
@@ -360,6 +366,9 @@ export async function loadSddManifest(sddPath, options) {
360
366
  ...(parsed.data.aggregation_manifest_path !== undefined
361
367
  ? { aggregationManifestPath: parsed.data.aggregation_manifest_path }
362
368
  : {}),
369
+ ...(parsed.data.feature_tree_anchor !== undefined
370
+ ? { featureTreeAnchor: parsed.data.feature_tree_anchor }
371
+ : {}),
363
372
  ...(parsed.data.description !== undefined ? { description: parsed.data.description } : {}),
364
373
  ...(parsed.data.lifecycle !== undefined ? { lifecycle: parsed.data.lifecycle } : {}),
365
374
  ...(parsed.data.surface !== undefined ? { surface: parsed.data.surface } : {}),
@@ -0,0 +1,7 @@
1
+ // Catalogues whose key is platform-scoped at organisation tier (M-ADR 0208 §4.4): two
2
+ // platforms may each own a row of one id, so the key is (platform_sdd_id, id). Its own
3
+ // module so a read-only verb (catalogue validate, reached by verify-cli) can use it
4
+ // without importing the aggregator.
5
+ // data_items (M-ADR 0216): each platform owns its vocabulary, as it owns its domains.
6
+ export const PLATFORM_SCOPED_AT_ORGANISATION = ["domains", "data_items"];
7
+ //# sourceMappingURL=platform-scope.js.map
@@ -754,11 +754,100 @@ coverage:
754
754
  kind: enum
755
755
  scoring: not-applicable
756
756
  reason: closed vocabulary, not an artefact
757
- conformance_role:
757
+ implementation_role:
758
758
  kind: enum
759
759
  scoring: not-applicable
760
760
  reason: closed vocabulary, not an artefact
761
- conformance_lifecycle:
761
+ implementation_lifecycle:
762
+ kind: enum
763
+ scoring: not-applicable
764
+ reason: closed vocabulary, not an artefact
765
+ repo_scope:
766
+ kind: enum
767
+ scoring: not-applicable
768
+ reason: closed vocabulary, not an artefact
769
+ implementations:
770
+ kind: entity
771
+ scoring: scored-at-source
772
+ instrument: the binding is scored inside the integration SDD quality rubric (implementation-binding criteria); its identity against the snapshot manifests is a builder finding
773
+ implementation_members:
774
+ kind: edge
775
+ scoring: derived-edge
776
+ class: realisation
777
+ impact_flow: both
778
+ reason: derived from entity rows or manifest blocks scored at the owning entity
779
+ snapshots:
780
+ kind: entity
781
+ scoring: predicate
782
+ instrument: the snapshot record itself — scoring it with a rubric would be circular. Its integrity is sdd-cli snapshot verify (manifest shape, member folders, companion fields) and the builder's implementation-identity finding
783
+ relationships:
784
+ implementation_id:
785
+ class: membership
786
+ impact_flow: toward-target
787
+ prior_snapshot_id:
788
+ class: lineage
789
+ impact_flow: none
790
+ snapshot_members:
791
+ kind: edge
792
+ scoring: derived-edge
793
+ class: membership
794
+ impact_flow: none
795
+ reason: derived from entity rows or manifest blocks scored at the owning entity
796
+ evidence_direction:
797
+ kind: enum
798
+ scoring: not-applicable
799
+ reason: closed vocabulary, not an artefact
800
+ member_presence:
801
+ kind: enum
802
+ scoring: not-applicable
803
+ reason: closed vocabulary, not an artefact
804
+ conformance_disposition:
805
+ kind: enum
806
+ scoring: not-applicable
807
+ reason: closed vocabulary, not an artefact
808
+ delta_disposition:
809
+ kind: enum
810
+ scoring: not-applicable
811
+ reason: closed vocabulary, not an artefact
812
+ data_items:
813
+ kind: entity
814
+ scoring: predicate
815
+ instrument: FK resolution of every supply and demand row against it at aggregation, and the coverage derivation that reads canonical_field (M-ADR 0216 §4.4)
816
+ integration_data_item:
817
+ kind: edge
818
+ scoring: predicate
819
+ class: realisation
820
+ impact_flow: both
821
+ instrument: the supply contradiction check (a positive and a negative row for one item is a divergence finding) and the reported section age (M-ADR 0216 §4.2)
822
+ feature_data_requirement:
823
+ kind: edge
824
+ scoring: predicate
825
+ class: dependency
826
+ impact_flow: toward-source
827
+ instrument: FK resolution against the platform's data_items at aggregation, and the fraction of features declaring any requirement, reported beside coverage (M-ADR 0216 §5)
828
+ feature_integration_support:
829
+ kind: edge
830
+ scoring: derived-edge
831
+ class: traceability
832
+ impact_flow: none
833
+ reason: derived at aggregation from supply and demand, each checked at its source
834
+ supply_verdict:
835
+ kind: enum
836
+ scoring: not-applicable
837
+ reason: closed vocabulary, not an artefact
838
+ supply_basis:
839
+ kind: enum
840
+ scoring: not-applicable
841
+ reason: closed vocabulary, not an artefact
842
+ data_necessity:
843
+ kind: enum
844
+ scoring: not-applicable
845
+ reason: closed vocabulary, not an artefact
846
+ support_verdict:
847
+ kind: enum
848
+ scoring: not-applicable
849
+ reason: closed vocabulary, not an artefact
850
+ limiting_reason:
762
851
  kind: enum
763
852
  scoring: not-applicable
764
853
  reason: closed vocabulary, not an artefact
@@ -193,6 +193,7 @@ export const RUBRIC_ELIGIBILITY = [
193
193
  refTransform: (r) => `regexp_replace(${r}, '^[A-Z]+-ADR-', 'ADR-')`,
194
194
  eligibilityFilter: "status = 'Accepted'",
195
195
  managedPathColumn: "doc_path",
196
+ canonicalPathColumn: "doc_path",
196
197
  eligibilityNote: "Accepted only, and adopter-owned only. A Proposed ADR is mid-decision and a Superseded one is history; requiring a current score on either would manufacture a gap nobody should close. The managed-path rule adds the second half of the same argument, ruled 2026-08-07: an adopter cannot remediate an ADR that sync overwrites, so a method-managed ADR is not in the adopter's denominator — its quality belongs to the producer's own registry, and an adopter's assessment of it is feedback to raise rather than a row to maintain. Measured at both estates 2026-08-08 (committed catalogues, member tier): forward 203 Accepted -> 83 adopter-owned (120 excluded); reverse 182 Accepted -> 62 adopter-owned (120 excluded). Zero rows under a producer path escaped the lock at either estate, so the lock and a path heuristic agree on today's data — the lock is used anyway, because agreement today is not the same as being the right rule.",
197
198
  },
198
199
  {
@@ -202,6 +203,7 @@ export const RUBRIC_ELIGIBILITY = [
202
203
  joinColumn: "spec_path",
203
204
  refTransform: null,
204
205
  eligibilityFilter: "status <> 'deprecated'",
206
+ canonicalPathColumn: "spec_path",
205
207
  eligibilityNote: "Every non-deprecated contract. Deprecated contracts are excluded for the same reason the contract-coverage predicate excludes them — they are on the way out, and scoring them is work with no consumer.",
206
208
  },
207
209
  {
@@ -429,6 +431,7 @@ export async function runScoresCoverage(session, options) {
429
431
  orphaned: 0,
430
432
  managedExcluded: 0,
431
433
  managedExcludedByBundle: {},
434
+ recordedPath: null,
432
435
  });
433
436
  }
434
437
  continue;
@@ -473,6 +476,7 @@ export async function runScoresCoverage(session, options) {
473
476
  orphaned: 0,
474
477
  managedExcluded,
475
478
  managedExcludedByBundle,
479
+ recordedPath: spec.canonicalPathColumn !== undefined ? { agree: 0, mismatch: 0, noRecordedPath: 0 } : null,
476
480
  });
477
481
  continue;
478
482
  }
@@ -518,6 +522,39 @@ export async function runScoresCoverage(session, options) {
518
522
  });
519
523
  }
520
524
  }
525
+ // Direction 4 — a resolved score whose RECORDED path is not the target's
526
+ // canonical path (decision #3, report-only). Paths are normalised on both
527
+ // sides (leading `./` stripped, backslashes to `/`) before comparison. A
528
+ // path the builder filled in from the catalogue agrees by construction —
529
+ // the metric says "agree", not "verified".
530
+ let recordedPath = null;
531
+ if (spec.canonicalPathColumn !== undefined) {
532
+ const pairs = await rows(session, `SELECT s.target_ref, s.target_path, c."${spec.canonicalPathColumn}"
533
+ FROM (${scoreSet}) s
534
+ JOIN "${spec.catalogue}" c ON c."${spec.joinColumn}" = ${ref}
535
+ ORDER BY s.target_ref;`);
536
+ let agree = 0;
537
+ let mismatch = 0;
538
+ let noRecordedPath = 0;
539
+ const n = (v) => String(v ?? "").replace(/\\/g, "/").replace(/^(\.\/)+/, "");
540
+ for (const [targetRef, recorded, canonical] of pairs) {
541
+ const r = n(recorded);
542
+ const c = n(canonical);
543
+ if (r === "")
544
+ noRecordedPath++;
545
+ else if (c === "" || r === c)
546
+ agree++;
547
+ else {
548
+ mismatch++;
549
+ findings.push({
550
+ kind: "recorded-path-mismatch",
551
+ rubricId: spec.rubricId,
552
+ subject: `${String(targetRef)} — recorded "${r}", catalogue "${c}"`,
553
+ });
554
+ }
555
+ }
556
+ recordedPath = { agree, mismatch, noRecordedPath };
557
+ }
521
558
  const eligible = Number((await rows(session, `SELECT COUNT(*) FROM "${spec.catalogue}" WHERE (${eligibleWhere});`))[0][0]);
522
559
  metrics.push({
523
560
  rubricId: spec.rubricId,
@@ -528,6 +565,7 @@ export async function runScoresCoverage(session, options) {
528
565
  orphaned: orphaned.length,
529
566
  managedExcluded,
530
567
  managedExcludedByBundle,
568
+ recordedPath,
531
569
  });
532
570
  }
533
571
  return { findings, metrics, undeclaredRubrics, notAdopted, stalenessBasis, totalScores, transformBridgedRefs };
@@ -11,19 +11,28 @@
11
11
  // Outputs the same shape of findings as `build` so consumers can pipe
12
12
  // either through the same parser. Exit 0 on PASS; non-zero on shape
13
13
  // drift OR internal-FK FAIL.
14
- import { stat } from "node:fs/promises";
14
+ import { readFile, stat } from "node:fs/promises";
15
15
  import { join, resolve } from "node:path";
16
16
  import { CLI_VERSION } from "./cli-version.js";
17
17
  import { readCatalogueCsv } from "./csv-reader.js";
18
18
  import { writeCatalogue } from "./csv-writer.js";
19
- import { validateWithEngineToLog } from "./engine-validator.js";
19
+ import { validatedPopulation, validateWithEngineToLog } from "./engine-validator.js";
20
20
  import { FindingsLog } from "./findings.js";
21
+ import { PLATFORM_SCOPED_AT_ORGANISATION } from "./platform-scope.js";
21
22
  import { loadCanonicalSchema } from "./schema-loader.js";
22
23
  export async function runCatalogueValidate(inputs, write) {
23
24
  const sddPath = resolve(inputs.sddPath ?? process.cwd());
24
- const cataloguesDir = join(sddPath, "catalogues");
25
+ // An organisation root is aggregate-only by construction (M-ADR 0208): its own
26
+ // catalogues/ is deliberately empty and catalogues-aggregated/ is all it produces.
27
+ // Validating the empty member directory reported every file missing — a check
28
+ // that could only fail on such a root. Validate what it actually publishes.
29
+ const orgRoot = await isOrganisationRoot(sddPath);
30
+ const dirName = orgRoot ? "catalogues-aggregated" : "catalogues";
31
+ const cataloguesDir = join(sddPath, dirName);
25
32
  const schema = await loadCanonicalSchema();
26
33
  write(`sdd-cli catalogue validate (canonical schema ${schema.schemaVersion}, cli ${CLI_VERSION})\n`);
34
+ if (orgRoot)
35
+ write(`organisation root: validating ${dirName}/ (an organisation root publishes only its aggregate)\n`);
27
36
  // Existence check on catalogues/ — fail fast with a clear message.
28
37
  try {
29
38
  const s = await stat(cataloguesDir);
@@ -51,7 +60,7 @@ export async function runCatalogueValidate(inputs, write) {
51
60
  if (err.code === "ENOENT") {
52
61
  // Missing file is a shape failure — every catalogue declared in
53
62
  // the schema MUST have a CSV (headers-only acceptable).
54
- findings.add("convention", `catalogues/${cat.name}.csv missing — every schema catalogue must have a file (headers-only acceptable)`);
63
+ findings.add("convention", `${dirName}/${cat.name}.csv missing — every schema catalogue must have a file (headers-only acceptable)`);
55
64
  columnShapeOk = false;
56
65
  rowsByCatalogue.set(cat.name, []);
57
66
  continue;
@@ -61,7 +70,7 @@ export async function runCatalogueValidate(inputs, write) {
61
70
  const expected = cat.columns.map((c) => c.name);
62
71
  const drift = compareColumns(expected, parsed.columns);
63
72
  if (drift !== null) {
64
- findings.add("convention", `catalogues/${cat.name}.csv: column shape drift — ${drift}`);
73
+ findings.add("convention", `${dirName}/${cat.name}.csv: column shape drift — ${drift}`);
65
74
  columnShapeOk = false;
66
75
  }
67
76
  rowsByCatalogue.set(cat.name, parsed.rows);
@@ -77,19 +86,24 @@ export async function runCatalogueValidate(inputs, write) {
77
86
  // cutover; same semantics, findings text unchanged — the A2/A3 dual-run
78
87
  // record is the evidence). Enum hard-fail behaviour per M-ADR 0203 §4.1
79
88
  // is unchanged: an out-of-set value is an error-severity finding.
80
- const { fk, enums } = await validateWithEngineToLog(schema, rowsByCatalogue, findings, { ...(profile !== undefined ? { profile } : {}) });
89
+ const { fk, enums } = await validateWithEngineToLog(schema, rowsByCatalogue, findings, {
90
+ ...(profile !== undefined ? { profile } : {}),
91
+ // At organisation tier `domains` is keyed (platform_sdd_id, id): two platforms may
92
+ // each own a domain of one name, exactly as the aggregator validates it.
93
+ ...(orgRoot ? { platformScopedTargets: PLATFORM_SCOPED_AT_ORGANISATION } : {}),
94
+ });
81
95
  // Report.
82
96
  const findingsText = findings.render();
83
97
  if (findingsText)
84
98
  write(`\n${findingsText}\n`);
85
99
  if (columnShapeOk) {
86
- write(`\nColumn-shape check: PASS\n`);
100
+ write(`\nColumn-shape check: PASS (${validatedPopulation(rowsByCatalogue)})\n`);
87
101
  }
88
102
  else {
89
103
  write(`\nColumn-shape check: FAIL\n`);
90
104
  }
91
105
  if (fk.internalPass) {
92
- write(`FK validation (internal): PASS\n`);
106
+ write(`FK validation (internal): PASS (${validatedPopulation(rowsByCatalogue)})\n`);
93
107
  }
94
108
  else {
95
109
  write(`FK validation (internal): FAIL (${fk.internalFailures.length} dangling)\n`);
@@ -98,7 +112,7 @@ export async function runCatalogueValidate(inputs, write) {
98
112
  }
99
113
  }
100
114
  if (enums.pass) {
101
- write(`Enum validation (column_enums): PASS\n`);
115
+ write(`Enum validation (column_enums): PASS (${validatedPopulation(rowsByCatalogue)})\n`);
102
116
  }
103
117
  else {
104
118
  write(`Enum validation (column_enums): FAIL (${enums.violations.length} out-of-set)\n`);
@@ -173,4 +187,14 @@ function compareColumns(expected, actual) {
173
187
  parts.push(`unexpected: ${extra.join(",")}`);
174
188
  return parts.join("; ");
175
189
  }
190
+ /** True when the SDD's manifest declares it an organisation root (aggregate-only). */
191
+ async function isOrganisationRoot(sddPath) {
192
+ try {
193
+ const raw = await readFile(join(sddPath, ".sdd", "sdd-manifest.yaml"), "utf8");
194
+ return /^category:\s*"?organisation-root"?\s*$/m.test(raw);
195
+ }
196
+ catch {
197
+ return false;
198
+ }
199
+ }
176
200
  //# sourceMappingURL=validate.js.map
@@ -1,28 +1,43 @@
1
1
  const WARN_MARKER = "[WARN]";
2
+ // A validator that ran over an EMPTY population says so with an `[N/A]` line
3
+ // and prints no `[PASS]`. Counting it as passed is the vacuous pass the
4
+ // empty-set sweep measured (an empty platform SDD validated `passed=8`); it is
5
+ // counted apart instead (R1, ruled 2026-09-29). Exit codes are unchanged: a
6
+ // run that measured nothing neither passes nor fails the gate — it is named.
7
+ // A script that measured PART of its population prints real `[PASS]` lines,
8
+ // so a stray `[N/A]` on one sub-check never demotes a real pass.
9
+ const NA_LINE = /^\[N\/A\]/m;
10
+ const PASS_LINE = /^\[PASS\]/m;
11
+ export function classifyResult(r, opts) {
12
+ if (r.status === "skip")
13
+ return "skipped";
14
+ if (r.exitCode !== 0)
15
+ return "failed";
16
+ const hasWarn = r.stdout.includes(WARN_MARKER);
17
+ if (opts.strict && hasWarn)
18
+ return "failed";
19
+ if (NA_LINE.test(r.stdout) && !PASS_LINE.test(r.stdout))
20
+ return "not-measured";
21
+ return hasWarn ? "warned" : "passed";
22
+ }
2
23
  export function aggregateResults(results, opts) {
3
24
  let passed = 0;
4
25
  let failed = 0;
5
26
  let skipped = 0;
6
27
  let warned = 0;
28
+ let notMeasured = 0;
7
29
  for (const r of results) {
8
- if (r.status === "skip") {
30
+ const outcome = classifyResult(r, opts);
31
+ if (r.status === "run" && r.exitCode === 0 && r.stdout.includes(WARN_MARKER))
32
+ warned++;
33
+ if (outcome === "skipped")
9
34
  skipped++;
10
- continue;
11
- }
12
- const hasWarn = r.stdout.includes(WARN_MARKER);
13
- if (r.exitCode !== 0) {
35
+ else if (outcome === "failed")
14
36
  failed++;
15
- }
16
- else if (hasWarn) {
17
- warned++;
18
- if (opts.strict)
19
- failed++;
20
- else
21
- passed++;
22
- }
23
- else {
37
+ else if (outcome === "not-measured")
38
+ notMeasured++;
39
+ else
24
40
  passed++;
25
- }
26
41
  }
27
42
  return {
28
43
  exitCode: failed > 0 ? 1 : 0,
@@ -30,6 +45,7 @@ export function aggregateResults(results, opts) {
30
45
  failed,
31
46
  skipped,
32
47
  warned,
48
+ notMeasured,
33
49
  };
34
50
  }
35
51
  //# sourceMappingURL=aggregate.js.map
@@ -52,8 +52,13 @@ export async function checkBundleCoherence(targetRepo) {
52
52
  }
53
53
  const expected = EXPECTED_FORWARD_BASE[profile];
54
54
  if (expected === undefined) {
55
- lines.push(`[PASS] bundle-coherence: profile '${profile}' declares no forward-base expectation\n`);
56
- return { exitCode: 0, stdout: lines.join("") };
55
+ // Nothing to compare is not a pass: counting it as one inflated the
56
+ // suite's "passed" figure for profiles that carry no expectation.
57
+ return {
58
+ exitCode: 0,
59
+ stdout: "",
60
+ skipped: `profile '${profile}' declares no forward-base expectation — nothing to compare`,
61
+ };
57
62
  }
58
63
  if (forwardBase.name === expected) {
59
64
  lines.push(`[PASS] bundle-coherence: profile '${profile}' ↔ forward base '${forwardBase.name}'\n`);
@@ -1,4 +1,5 @@
1
1
  import { checkBundleCoherence } from "./bundle-coherence.js";
2
+ import { checkYamlParse } from "./yaml-parse.js";
2
3
  // Validators that accept --app-root / --repo-root flags work fine from the
3
4
  // bundled location because they override their internal ROOT_DIR via the
4
5
  // flag. Validators without that flag (adr-structure, runway-classification,
@@ -65,6 +66,14 @@ const APP_VALIDATORS = [
65
66
  },
66
67
  build: () => ({ status: "run", args: [] }),
67
68
  },
69
+ {
70
+ name: "yaml-parse",
71
+ source: {
72
+ kind: "native",
73
+ run: async (ctx) => checkYamlParse(ctx.targetRepo),
74
+ },
75
+ build: () => ({ status: "run", args: [] }),
76
+ },
68
77
  // Note: validate-runway-classification is intentionally NOT in the app set.
69
78
  // It requires platform-level files (docs/platform/feature-tree.md,
70
79
  // architecture-runway-index.md) that app repos don't own. Per ADR 0089,
@@ -109,6 +118,14 @@ const INTEGRATION_VALIDATORS = [
109
118
  },
110
119
  build: () => ({ status: "run", args: [] }),
111
120
  },
121
+ {
122
+ name: "yaml-parse",
123
+ source: {
124
+ kind: "native",
125
+ run: async (ctx) => checkYamlParse(ctx.targetRepo),
126
+ },
127
+ build: () => ({ status: "run", args: [] }),
128
+ },
112
129
  ];
113
130
  const PLATFORM_VALIDATORS = [
114
131
  {
@@ -183,6 +200,14 @@ const PLATFORM_VALIDATORS = [
183
200
  },
184
201
  build: () => ({ status: "run", args: [] }),
185
202
  },
203
+ {
204
+ name: "yaml-parse",
205
+ source: {
206
+ kind: "native",
207
+ run: async (ctx) => checkYamlParse(ctx.targetRepo),
208
+ },
209
+ build: () => ({ status: "run", args: [] }),
210
+ },
186
211
  // Optional: the upstream validate-docs.sh dispatcher covers the 6
187
212
  // conditional validators (backward-compatibility, deprecation-runway,
188
213
  // migration-completeness, ddd-artifacts, glossary-alignment,
@@ -1,5 +1,5 @@
1
1
  import { profileFromRepoKind, readRepoKind } from "../repo-kind.js";
2
- import { aggregateResults } from "./aggregate.js";
2
+ import { aggregateResults, classifyResult } from "./aggregate.js";
3
3
  import { selectValidators } from "./dispatch.js";
4
4
  import { runValidator } from "./runner.js";
5
5
  const SECTION_RULE = "═".repeat(59);
@@ -35,13 +35,17 @@ export async function runValidate(opts, stdout = (line) => process.stdout.write(
35
35
  stdout(result.stdout);
36
36
  if (result.stderr)
37
37
  stdout(result.stderr);
38
- if (result.exitCode === 0)
39
- stdout(pass(`${cfg.name}: ok`));
40
- else
38
+ if (result.exitCode !== 0)
41
39
  stdout(fail(`${cfg.name}: exit ${result.exitCode}`));
40
+ else if (classifyResult(result, { strict: opts.strict }) === "not-measured") {
41
+ stdout(na(`${cfg.name}: nothing measured — its population is empty`));
42
+ }
43
+ else
44
+ stdout(pass(`${cfg.name}: ok`));
42
45
  }
43
46
  const summary = aggregateResults(results, { strict: opts.strict });
44
- stdout(info(`Summary: passed=${summary.passed} failed=${summary.failed} skipped=${summary.skipped} warned=${summary.warned}${opts.strict ? " (strict)" : ""}`));
47
+ stdout(info(`Summary: passed=${summary.passed} failed=${summary.failed} skipped=${summary.skipped} ` +
48
+ `not-measured=${summary.notMeasured} warned=${summary.warned}${opts.strict ? " (strict)" : ""}`));
45
49
  stdout(verdict(summary, validators.length));
46
50
  return { exitCode: summary.exitCode, summary };
47
51
  }
@@ -55,14 +59,29 @@ function verdict(summary, selected) {
55
59
  if (summary.exitCode !== 0)
56
60
  return fail("One or more validators failed");
57
61
  const ran = summary.passed + summary.failed;
58
- if (summary.skipped === 0)
62
+ const nm = summary.notMeasured;
63
+ if (summary.skipped === 0 && nm === 0)
59
64
  return pass("All validators passed");
60
- if (ran === 0) {
65
+ if (ran === 0 && nm === 0) {
61
66
  return warn(`0 of ${selected} validators ran — all ${summary.skipped} skipped. ` +
62
67
  `This is an absence of evidence, not a pass.`);
63
68
  }
64
- return warn(`${ran} of ${selected} validators passed — ${summary.skipped} skipped, ` +
65
- `and a skipped validator is not covered by this verdict.`);
69
+ if (nm === 0) {
70
+ return warn(`${ran} of ${selected} validators passed — ${summary.skipped} skipped, ` +
71
+ `and a skipped validator is not covered by this verdict.`);
72
+ }
73
+ const uncovered = [
74
+ summary.skipped > 0 ? `${summary.skipped} skipped` : "",
75
+ nm > 0 ? `${nm} measured nothing (an empty population)` : "",
76
+ ]
77
+ .filter(Boolean)
78
+ .join(", ");
79
+ if (ran === 0) {
80
+ return warn(`0 of ${selected} validators measured anything — ${uncovered}. ` +
81
+ `This is an absence of evidence, not a pass.`);
82
+ }
83
+ return warn(`${ran} of ${selected} validators passed — ${uncovered}; ` +
84
+ `neither is covered by this verdict.`);
66
85
  }
67
86
  function section(title) {
68
87
  return `\n${SECTION_RULE}\n ${title}\n${SECTION_RULE}\n\n`;
@@ -79,10 +98,13 @@ function fail(msg) {
79
98
  function skip(msg) {
80
99
  return `[SKIP] ${msg}\n`;
81
100
  }
101
+ function na(msg) {
102
+ return `[N/A] ${msg}\n`;
103
+ }
82
104
  function warn(msg) {
83
105
  return `[WARN] ${msg}\n`;
84
106
  }
85
107
  function emptySummary() {
86
- return { exitCode: 1, passed: 0, failed: 0, skipped: 0, warned: 0 };
108
+ return { exitCode: 1, passed: 0, failed: 0, skipped: 0, warned: 0, notMeasured: 0 };
87
109
  }
88
110
  //# sourceMappingURL=index.js.map