@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.
- package/LICENSE +201 -0
- package/NOTICE +17 -0
- package/README.md +7 -0
- package/dist/lib/catalogue/baseline-check.js +136 -0
- package/dist/lib/catalogue/canonical-schema.yaml +3557 -0
- package/dist/lib/catalogue/cli-version.js +10 -0
- package/dist/lib/catalogue/coverage-predicates.js +603 -0
- package/dist/lib/catalogue/coverage-render.js +522 -0
- package/dist/lib/catalogue/coverage-run.js +115 -0
- package/dist/lib/catalogue/csv-reader.js +97 -0
- package/dist/lib/catalogue/csv-writer.js +96 -0
- package/dist/lib/catalogue/ddl-generator.js +233 -0
- package/dist/lib/catalogue/engine-validator.js +222 -0
- package/dist/lib/catalogue/finding-identity.js +183 -0
- package/dist/lib/catalogue/findings.js +46 -0
- package/dist/lib/catalogue/fk-validator.js +136 -0
- package/dist/lib/catalogue/gate-checks.js +338 -0
- package/dist/lib/catalogue/graph-projection.js +329 -0
- package/dist/lib/catalogue/impact-run.js +228 -0
- package/dist/lib/catalogue/manifest-loader.js +422 -0
- package/dist/lib/catalogue/relationship-semantics.js +66 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +764 -0
- package/dist/lib/catalogue/scaffold-delta.js +261 -0
- package/dist/lib/catalogue/schema-compat.js +52 -0
- package/dist/lib/catalogue/schema-loader.js +87 -0
- package/dist/lib/catalogue/scores-coverage.js +535 -0
- package/dist/lib/catalogue/staleness-verdicts.js +345 -0
- package/dist/lib/catalogue/validate.js +176 -0
- package/dist/lib/catalogue/version.js +18 -0
- package/dist/lib/list/discover.js +100 -0
- package/dist/lib/list/format.js +84 -0
- package/dist/lib/list/index.js +16 -0
- package/dist/lib/lock-file.js +413 -0
- package/dist/lib/pack/log.js +18 -0
- package/dist/lib/pack/schema.js +121 -0
- package/dist/lib/pack/seed.js +182 -0
- package/dist/lib/pack/validate.js +49 -0
- package/dist/lib/package-version.js +25 -0
- package/dist/lib/repo-kind.js +129 -0
- package/dist/lib/scripts.js +7 -0
- package/dist/lib/validate/aggregate.js +35 -0
- package/dist/lib/validate/bundle-coherence.js +69 -0
- package/dist/lib/validate/dispatch.js +218 -0
- package/dist/lib/validate/index.js +88 -0
- package/dist/lib/validate/runner.js +85 -0
- package/dist/verbs/catalogue-verify.js +114 -0
- package/dist/verbs/list.js +45 -0
- package/dist/verbs/pack-validate.js +17 -0
- package/dist/verbs/path-option.js +25 -0
- package/dist/verbs/validate.js +22 -0
- package/dist/verify-bin.js +8 -0
- package/dist/verify-program.js +115 -0
- package/package.json +32 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// Staleness VERDICTS — the second half of M-ADR 0205 §4.5's staleness scope
|
|
2
|
+
// (ledger §3o): "staleness meaning corpus_ref older than the target's last
|
|
3
|
+
// content change". The scores-coverage predicate computes each row's BASIS;
|
|
4
|
+
// this module computes the VERDICT the basis supports. A stale score is worse
|
|
5
|
+
// than a missing one, because it reads as coverage — the predicate otherwise
|
|
6
|
+
// counts a score written against a corpus that has since moved as fully
|
|
7
|
+
// covering its target.
|
|
8
|
+
//
|
|
9
|
+
// Verdict rules, per basis:
|
|
10
|
+
// corpus_ref + target_path — PRECISE, and a CONTENT test: the target is stale
|
|
11
|
+
// iff its blob at HEAD differs from its blob at the anchor. An anchor the
|
|
12
|
+
// repo does not know is UNKNOWN-ANCHOR (never guessed); an anchor with
|
|
13
|
+
// no target_path cannot support a per-target verdict and is counted as
|
|
14
|
+
// such rather than compared against repo HEAD (which would mark a row
|
|
15
|
+
// stale whenever ANYTHING in the repo moved — alarm, not measurement).
|
|
16
|
+
//
|
|
17
|
+
// WHY CONTENT AND NOT `git log`: the previous test asked whether any
|
|
18
|
+
// commit touched the path since the anchor, which fires on a PURE RENAME
|
|
19
|
+
// — so a portability rename restarts the staleness treadmill for every
|
|
20
|
+
// anchored score in the corpus, and the estate is told to re-review
|
|
21
|
+
// hundreds of artefacts whose text never changed. Measured instance: one
|
|
22
|
+
// score reported stale-by-hours was byte-IDENTICAL to its assessed state,
|
|
23
|
+
// caught only by comparing blobs by hand. A test that cannot tell "moved"
|
|
24
|
+
// from "changed" produces re-review debt out of nothing.
|
|
25
|
+
//
|
|
26
|
+
// Renames are exempt BY CONSTRUCTION, not by a special case: where the
|
|
27
|
+
// path does not exist at the anchor, git's own rename detection over
|
|
28
|
+
// <anchor>..HEAD supplies the pre-rename name and the blob is compared
|
|
29
|
+
// across it. Where that also fails the row is PATH-ABSENT-AT-ANCHOR —
|
|
30
|
+
// reported as its own outcome rather than guessed either way.
|
|
31
|
+
//
|
|
32
|
+
// TARGET-MISSING (any basis): the target_path does not exist at HEAD. A
|
|
33
|
+
// deleted artefact cannot be re-reviewed, so folding it into `stale`
|
|
34
|
+
// prescribes a remedy that cannot be performed (M-ADR 0199 §4.4). It is
|
|
35
|
+
// counted and reported on its own, and its remedy is to withdraw or
|
|
36
|
+
// re-point the score.
|
|
37
|
+
// scored_at + target_path — COARSE: last content change of the path
|
|
38
|
+
// (committer date) after scored_at ⇒ stale. A path git does not track
|
|
39
|
+
// is UNKNOWN-ANCHOR.
|
|
40
|
+
// none — already reported by scores-coverage as unknown currency; not
|
|
41
|
+
// re-reported here.
|
|
42
|
+
//
|
|
43
|
+
// MEMBER TIER ONLY, stated not silent: the aggregate tier reads sibling
|
|
44
|
+
// catalogues whose target files and git histories are not local, so a verdict
|
|
45
|
+
// there would be a guess. Reporting-only (`coverage` class) like the rest of
|
|
46
|
+
// the scores family — promotion to gate-acting is a later, deliberate act.
|
|
47
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
48
|
+
import { access } from "node:fs/promises";
|
|
49
|
+
import { join } from "node:path";
|
|
50
|
+
import { promisify } from "node:util";
|
|
51
|
+
const execFile = promisify(execFileCb);
|
|
52
|
+
const NO_RUN = {
|
|
53
|
+
assessed: 0,
|
|
54
|
+
stale: [],
|
|
55
|
+
current: 0,
|
|
56
|
+
unknownAnchor: 0,
|
|
57
|
+
anchorWithoutPath: 0,
|
|
58
|
+
targetMissing: [],
|
|
59
|
+
pathAbsentAtAnchor: 0,
|
|
60
|
+
directoryTargets: 0,
|
|
61
|
+
};
|
|
62
|
+
async function git(sddRoot, args) {
|
|
63
|
+
try {
|
|
64
|
+
const { stdout } = await execFile("git", [...args], {
|
|
65
|
+
cwd: sddRoot,
|
|
66
|
+
encoding: "utf8",
|
|
67
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
68
|
+
});
|
|
69
|
+
return { ok: true, stdout: stdout.trim() };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return { ok: false, stdout: "" };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Is the target genuinely GONE, or merely not committed?
|
|
77
|
+
*
|
|
78
|
+
* `git rev-parse HEAD:<path>` fails for both a deleted file and an untracked
|
|
79
|
+
* one that is sitting on disk. Treating the second as deleted would tell an
|
|
80
|
+
* author to withdraw a score for an artefact they can see in front of them —
|
|
81
|
+
* so absence from the HEAD tree is confirmed against the working tree before
|
|
82
|
+
* it is called missing. (Caught by this module's own untracked-path control,
|
|
83
|
+
* which is what it was there for.)
|
|
84
|
+
*/
|
|
85
|
+
async function existsOnDisk(sddRoot, path) {
|
|
86
|
+
try {
|
|
87
|
+
await access(join(sddRoot, path));
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* DIRECTORY TARGETS are a real and different case, raised by the reverse estate
|
|
96
|
+
* from its own pre-cut data: 18 of its 84 stale rows name a whole-domain
|
|
97
|
+
* directory rather than a file.
|
|
98
|
+
*
|
|
99
|
+
* `git rev-parse <rev>:<path>` returns a TREE oid for a directory and a BLOB oid
|
|
100
|
+
* for a file, so the comparison below works for both — but it does not mean the
|
|
101
|
+
* same thing. A file is stale when its own bytes changed; a directory is stale
|
|
102
|
+
* when ANYTHING beneath it changed, which for a whole-domain tree is close to
|
|
103
|
+
* unconditional. That is a wider test, and it is defensible (a score over a tree
|
|
104
|
+
* really is invalidated by movement inside it) — but it must be SAID, because a
|
|
105
|
+
* number that is near-unconditional by construction reads exactly like a number
|
|
106
|
+
* that is measuring something.
|
|
107
|
+
*
|
|
108
|
+
* Git's rename detection also does not apply: a tree has no rename pair. A moved
|
|
109
|
+
* directory therefore lands in `pathAbsentAtAnchor` rather than being exempted.
|
|
110
|
+
*/
|
|
111
|
+
async function objectTypeAt(sddRoot, rev, path) {
|
|
112
|
+
const oid = await git(sddRoot, ["rev-parse", `${rev}:${path}`]);
|
|
113
|
+
if (!oid.ok || oid.stdout === "")
|
|
114
|
+
return null;
|
|
115
|
+
const t = await git(sddRoot, ["cat-file", "-t", oid.stdout]);
|
|
116
|
+
if (!t.ok)
|
|
117
|
+
return null;
|
|
118
|
+
return t.stdout === "tree" ? "tree" : "blob";
|
|
119
|
+
}
|
|
120
|
+
/** Blob (or tree — see `objectTypeAt`) id of `<rev>:<path>`, else null. */
|
|
121
|
+
async function blobAt(sddRoot, rev, path) {
|
|
122
|
+
const r = await git(sddRoot, ["rev-parse", `${rev}:${path}`]);
|
|
123
|
+
return r.ok && r.stdout !== "" ? r.stdout : null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* new-path -> pre-rename path, from git's OWN rename detection over
|
|
127
|
+
* `<anchor>..HEAD`. One call per distinct anchor, not per row: anchors are few
|
|
128
|
+
* and this walk is the expensive part.
|
|
129
|
+
*
|
|
130
|
+
* Using git's detector rather than a heuristic matters — a basename match would
|
|
131
|
+
* pair unrelated files that happen to share a name, which is precisely the
|
|
132
|
+
* shared-token failure this corpus keeps producing.
|
|
133
|
+
*/
|
|
134
|
+
async function renameMap(sddRoot, anchor) {
|
|
135
|
+
const map = new Map();
|
|
136
|
+
const r = await git(sddRoot, ["diff", "--name-status", "-M", anchor, "HEAD"]);
|
|
137
|
+
if (!r.ok)
|
|
138
|
+
return map;
|
|
139
|
+
for (const line of r.stdout.split("\n")) {
|
|
140
|
+
if (!line.startsWith("R"))
|
|
141
|
+
continue;
|
|
142
|
+
const parts = line.split("\t");
|
|
143
|
+
if (parts.length < 3)
|
|
144
|
+
continue;
|
|
145
|
+
map.set(parts[2], parts[1]);
|
|
146
|
+
}
|
|
147
|
+
return map;
|
|
148
|
+
}
|
|
149
|
+
export async function runStalenessVerdicts(session, options) {
|
|
150
|
+
if (!options.available.has("quality_scores"))
|
|
151
|
+
return { ...NO_RUN };
|
|
152
|
+
if (options.tier !== "member") {
|
|
153
|
+
return {
|
|
154
|
+
...NO_RUN,
|
|
155
|
+
skipped: "aggregate tier cannot reach member repos' files or git histories — staleness verdicts are member-tier",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const inRepo = await git(options.sddRoot, ["rev-parse", "--git-dir"]);
|
|
159
|
+
if (!inRepo.ok) {
|
|
160
|
+
return {
|
|
161
|
+
...NO_RUN,
|
|
162
|
+
skipped: `${options.sddRoot} is not a git repository — no anchor can be compared, verdicts UNATTEMPTED (not fresh)`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const asStr = (v) => v === null || v === undefined ? "" : String(v).trim();
|
|
166
|
+
const reader = await session.conn.runAndReadAll(`SELECT rubric_id, target_ref, target_path, corpus_ref, scored_at, staleness_basis
|
|
167
|
+
FROM "quality_scores"
|
|
168
|
+
WHERE staleness_basis IN ('corpus_ref', 'scored_at');`);
|
|
169
|
+
const rows = reader.getRows();
|
|
170
|
+
const stale = [];
|
|
171
|
+
let current = 0;
|
|
172
|
+
let unknownAnchor = 0;
|
|
173
|
+
let anchorWithoutPath = 0;
|
|
174
|
+
let pathAbsentAtAnchor = 0;
|
|
175
|
+
let directoryTargets = 0;
|
|
176
|
+
const targetMissing = [];
|
|
177
|
+
const anchorKnown = new Map();
|
|
178
|
+
const renamesByAnchor = new Map();
|
|
179
|
+
for (const [rubricIdRaw, targetRefRaw, targetPathRaw, corpusRefRaw, scoredAtRaw, basisRaw] of rows) {
|
|
180
|
+
const rubricId = asStr(rubricIdRaw);
|
|
181
|
+
const targetRef = asStr(targetRefRaw);
|
|
182
|
+
const basis = asStr(basisRaw);
|
|
183
|
+
// Counted for BOTH bases, before the branch. The first cut of this counter
|
|
184
|
+
// sat inside the corpus_ref branch only, so it was blind to every
|
|
185
|
+
// scored_at row — and the estate that ASKED for this count has all 18 of
|
|
186
|
+
// its directory targets on the scored_at basis, so the metric it requested
|
|
187
|
+
// read zero for it. The controls passed because they were written with
|
|
188
|
+
// corpus_ref fixtures: a control that exercises one branch says nothing
|
|
189
|
+
// about the other.
|
|
190
|
+
{
|
|
191
|
+
const p = asStr(targetPathRaw);
|
|
192
|
+
if (p !== "" && (await objectTypeAt(options.sddRoot, "HEAD", p)) === "tree") {
|
|
193
|
+
directoryTargets += 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (basis === "corpus_ref") {
|
|
197
|
+
const anchor = asStr(corpusRefRaw);
|
|
198
|
+
const path = asStr(targetPathRaw);
|
|
199
|
+
if (path === "") {
|
|
200
|
+
anchorWithoutPath += 1;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
let known = anchorKnown.get(anchor);
|
|
204
|
+
if (known === undefined) {
|
|
205
|
+
known = (await git(options.sddRoot, ["cat-file", "-e", `${anchor}^{commit}`])).ok;
|
|
206
|
+
anchorKnown.set(anchor, known);
|
|
207
|
+
}
|
|
208
|
+
if (!known) {
|
|
209
|
+
unknownAnchor += 1;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const headBlob = await blobAt(options.sddRoot, "HEAD", path);
|
|
213
|
+
if (headBlob === null) {
|
|
214
|
+
if (await existsOnDisk(options.sddRoot, path)) {
|
|
215
|
+
// Present but uncommitted: no blob to compare against, and nothing
|
|
216
|
+
// deleted. Unknown, which is not fresh.
|
|
217
|
+
unknownAnchor += 1;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
targetMissing.push({ rubricId, targetRef, targetPath: path });
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
let anchorBlob = await blobAt(options.sddRoot, anchor, path);
|
|
225
|
+
if (anchorBlob === null) {
|
|
226
|
+
// The path did not exist at the anchor under this name. Ask git whether
|
|
227
|
+
// it was renamed there rather than concluding anything.
|
|
228
|
+
let renames = renamesByAnchor.get(anchor);
|
|
229
|
+
if (renames === undefined) {
|
|
230
|
+
renames = await renameMap(options.sddRoot, anchor);
|
|
231
|
+
renamesByAnchor.set(anchor, renames);
|
|
232
|
+
}
|
|
233
|
+
const oldPath = renames.get(path);
|
|
234
|
+
if (oldPath !== undefined) {
|
|
235
|
+
anchorBlob = await blobAt(options.sddRoot, anchor, oldPath);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (anchorBlob === null) {
|
|
239
|
+
pathAbsentAtAnchor += 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (anchorBlob === headBlob) {
|
|
243
|
+
// Byte-identical to its assessed state — including across a rename,
|
|
244
|
+
// which is the whole point of testing content instead of touches.
|
|
245
|
+
current += 1;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
stale.push({
|
|
249
|
+
rubricId,
|
|
250
|
+
targetRef,
|
|
251
|
+
basis: "corpus_ref",
|
|
252
|
+
detail: "target CONTENT differs from its state at the anchor",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
const path = asStr(targetPathRaw);
|
|
258
|
+
const at = asStr(scoredAtRaw);
|
|
259
|
+
if (path === "" || at === "") {
|
|
260
|
+
// basis says scored_at, but the row cannot actually support it —
|
|
261
|
+
// count as unknown rather than trusting the label over the data.
|
|
262
|
+
unknownAnchor += 1;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if ((await blobAt(options.sddRoot, "HEAD", path)) === null &&
|
|
266
|
+
!(await existsOnDisk(options.sddRoot, path))) {
|
|
267
|
+
targetMissing.push({ rubricId, targetRef, targetPath: path });
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const last = await git(options.sddRoot, ["log", "-1", "--format=%cI", "--", path]);
|
|
271
|
+
if (!last.ok || last.stdout === "") {
|
|
272
|
+
unknownAnchor += 1;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const reviewedMs = Date.parse(at);
|
|
276
|
+
if (Number.isNaN(reviewedMs)) {
|
|
277
|
+
unknownAnchor += 1;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (Date.parse(last.stdout) > reviewedMs) {
|
|
281
|
+
stale.push({
|
|
282
|
+
rubricId,
|
|
283
|
+
targetRef,
|
|
284
|
+
basis: "scored_at",
|
|
285
|
+
detail: `target last changed ${last.stdout.slice(0, 10)}, after the review of ${at.slice(0, 10)}`,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
current += 1;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
assessed: rows.length,
|
|
295
|
+
stale,
|
|
296
|
+
current,
|
|
297
|
+
unknownAnchor,
|
|
298
|
+
anchorWithoutPath,
|
|
299
|
+
targetMissing,
|
|
300
|
+
pathAbsentAtAnchor,
|
|
301
|
+
directoryTargets,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
export function renderStalenessVerdicts(res, tier) {
|
|
305
|
+
const out = [];
|
|
306
|
+
if (res.skipped !== undefined) {
|
|
307
|
+
out.push(`[metric] scores_staleness (${tier}): n/a — ${res.skipped}`);
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
if (res.assessed === 0)
|
|
311
|
+
return out;
|
|
312
|
+
for (const s of res.stale) {
|
|
313
|
+
out.push(`[finding] coverage: ${s.rubricId} score for '${s.targetRef}' is STALE — ` +
|
|
314
|
+
`${s.detail}; the assessment predates the artefact it reads as covering — ` +
|
|
315
|
+
`re-review it or record why the change does not invalidate the score`);
|
|
316
|
+
}
|
|
317
|
+
if (res.pathAbsentAtAnchor > 0) {
|
|
318
|
+
// Counted AND said. A bucket that only ever appears as a number in a metric
|
|
319
|
+
// line is a state nobody acts on, and this one means an assessment cannot
|
|
320
|
+
// be verified at all.
|
|
321
|
+
out.push(`[finding] coverage: ${res.pathAbsentAtAnchor} score(s) have a target that DID NOT EXIST ` +
|
|
322
|
+
`AT THE ANCHOR under any name git can pair — the assessment cannot be verified ` +
|
|
323
|
+
`against its own corpus_ref; re-anchor the score, or re-review and re-anchor together`);
|
|
324
|
+
}
|
|
325
|
+
for (const t of res.targetMissing) {
|
|
326
|
+
// Remedy deliberately differs from STALE's. M-ADR 0199 §4.4 binds a remedy
|
|
327
|
+
// to be performable at the tier that emitted it, and "re-review it" cannot
|
|
328
|
+
// be performed on an artefact that no longer exists.
|
|
329
|
+
out.push(`[finding] coverage: ${t.rubricId} score for '${t.targetRef}' has a TARGET THAT NO LONGER ` +
|
|
330
|
+
`EXISTS — '${t.targetPath}' is absent at HEAD; the assessment cannot be re-reviewed, ` +
|
|
331
|
+
`so withdraw the score or re-point it at the successor artefact`);
|
|
332
|
+
}
|
|
333
|
+
out.push(`[metric] scores_staleness (${tier}): ${res.assessed} verdict-capable score(s) — ` +
|
|
334
|
+
`${res.stale.length} stale / ${res.current} current / ` +
|
|
335
|
+
`${res.targetMissing.length} target-missing / ` +
|
|
336
|
+
`${res.unknownAnchor} unknown-anchor / ${res.anchorWithoutPath} anchor-without-path / ` +
|
|
337
|
+
`${res.pathAbsentAtAnchor} path-absent-at-anchor` +
|
|
338
|
+
(res.directoryTargets > 0
|
|
339
|
+
? ` (of which ${res.directoryTargets} target a DIRECTORY — stale there means anything beneath it changed, a wider test than a file's and near-unconditional over a large tree)`
|
|
340
|
+
: "") +
|
|
341
|
+
` (staleness is a CONTENT test — a rename is not a change; unknown is not fresh;` +
|
|
342
|
+
` reporting-only per M-ADR 0205 §4.5)`);
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
//# sourceMappingURL=staleness-verdicts.js.map
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// `sdd-cli catalogue validate` — re-validate an existing catalogues/
|
|
2
|
+
// directory without rebuilding. Two checks per the canonical CLI spec:
|
|
3
|
+
//
|
|
4
|
+
// 1. Column-shape check: every CSV's header row must match the
|
|
5
|
+
// canonical schema's declared columns (exact set + order).
|
|
6
|
+
// 2. FK validation: full pass over the loaded rows, internal mode
|
|
7
|
+
// (external FKs treated per their declared flag — same semantics
|
|
8
|
+
// as `catalogue build`). Adopters who want post-merge validation
|
|
9
|
+
// run `catalogue aggregate` instead.
|
|
10
|
+
//
|
|
11
|
+
// Outputs the same shape of findings as `build` so consumers can pipe
|
|
12
|
+
// either through the same parser. Exit 0 on PASS; non-zero on shape
|
|
13
|
+
// drift OR internal-FK FAIL.
|
|
14
|
+
import { stat } from "node:fs/promises";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
import { CLI_VERSION } from "./cli-version.js";
|
|
17
|
+
import { readCatalogueCsv } from "./csv-reader.js";
|
|
18
|
+
import { writeCatalogue } from "./csv-writer.js";
|
|
19
|
+
import { validateWithEngineToLog } from "./engine-validator.js";
|
|
20
|
+
import { FindingsLog } from "./findings.js";
|
|
21
|
+
import { loadCanonicalSchema } from "./schema-loader.js";
|
|
22
|
+
export async function runCatalogueValidate(inputs, write) {
|
|
23
|
+
const sddPath = resolve(inputs.sddPath ?? process.cwd());
|
|
24
|
+
const cataloguesDir = join(sddPath, "catalogues");
|
|
25
|
+
const schema = await loadCanonicalSchema();
|
|
26
|
+
write(`sdd-cli catalogue validate (canonical schema ${schema.schemaVersion}, cli ${CLI_VERSION})\n`);
|
|
27
|
+
// Existence check on catalogues/ — fail fast with a clear message.
|
|
28
|
+
try {
|
|
29
|
+
const s = await stat(cataloguesDir);
|
|
30
|
+
if (!s.isDirectory()) {
|
|
31
|
+
throw new Error(`catalogues/ at ${cataloguesDir} exists but is not a directory`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
if (err.code === "ENOENT") {
|
|
36
|
+
throw new Error(`catalogues/ not found at ${cataloguesDir}\n` +
|
|
37
|
+
` Run \`sdd-cli catalogue build\` before \`validate\`.`);
|
|
38
|
+
}
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
const findings = new FindingsLog();
|
|
42
|
+
const rowsByCatalogue = new Map();
|
|
43
|
+
let columnShapeOk = true;
|
|
44
|
+
for (const cat of schema.catalogues) {
|
|
45
|
+
const filePath = join(cataloguesDir, `${cat.name}.csv`);
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = await readCatalogueCsv(filePath);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err.code === "ENOENT") {
|
|
52
|
+
// Missing file is a shape failure — every catalogue declared in
|
|
53
|
+
// 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)`);
|
|
55
|
+
columnShapeOk = false;
|
|
56
|
+
rowsByCatalogue.set(cat.name, []);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
const expected = cat.columns.map((c) => c.name);
|
|
62
|
+
const drift = compareColumns(expected, parsed.columns);
|
|
63
|
+
if (drift !== null) {
|
|
64
|
+
findings.add("convention", `catalogues/${cat.name}.csv: column shape drift — ${drift}`);
|
|
65
|
+
columnShapeOk = false;
|
|
66
|
+
}
|
|
67
|
+
rowsByCatalogue.set(cat.name, parsed.rows);
|
|
68
|
+
}
|
|
69
|
+
// Derive the SDD profile from products.csv so profile-external FKs (schema
|
|
70
|
+
// 2.23.0) resolve consistently with `catalogue build`. A per-SDD catalogue
|
|
71
|
+
// has one distinct profile → pass it (an app catalogue's capabilities.domain_id
|
|
72
|
+
// is then treated external, matching the build). An aggregated catalogue mixes
|
|
73
|
+
// profiles → leave undefined, so those FKs validate as internal and MUST
|
|
74
|
+
// resolve (the merged platform domains are present to satisfy them).
|
|
75
|
+
const profile = singleProfile(rowsByCatalogue.get("products") ?? []);
|
|
76
|
+
// FK + bound-enum validation on the embedded engine (M-ADR 0205 §4.4
|
|
77
|
+
// cutover; same semantics, findings text unchanged — the A2/A3 dual-run
|
|
78
|
+
// record is the evidence). Enum hard-fail behaviour per M-ADR 0203 §4.1
|
|
79
|
+
// is unchanged: an out-of-set value is an error-severity finding.
|
|
80
|
+
const { fk, enums } = await validateWithEngineToLog(schema, rowsByCatalogue, findings, { ...(profile !== undefined ? { profile } : {}) });
|
|
81
|
+
// Report.
|
|
82
|
+
const findingsText = findings.render();
|
|
83
|
+
if (findingsText)
|
|
84
|
+
write(`\n${findingsText}\n`);
|
|
85
|
+
if (columnShapeOk) {
|
|
86
|
+
write(`\nColumn-shape check: PASS\n`);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
write(`\nColumn-shape check: FAIL\n`);
|
|
90
|
+
}
|
|
91
|
+
if (fk.internalPass) {
|
|
92
|
+
write(`FK validation (internal): PASS\n`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
write(`FK validation (internal): FAIL (${fk.internalFailures.length} dangling)\n`);
|
|
96
|
+
for (const f of fk.internalFailures) {
|
|
97
|
+
write(` ${f.catalogue}.${f.column}: row pk=${f.referencingRow} → ${f.target}=${f.value || "<empty>"}\n`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (enums.pass) {
|
|
101
|
+
write(`Enum validation (column_enums): PASS\n`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
write(`Enum validation (column_enums): FAIL (${enums.violations.length} out-of-set)\n`);
|
|
105
|
+
for (const v of enums.violations) {
|
|
106
|
+
write(` ${v.catalogue}.${v.column}: row pk=${v.rowPk} value "${v.value}" (allowed: ${v.allowed})\n`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (fk.externalFindings > 0) {
|
|
110
|
+
write(`External findings: ${fk.externalFindings} (validated by aggregator)\n`);
|
|
111
|
+
}
|
|
112
|
+
// Force-touch writeCatalogue to keep imports honest if a future
|
|
113
|
+
// refactor wants to re-emit a cleaned-up file from validate. Today
|
|
114
|
+
// validate is read-only.
|
|
115
|
+
void writeCatalogue;
|
|
116
|
+
const strictFail = inputs.strict === true && findings.count() > 0;
|
|
117
|
+
// Error-severity findings (the bound-enum FAIL path) fail validate by
|
|
118
|
+
// default, matching `catalogue build`'s exit-code contract.
|
|
119
|
+
const exitCode = !columnShapeOk || !fk.internalPass || !enums.pass || findings.errorCount() > 0 || strictFail
|
|
120
|
+
? 1
|
|
121
|
+
: 0;
|
|
122
|
+
return {
|
|
123
|
+
exitCode,
|
|
124
|
+
schemaVersion: schema.schemaVersion,
|
|
125
|
+
internalFkPass: fk.internalPass,
|
|
126
|
+
columnShapeOk,
|
|
127
|
+
enumPass: enums.pass,
|
|
128
|
+
findingsCount: findings.count(),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Compare a CSV's actual column header against the schema's declared
|
|
133
|
+
* column order. Returns null if exactly equal (order included), or a
|
|
134
|
+
* one-line description of the drift otherwise.
|
|
135
|
+
*/
|
|
136
|
+
/**
|
|
137
|
+
* The single distinct non-empty `profile` across products rows, or undefined
|
|
138
|
+
* when there are zero or multiple (an aggregated catalogue). Drives
|
|
139
|
+
* profile-external FK handling (schema 2.23.0) — see the call site.
|
|
140
|
+
*/
|
|
141
|
+
function singleProfile(products) {
|
|
142
|
+
const profiles = new Set();
|
|
143
|
+
for (const p of products) {
|
|
144
|
+
const v = String(p["profile"] ?? "");
|
|
145
|
+
if (v !== "")
|
|
146
|
+
profiles.add(v);
|
|
147
|
+
}
|
|
148
|
+
return profiles.size === 1 ? [...profiles][0] : undefined;
|
|
149
|
+
}
|
|
150
|
+
function compareColumns(expected, actual) {
|
|
151
|
+
if (expected.length === actual.length) {
|
|
152
|
+
let equal = true;
|
|
153
|
+
for (let i = 0; i < expected.length; i++) {
|
|
154
|
+
if (expected[i] !== actual[i]) {
|
|
155
|
+
equal = false;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (equal)
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const expectedSet = new Set(expected);
|
|
163
|
+
const actualSet = new Set(actual);
|
|
164
|
+
const missing = expected.filter((c) => !actualSet.has(c));
|
|
165
|
+
const extra = actual.filter((c) => !expectedSet.has(c));
|
|
166
|
+
if (missing.length === 0 && extra.length === 0) {
|
|
167
|
+
return `column order differs (expected: ${expected.join(",")} | got: ${actual.join(",")})`;
|
|
168
|
+
}
|
|
169
|
+
const parts = [];
|
|
170
|
+
if (missing.length > 0)
|
|
171
|
+
parts.push(`missing: ${missing.join(",")}`);
|
|
172
|
+
if (extra.length > 0)
|
|
173
|
+
parts.push(`unexpected: ${extra.join(",")}`);
|
|
174
|
+
return parts.join("; ");
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=validate.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// `sdd-cli catalogue version` — prints the canonical schema version
|
|
2
|
+
// and the sdd-cli version pin so adopters can verify their tooling
|
|
3
|
+
// matches the schema they expect.
|
|
4
|
+
import { CLI_VERSION } from "./cli-version.js";
|
|
5
|
+
import { SCHEMA_COMPAT } from "./schema-compat.js";
|
|
6
|
+
import { loadCanonicalSchema } from "./schema-loader.js";
|
|
7
|
+
export async function runCatalogueVersion(write) {
|
|
8
|
+
const schema = await loadCanonicalSchema();
|
|
9
|
+
write([
|
|
10
|
+
`sdd-cli ${CLI_VERSION}`,
|
|
11
|
+
`catalogue schema ${schema.schemaVersion}`,
|
|
12
|
+
`accepted catalogue range ${SCHEMA_COMPAT.catalogue_schema}`,
|
|
13
|
+
`accepted sdd_manifest ${SCHEMA_COMPAT.sdd_manifest}`,
|
|
14
|
+
`accepted aggregation ${SCHEMA_COMPAT.aggregation_manifest}`,
|
|
15
|
+
"",
|
|
16
|
+
].join("\n"));
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=version.js.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { opendir } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { readLockFile } from "../lock-file.js";
|
|
4
|
+
import { readRepoKind } from "../repo-kind.js";
|
|
5
|
+
const SKIP_DIRS = new Set([
|
|
6
|
+
"node_modules",
|
|
7
|
+
".git",
|
|
8
|
+
"dist",
|
|
9
|
+
"build",
|
|
10
|
+
".next",
|
|
11
|
+
".turbo",
|
|
12
|
+
".cache",
|
|
13
|
+
"coverage",
|
|
14
|
+
"tmp",
|
|
15
|
+
"export",
|
|
16
|
+
".venv",
|
|
17
|
+
"__pycache__",
|
|
18
|
+
]);
|
|
19
|
+
export async function discoverSddRepos(roots) {
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
const repos = [];
|
|
22
|
+
for (const root of roots) {
|
|
23
|
+
const abs = resolve(root);
|
|
24
|
+
await walk(abs, seen, repos);
|
|
25
|
+
}
|
|
26
|
+
repos.sort((a, b) => a.path.localeCompare(b.path));
|
|
27
|
+
return repos;
|
|
28
|
+
}
|
|
29
|
+
async function walk(dir, seen, repos) {
|
|
30
|
+
if (seen.has(dir))
|
|
31
|
+
return;
|
|
32
|
+
seen.add(dir);
|
|
33
|
+
const kind = await tryReadRepoKind(dir);
|
|
34
|
+
if (kind) {
|
|
35
|
+
const bundles = await tryReadBundles(dir);
|
|
36
|
+
const forwardBase = bundles.find((b) => b.isForwardBase) ?? bundles[0];
|
|
37
|
+
const bundleVersion = forwardBase?.version ?? null;
|
|
38
|
+
repos.push({ path: dir, kind, bundleVersion, bundles });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = await opendir(dir);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for await (const entry of entries) {
|
|
49
|
+
if (!entry.isDirectory())
|
|
50
|
+
continue;
|
|
51
|
+
if (entry.name.startsWith(".") &&
|
|
52
|
+
entry.name !== ".iteraitive" &&
|
|
53
|
+
entry.name !== ".sdd-method") {
|
|
54
|
+
if (SKIP_DIRS.has(entry.name))
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (SKIP_DIRS.has(entry.name))
|
|
58
|
+
continue;
|
|
59
|
+
await walk(join(dir, entry.name), seen, repos);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function tryReadRepoKind(dir) {
|
|
63
|
+
try {
|
|
64
|
+
return await readRepoKind(dir);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function tryReadBundles(dir) {
|
|
71
|
+
try {
|
|
72
|
+
const lock = await readLockFile(dir);
|
|
73
|
+
if (!lock)
|
|
74
|
+
return [];
|
|
75
|
+
return lock.bundles.map(toDiscoveredBundle);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function toDiscoveredBundle(entry) {
|
|
82
|
+
const bundle = {
|
|
83
|
+
name: entry.name,
|
|
84
|
+
version: entry.bundleVersion,
|
|
85
|
+
isForwardBase: entry.isForwardBase,
|
|
86
|
+
managedPathCount: entry.managedPaths.length,
|
|
87
|
+
};
|
|
88
|
+
if (entry.producedByAdopter)
|
|
89
|
+
bundle.producedByAdopter = entry.producedByAdopter;
|
|
90
|
+
if (entry.scope)
|
|
91
|
+
bundle.scope = entry.scope;
|
|
92
|
+
if (entry.requiresForwardBase) {
|
|
93
|
+
bundle.requiresForwardBase = entry.requiresForwardBase.bundle;
|
|
94
|
+
}
|
|
95
|
+
if (entry.requiresPack) {
|
|
96
|
+
bundle.requiresPack = entry.requiresPack.name;
|
|
97
|
+
}
|
|
98
|
+
return bundle;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=discover.js.map
|