@sdd-method/verify-cli 0.1.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.
Files changed (53) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +17 -0
  3. package/README.md +7 -0
  4. package/dist/lib/catalogue/baseline-check.js +136 -0
  5. package/dist/lib/catalogue/canonical-schema.yaml +3557 -0
  6. package/dist/lib/catalogue/cli-version.js +10 -0
  7. package/dist/lib/catalogue/coverage-predicates.js +603 -0
  8. package/dist/lib/catalogue/coverage-render.js +522 -0
  9. package/dist/lib/catalogue/coverage-run.js +115 -0
  10. package/dist/lib/catalogue/csv-reader.js +97 -0
  11. package/dist/lib/catalogue/csv-writer.js +96 -0
  12. package/dist/lib/catalogue/ddl-generator.js +233 -0
  13. package/dist/lib/catalogue/engine-validator.js +222 -0
  14. package/dist/lib/catalogue/finding-identity.js +183 -0
  15. package/dist/lib/catalogue/findings.js +46 -0
  16. package/dist/lib/catalogue/fk-validator.js +136 -0
  17. package/dist/lib/catalogue/gate-checks.js +338 -0
  18. package/dist/lib/catalogue/graph-projection.js +329 -0
  19. package/dist/lib/catalogue/impact-run.js +228 -0
  20. package/dist/lib/catalogue/manifest-loader.js +422 -0
  21. package/dist/lib/catalogue/relationship-semantics.js +66 -0
  22. package/dist/lib/catalogue/rubric-coverage-map.yaml +764 -0
  23. package/dist/lib/catalogue/scaffold-delta.js +261 -0
  24. package/dist/lib/catalogue/schema-compat.js +52 -0
  25. package/dist/lib/catalogue/schema-loader.js +87 -0
  26. package/dist/lib/catalogue/scores-coverage.js +535 -0
  27. package/dist/lib/catalogue/staleness-verdicts.js +345 -0
  28. package/dist/lib/catalogue/validate.js +176 -0
  29. package/dist/lib/catalogue/version.js +18 -0
  30. package/dist/lib/list/discover.js +100 -0
  31. package/dist/lib/list/format.js +84 -0
  32. package/dist/lib/list/index.js +16 -0
  33. package/dist/lib/lock-file.js +413 -0
  34. package/dist/lib/pack/log.js +18 -0
  35. package/dist/lib/pack/schema.js +121 -0
  36. package/dist/lib/pack/seed.js +182 -0
  37. package/dist/lib/pack/validate.js +49 -0
  38. package/dist/lib/package-version.js +25 -0
  39. package/dist/lib/repo-kind.js +129 -0
  40. package/dist/lib/scripts.js +7 -0
  41. package/dist/lib/validate/aggregate.js +35 -0
  42. package/dist/lib/validate/bundle-coherence.js +69 -0
  43. package/dist/lib/validate/dispatch.js +218 -0
  44. package/dist/lib/validate/index.js +88 -0
  45. package/dist/lib/validate/runner.js +85 -0
  46. package/dist/verbs/catalogue-verify.js +114 -0
  47. package/dist/verbs/list.js +45 -0
  48. package/dist/verbs/pack-validate.js +17 -0
  49. package/dist/verbs/path-option.js +25 -0
  50. package/dist/verbs/validate.js +22 -0
  51. package/dist/verify-bin.js +8 -0
  52. package/dist/verify-program.js +115 -0
  53. package/package.json +32 -0
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Finding IDENTITY — the stable key a findings baseline is compared on, per
3
+ * M-ADR 0132 §4.3 as amended, delivering the ruling recorded at ledger §3p.
4
+ *
5
+ * THE PROBLEM THIS EXISTS FOR, measured rather than asserted. A findings
6
+ * baseline stores whole `[finding]` lines, so improving a message invalidates
7
+ * every baselined line carrying it. Wave №6 added a remedy sentence to one
8
+ * orphan message: 57 baselined lines went inert and 57 replacements arrived
9
+ * unbaselined, with ZERO genuinely new findings underneath. At the baseline, a
10
+ * wave that improves wording is indistinguishable from a wave that introduces
11
+ * 57 new problems. It recurred at wave №11 (9 lines, one estate) because the
12
+ * class was understood and the mechanism had not changed.
13
+ *
14
+ * WHY IDENTITY IS EMITTED AND NOT RECOVERED. The obvious cheaper fix is to let
15
+ * a comparator extract (category, subject) from stored lines and leave every
16
+ * estate file untouched. Measured across every baseline in both estates — 86
17
+ * files, 74 non-empty, 1,677 body lines — that works for 1,434 lines (85.5%)
18
+ * and fails for 243 (14.5%), which embed their subject in prose:
19
+ *
20
+ * [finding] orphan: repo integration-calamp resolves to no capability ...
21
+ *
22
+ * One renderer template, all orphan class, spread across 62 repos. Recovering
23
+ * identity there means teaching the comparator the renderer's message
24
+ * templates — which re-couples identity to message text, the exact coupling
25
+ * this module removes, and fails SILENTLY the next time a template changes.
26
+ * Identity that was never emitted cannot be reconstructed from prose after the
27
+ * fact; it has to be emitted where it is known.
28
+ *
29
+ * THE RENDERED FORM keeps the identity in the line a human reads, because a
30
+ * baseline is reviewed by people and a side-channel nobody sees is a side
31
+ * channel nobody maintains:
32
+ *
33
+ * [finding] orphan (repo:integration-calamp): repo integration-calamp ...
34
+ * [finding] divergence (docs/domains/asset/product/x.traceability.yaml): ...
35
+ *
36
+ * A finding with no declared subject renders EXACTLY as it does today. That is
37
+ * deliberate and load-bearing: migration is per-emission-site rather than
38
+ * flag-day, an unmigrated site is unchanged rather than broken, and the size of
39
+ * an estate's re-take is proportional to how many sites have actually moved.
40
+ */
41
+ /**
42
+ * A subject is `kind:id` where the finding knows the kind (`repo:x`,
43
+ * `capability:y`), or a repo-relative path where the subject IS a file. Both
44
+ * are stable under rewording; neither is a sentence.
45
+ *
46
+ * Parentheses are the delimiter because no subject may contain one — asserted
47
+ * below rather than assumed, since a subject that could contain the delimiter
48
+ * would make the parse ambiguous exactly when it mattered.
49
+ */
50
+ export function assertRenderableSubject(subject) {
51
+ if (subject.includes("(") || subject.includes(")")) {
52
+ throw new Error(`finding subject may not contain parentheses (they delimit the identity): ${subject}`);
53
+ }
54
+ if (subject.trim() !== subject || subject === "") {
55
+ throw new Error(`finding subject must be non-empty and untrimmed-clean: '${subject}'`);
56
+ }
57
+ }
58
+ export function renderFindingLine(f) {
59
+ const err = f.severity === "error" ? " [error]" : "";
60
+ if (f.subject === undefined || f.subject === null) {
61
+ return `[finding] ${f.category}${err}: ${f.summary}`;
62
+ }
63
+ assertRenderableSubject(f.subject);
64
+ return `[finding] ${f.category}${err} (${f.subject}): ${f.summary}`;
65
+ }
66
+ const EXPLICIT = /^\[finding\] (\w+)(?: \[error\])? \(([^)]+)\): /;
67
+ const LEGACY = /^\[finding\] (\w+)(?: \[error\])?: /;
68
+ /**
69
+ * Recovers the identity of a stored baseline line.
70
+ *
71
+ * Three outcomes, and the third is the one that matters: an explicit identity
72
+ * is read exactly; a LEGACY line is keyed on its whole summary, which
73
+ * reproduces today's whole-line behaviour for lines that have not moved yet;
74
+ * and a line that is not a finding at all returns null.
75
+ *
76
+ * Keying a legacy line on its summary is not a silent approximation of
77
+ * identity — it IS the old contract, kept deliberately so an unmigrated line
78
+ * behaves exactly as it does now rather than matching something it should not.
79
+ */
80
+ export function parseFindingLine(line) {
81
+ const m = EXPLICIT.exec(line);
82
+ if (m) {
83
+ return { identity: { category: m[1], subject: m[2] }, explicit: true };
84
+ }
85
+ const l = LEGACY.exec(line);
86
+ if (!l)
87
+ return null;
88
+ return {
89
+ identity: { category: l[1], subject: null },
90
+ explicit: false,
91
+ };
92
+ }
93
+ /**
94
+ * The comparison key. For an explicit identity that is (category, subject); for
95
+ * a legacy line it falls back to the whole line, preserving current behaviour.
96
+ */
97
+ export function findingKey(line) {
98
+ const parsed = parseFindingLine(line);
99
+ if (!parsed)
100
+ return `raw\u0000${line}`;
101
+ if (parsed.identity.subject === null)
102
+ return `legacy\u0000${line}`;
103
+ return `id\u0000${parsed.identity.category}\u0000${parsed.identity.subject}`;
104
+ }
105
+ /**
106
+ * The MIGRATION BRIDGE: a live identity-carrying line with its identity token
107
+ * removed, which is byte-identical to how that same finding rendered before its
108
+ * emission site migrated.
109
+ *
110
+ * This is not inference. The token is exactly the ` (subject)` this module's own
111
+ * renderer inserts, and the summary either side of it is untouched — so removing
112
+ * it reconstructs the previous rendering deterministically, not plausibly.
113
+ *
114
+ * WHY IT EXISTS, measured on a real estate: a baseline dominated by findings
115
+ * from the FIRST migrated emission sites read 629 unbaselined / 380 closed / 0
116
+ * REWORDED — one closed and one new, which is the exact outcome §9 was written
117
+ * to prevent. The rewording bucket could not catch it because rewording is
118
+ * matched between two identity-carrying lines, and a pre-migration baseline has
119
+ * no identity on its side. The mechanism worked; the migration boundary sat
120
+ * underneath it, invisible to any estate whose baseline predates emission.
121
+ */
122
+ export function strippedOfIdentity(line) {
123
+ const m = EXPLICIT.exec(line);
124
+ if (!m)
125
+ return null;
126
+ return line.replace(` (${m[2]})`, "");
127
+ }
128
+ /**
129
+ * Compares live findings against a baseline by identity.
130
+ *
131
+ * Only gate-acting categories belong in a baseline (M-ADR 0132 §4.3 — `orphan`
132
+ * and `divergence`); filtering is the caller's, because a comparator that
133
+ * silently dropped categories would make an empty result ambiguous between
134
+ * "nothing gate-acting" and "nothing at all".
135
+ */
136
+ export function compareToBaseline(live, baseline) {
137
+ const baseByKey = new Map();
138
+ let legacyKeyed = 0;
139
+ for (const b of baseline) {
140
+ const key = findingKey(b);
141
+ if (key.startsWith("legacy\u0000") || key.startsWith("raw\u0000"))
142
+ legacyKeyed++;
143
+ // First occurrence wins; a baseline holding the same identity twice is a
144
+ // duplicate, and collapsing it here matches what a re-take does anyway.
145
+ if (!baseByKey.has(key))
146
+ baseByKey.set(key, b);
147
+ }
148
+ const unbaselined = [];
149
+ const reworded = [];
150
+ const matched = new Set();
151
+ let unchanged = 0;
152
+ for (const l of live) {
153
+ const key = findingKey(l);
154
+ let b = baseByKey.get(key);
155
+ if (b === undefined) {
156
+ // MIGRATION BRIDGE: before calling a finding new, ask whether this is the
157
+ // same finding wearing its pre-migration rendering. Exact match on the
158
+ // stripped form only — never a fuzzy one.
159
+ const stripped = strippedOfIdentity(l);
160
+ if (stripped !== null) {
161
+ const legacyKey = findingKey(stripped);
162
+ const legacyMatch = baseByKey.get(legacyKey);
163
+ if (legacyMatch !== undefined) {
164
+ matched.add(legacyKey);
165
+ reworded.push({ baseline: legacyMatch, live: l });
166
+ continue;
167
+ }
168
+ }
169
+ unbaselined.push(l);
170
+ continue;
171
+ }
172
+ matched.add(key);
173
+ if (b === l)
174
+ unchanged++;
175
+ else
176
+ reworded.push({ baseline: b, live: l });
177
+ }
178
+ const stale = [...baseByKey.entries()]
179
+ .filter(([k]) => !matched.has(k))
180
+ .map(([, v]) => v);
181
+ return { unbaselined, stale, reworded, unchanged, legacyKeyed };
182
+ }
183
+ //# sourceMappingURL=finding-identity.js.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Findings collector for the canonical builder per ADR 0134 / builder-spec
3
+ * §"Emit findings as it parses".
4
+ *
5
+ * Findings are human-readable AND machine-parseable. Format per finding:
6
+ * [finding] {category}: {one-line summary}
7
+ *
8
+ * Categories are an open set; the canonical catalogue is in the builder
9
+ * spec. This module hardcodes the well-known ones for type safety; new
10
+ * categories may be passed as freeform strings if they emerge.
11
+ */
12
+ import { renderFindingLine } from "./finding-identity.js";
13
+ export class FindingsLog {
14
+ findings = [];
15
+ add(category, summary, severity = "warn", subject) {
16
+ // Spread rather than an explicit `subject: undefined`, which
17
+ // exactOptionalPropertyTypes rejects — an absent subject and a subject
18
+ // whose value is undefined are different things here, and the type is
19
+ // right to insist on the distinction.
20
+ this.findings.push({
21
+ category,
22
+ summary,
23
+ severity,
24
+ ...(subject === undefined ? {} : { subject }),
25
+ });
26
+ }
27
+ /** Returns a copy of recorded findings, in insertion order. */
28
+ all() {
29
+ return [...this.findings];
30
+ }
31
+ /** Count findings, optionally filtered to a single category. */
32
+ count(category) {
33
+ if (category === undefined)
34
+ return this.findings.length;
35
+ return this.findings.filter((f) => f.category === category).length;
36
+ }
37
+ /** Count error-severity findings (these fail the build by default). */
38
+ errorCount() {
39
+ return this.findings.filter((f) => f.severity === "error").length;
40
+ }
41
+ /** Render every finding as a stdout line. Error-severity findings are marked. */
42
+ render() {
43
+ return this.findings.map((f) => renderFindingLine(f)).join("\n");
44
+ }
45
+ }
46
+ //# sourceMappingURL=findings.js.map
@@ -0,0 +1,136 @@
1
+ /**
2
+ * FK validator per ADR 0134 builder-spec §"Run internal FK validation
3
+ * before exiting" + §"External FK handling".
4
+ *
5
+ * For each catalogue's foreign keys:
6
+ * - Internal FKs (external=false): every non-empty value MUST resolve
7
+ * to a primary-key in the referenced catalogue's emitted rows. Missing
8
+ * resolution is a hard failure.
9
+ * - External FKs (external=true, or external for the build's profile via
10
+ * `externalForProfiles` — schema 2.23.0): every non-empty value is logged
11
+ * as a `[finding] external` line and skipped from validation. The
12
+ * aggregator resolves these post-merge per ADR 0134 §4.5. A required
13
+ * (non-nullable) external FK still fails when EMPTY — the value must be
14
+ * named even if it resolves cross-SDD.
15
+ *
16
+ * Returns a structured result; the caller decides exit code + reporting.
17
+ *
18
+ * POST-CUTOVER ROLE (M-ADR 0205 §4.4, A4): no production path calls this
19
+ * module — validate/build/aggregate run the engine path. It remains as the
20
+ * reference side of the standing differential (parity-harness.ts and the
21
+ * estate sweep driver), so implementation drift between the two gets
22
+ * caught by the suite instead of lurking.
23
+ */
24
+ /** The stamp column M-ADR 0208 §4.4 keys on. */
25
+ export const PLATFORM_STAMP_COLUMN = "platform_sdd_id";
26
+ /**
27
+ * Whether the FK `cat.fk → targetCat` resolves under the platform-scoped
28
+ * key (see FkValidationOptions.platformScopedTargets). One definition,
29
+ * shared with the query generator so the two paths cannot drift.
30
+ */
31
+ export function isPlatformScopedFk(cat, targetCat, options) {
32
+ if (!(options.platformScopedTargets ?? []).includes(targetCat.name))
33
+ return false;
34
+ const has = (c) => c.columns.some((col) => col.name === PLATFORM_STAMP_COLUMN);
35
+ return has(cat) && has(targetCat);
36
+ }
37
+ /**
38
+ * Build an in-memory map of every catalogue's primary-key set, then walk
39
+ * every row's FK columns and check resolution.
40
+ */
41
+ export function validateForeignKeys(schema, rowsByCatalogue, findings, options = {}) {
42
+ // Index primary keys per catalogue, and — for platform-scoped targets —
43
+ // the set of stamps each pk is present under ('' for an unstamped row).
44
+ const pkIndex = new Map();
45
+ const stampIndex = new Map();
46
+ const catByName = new Map(schema.catalogues.map((c) => [c.name, c]));
47
+ for (const cat of schema.catalogues) {
48
+ const rows = rowsByCatalogue.get(cat.name) ?? [];
49
+ const pks = new Set();
50
+ const stamps = new Map();
51
+ for (const row of rows) {
52
+ const pk = row[cat.primaryKey];
53
+ if (pk !== undefined && pk !== null && pk !== "") {
54
+ pks.add(String(pk));
55
+ const stamp = String(row[PLATFORM_STAMP_COLUMN] ?? "");
56
+ const set = stamps.get(String(pk)) ?? new Set();
57
+ set.add(stamp);
58
+ stamps.set(String(pk), set);
59
+ }
60
+ }
61
+ pkIndex.set(cat.name, pks);
62
+ stampIndex.set(cat.name, stamps);
63
+ }
64
+ const failures = [];
65
+ let externalCount = 0;
66
+ for (const cat of schema.catalogues) {
67
+ const rows = rowsByCatalogue.get(cat.name) ?? [];
68
+ for (const fk of cat.foreignKeys) {
69
+ // Effective-external (2.23.0): declared external OR external for the
70
+ // profile under build. Governs the resolution skip only — the
71
+ // required-empty check below stays keyed on the DECLARED `external` so
72
+ // a profile-external FK still requires a (cross-SDD) value to be named.
73
+ const effectiveExternal = fk.external ||
74
+ (options.profile !== undefined &&
75
+ (fk.externalForProfiles ?? []).includes(options.profile));
76
+ const [targetCat, targetCol] = fk.references.split(".");
77
+ if (!targetCat || !targetCol) {
78
+ throw new Error(`malformed FK reference "${fk.references}" on ${cat.name}.${fk.column}`);
79
+ }
80
+ const targetPks = pkIndex.get(targetCat);
81
+ const targetDef = catByName.get(targetCat);
82
+ if (!targetPks || !targetDef) {
83
+ throw new Error(`FK ${cat.name}.${fk.column} references unknown catalogue "${targetCat}"`);
84
+ }
85
+ const scoped = isPlatformScopedFk(cat, targetDef, options);
86
+ const targetStamps = stampIndex.get(targetCat);
87
+ for (const row of rows) {
88
+ const raw = row[fk.column];
89
+ if (raw === undefined || raw === null || raw === "") {
90
+ if (!fk.nullable && !fk.external) {
91
+ // Required FK with no value — internal violation regardless of external flag.
92
+ failures.push({
93
+ catalogue: cat.name,
94
+ column: fk.column,
95
+ referencingRow: String(row[cat.primaryKey] ?? ""),
96
+ value: "",
97
+ target: fk.references,
98
+ });
99
+ }
100
+ continue;
101
+ }
102
+ const value = String(raw);
103
+ let resolved = targetPks.has(value);
104
+ if (resolved && scoped) {
105
+ const mine = String(row[PLATFORM_STAMP_COLUMN] ?? "");
106
+ const theirs = targetStamps.get(value) ?? new Set();
107
+ resolved = mine === "" || theirs.has("") || theirs.has(mine);
108
+ }
109
+ if (effectiveExternal && !options.treatExternalAsInternal) {
110
+ if (!resolved) {
111
+ // Per spec: optimistically resolve against own rows; only log when unresolved.
112
+ externalCount++;
113
+ findings.add("external", `${cat.name}.${fk.column} → ${fk.references}=${value} ` +
114
+ `(referencing row pk=${row[cat.primaryKey] ?? "?"}; resolves at aggregation)`);
115
+ }
116
+ continue;
117
+ }
118
+ if (!resolved) {
119
+ failures.push({
120
+ catalogue: cat.name,
121
+ column: fk.column,
122
+ referencingRow: String(row[cat.primaryKey] ?? ""),
123
+ value,
124
+ target: fk.references,
125
+ });
126
+ }
127
+ }
128
+ }
129
+ }
130
+ return {
131
+ internalPass: failures.length === 0,
132
+ internalFailures: failures,
133
+ externalFindings: externalCount,
134
+ };
135
+ }
136
+ //# sourceMappingURL=fk-validator.js.map