@sdd-method/verify-cli 0.1.1 → 0.3.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/baseline-check.js +17 -2
- package/dist/lib/catalogue/canonical-schema.yaml +505 -10
- package/dist/lib/catalogue/coverage-render.js +30 -0
- package/dist/lib/catalogue/engine-validator.js +17 -0
- package/dist/lib/catalogue/graph-projection.js +17 -0
- package/dist/lib/catalogue/impact-run.js +2 -2
- package/dist/lib/catalogue/manifest-loader.js +9 -0
- package/dist/lib/catalogue/platform-scope.js +7 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +99 -2
- package/dist/lib/catalogue/scores-coverage.js +38 -0
- package/dist/lib/catalogue/validate.js +33 -9
- package/dist/lib/validate/aggregate.js +31 -15
- package/dist/lib/validate/bundle-coherence.js +7 -2
- package/dist/lib/validate/dispatch.js +25 -0
- package/dist/lib/validate/index.js +32 -10
- package/dist/lib/validate/yaml-parse.js +112 -0
- package/dist/verbs/catalogue-verify.js +1 -1
- package/package.json +1 -1
- package/scripts/validate-app-dependencies.sh +24 -4
- package/scripts/validate-app-docs.sh +18 -4
|
@@ -78,6 +78,9 @@ export function renderRepoCapability(findings, metrics) {
|
|
|
78
78
|
}));
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
if (metrics.total === 0 && findings.length === 0) {
|
|
82
|
+
lines.push(`[metric] repo_capability_resolution: n/a — 0 repository rows, nothing to resolve`);
|
|
83
|
+
}
|
|
81
84
|
if (metrics.total > 0) {
|
|
82
85
|
const resolved = metrics.byCapability + metrics.byExemption;
|
|
83
86
|
const pct = fmt1f((100.0 * resolved) / metrics.total);
|
|
@@ -125,6 +128,9 @@ export function renderServiceCapability(findings, metrics, tier) {
|
|
|
125
128
|
`capability_service.csv); check the aggregate next${leverageNote(f)}`);
|
|
126
129
|
}
|
|
127
130
|
}
|
|
131
|
+
if (metrics.total === 0 && findings.length === 0) {
|
|
132
|
+
lines.push(`[metric] service_capability_resolution (tier=${tier}): n/a — 0 service rows, nothing to resolve`);
|
|
133
|
+
}
|
|
128
134
|
if (metrics.total > 0) {
|
|
129
135
|
const resolved = metrics.claimed + metrics.exempt;
|
|
130
136
|
const pct = fmt1f((100.0 * resolved) / metrics.total);
|
|
@@ -426,6 +432,14 @@ export function renderScoresCoverage(res, tier) {
|
|
|
426
432
|
`catalogue row — the assessment describes something the model no longer ` +
|
|
427
433
|
`holds; re-key the registry entry or withdraw the score`);
|
|
428
434
|
}
|
|
435
|
+
else if (f.kind === "recorded-path-mismatch") {
|
|
436
|
+
// COVERAGE class — report-only by ruling (decision #3, 2026-09-29): it
|
|
437
|
+
// becomes gate-acting only once both estates read zero, and that is a
|
|
438
|
+
// deliberate later change to this one word, as for orphaned-score above.
|
|
439
|
+
out.push(`[finding] coverage: ${f.rubricId} score for ${f.subject} — the recorded file ` +
|
|
440
|
+
`is not the target's catalogue path, so the assessment may describe a copy the ` +
|
|
441
|
+
`catalogue cannot see; re-score against the catalogue path or re-point file_path`);
|
|
442
|
+
}
|
|
429
443
|
else {
|
|
430
444
|
out.push(`[finding] coverage: ${f.subject}`);
|
|
431
445
|
}
|
|
@@ -470,6 +484,22 @@ export function renderScoresCoverage(res, tier) {
|
|
|
470
484
|
out.push(`[metric] scores_coverage.${m.rubricId}: ${m.covered}/${m.eligible} ` +
|
|
471
485
|
`${m.targetKind}(s) scored, ${m.scored} score(s) present, ${m.orphaned} orphaned`);
|
|
472
486
|
}
|
|
487
|
+
// Recorded-path check (decision #3): stated for every rubric with scores,
|
|
488
|
+
// including the ones it cannot check — n/a is named, never read as agreement.
|
|
489
|
+
const scoredMetrics = res.metrics.filter((m) => m.scored > 0);
|
|
490
|
+
const checkable = scoredMetrics.filter((m) => m.recordedPath !== null);
|
|
491
|
+
const notCheckable = scoredMetrics.filter((m) => m.recordedPath === null);
|
|
492
|
+
if (scoredMetrics.length > 0) {
|
|
493
|
+
const sum = (k) => checkable.reduce((a, m) => a + (m.recordedPath?.[k] ?? 0), 0);
|
|
494
|
+
out.push(`[metric] scores_coverage.recorded_path: ${sum("agree")} agree, ${sum("mismatch")} mismatch, ` +
|
|
495
|
+
`${sum("noRecordedPath")} with no recorded path — across ${checkable.length} rubric(s) whose ` +
|
|
496
|
+
`catalogue holds the scored file's path` +
|
|
497
|
+
(notCheckable.length > 0
|
|
498
|
+
? `; n/a for ${notCheckable.length} (${notCheckable.map((m) => m.rubricId).join(", ")}) — ` +
|
|
499
|
+
`the catalogue does not hold the path of the file those rubrics score`
|
|
500
|
+
: "") +
|
|
501
|
+
` (report-only; a path filled in from the catalogue agrees by construction)`);
|
|
502
|
+
}
|
|
473
503
|
// The excluded population is printed BESIDE the denominator it shrank, never
|
|
474
504
|
// folded into it. A reader comparing this run against a pre-filter baseline
|
|
475
505
|
// must be able to see why the denominator moved without re-deriving it, and
|
|
@@ -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,108 @@ coverage:
|
|
|
754
754
|
kind: enum
|
|
755
755
|
scoring: not-applicable
|
|
756
756
|
reason: closed vocabulary, not an artefact
|
|
757
|
-
|
|
757
|
+
implementation_role:
|
|
758
758
|
kind: enum
|
|
759
759
|
scoring: not-applicable
|
|
760
760
|
reason: closed vocabulary, not an artefact
|
|
761
|
-
|
|
761
|
+
implementation_lifecycle:
|
|
762
762
|
kind: enum
|
|
763
763
|
scoring: not-applicable
|
|
764
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:
|
|
851
|
+
kind: enum
|
|
852
|
+
scoring: not-applicable
|
|
853
|
+
reason: closed vocabulary, not an artefact
|
|
854
|
+
evidence_target_repo:
|
|
855
|
+
kind: entity
|
|
856
|
+
scoring: predicate
|
|
857
|
+
instrument: "FK resolution against repositories at aggregation: a reference that names no repository fails like any other foreign key (M-ADR 0214 §4.10)"
|
|
858
|
+
relationships:
|
|
859
|
+
repo_id:
|
|
860
|
+
class: xref
|
|
861
|
+
impact_flow: toward-source
|
|
@@ -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
|
-
|
|
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",
|
|
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",
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
}
|
|
12
|
-
const hasWarn = r.stdout.includes(WARN_MARKER);
|
|
13
|
-
if (r.exitCode !== 0) {
|
|
35
|
+
else if (outcome === "failed")
|
|
14
36
|
failed++;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|