@ecoma-io/archkeep 0.14.0 → 0.16.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/README.md +11 -5
- package/cli.mjs +571 -61
- package/commands.mjs +57 -0
- package/lsp.mjs +15 -2
- package/package.json +8 -2
- package/src/analysis/analyze.mjs +15 -0
- package/src/analysis/contract.md +36 -18
- package/src/analysis/csharp.mjs +485 -0
- package/src/analysis/dotnet/csproj.mjs +380 -0
- package/src/analysis/dotnet/mask.mjs +178 -0
- package/src/analysis/dotnet/namespaces.mjs +172 -0
- package/src/analysis/dotnet/resolve.mjs +89 -0
- package/src/analysis/go.mjs +289 -5
- package/src/analysis/java.mjs +329 -0
- package/src/analysis/jvm/gradle.mjs +545 -0
- package/src/analysis/jvm/mask.mjs +170 -0
- package/src/analysis/jvm/maven.mjs +612 -0
- package/src/analysis/jvm/packages.mjs +209 -0
- package/src/analysis/jvm/resolve.mjs +139 -0
- package/src/analysis/kotlin.mjs +210 -0
- package/src/analysis/manifest-util.mjs +30 -0
- package/src/analysis/python.mjs +3 -2
- package/src/analysis/registry.mjs +11 -0
- package/src/analysis/rust.mjs +171 -17
- package/src/analysis/source-util.mjs +155 -6
- package/src/analysis/typescript.mjs +11 -3
- package/src/commands/README.md +52 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +2 -1
- package/src/commands/context.mjs +124 -16
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +195 -33
- package/src/commands/delta-snapshot.mjs +156 -1
- package/src/commands/delta.mjs +142 -17
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +57 -0
- package/src/commands/provenance.mjs +7 -44
- package/src/commands/rules.mjs +775 -0
- package/src/commands/trajectory.mjs +437 -0
- package/src/governance/profile-registry.mjs +0 -1
- package/src/graph/create-dependencies.mjs +138 -15
- package/src/lsp/diagnose.mjs +1 -1
- package/src/lsp/server.mjs +97 -1
- package/src/lsp/workspace-index.mjs +106 -15
- package/src/options.mjs +30 -7
- package/src/path-util.mjs +40 -0
- package/src/process.mjs +10 -1
- package/src/providers/moon.mjs +287 -36
- package/src/providers/native/differential.fixtures.mjs +32 -6
- package/src/providers/native/discover.mjs +83 -4
- package/src/providers/native/graph.mjs +58 -0
- package/src/providers/native/model.mjs +59 -1
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +82 -1
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +255 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/rules/index.mjs +21 -6
- package/src/rules/reachability.mjs +2 -0
- package/src/rules/tags.mjs +7 -5
- package/src/rules/topology.mjs +5 -3
- package/src/tsconfig-paths.mjs +3 -2
- package/src/workspace.mjs +115 -23
|
@@ -66,6 +66,7 @@ import { canonicalizeJson } from "../canonical.mjs";
|
|
|
66
66
|
import { suppressionCovers } from "../config.mjs";
|
|
67
67
|
import { referenceTime } from "../governance/clock.mjs";
|
|
68
68
|
import { suppressionFate } from "../governance/waiver.mjs";
|
|
69
|
+
import { namespacedId } from "./custom-rules.mjs";
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* Computes a violation's architectural identity.
|
|
@@ -190,23 +191,7 @@ export function classifyViolations({ base, head, suppressions = [], now = refere
|
|
|
190
191
|
headSites: headGroup ? headGroup.sites : [],
|
|
191
192
|
};
|
|
192
193
|
|
|
193
|
-
|
|
194
|
-
entry.classification = "introduced";
|
|
195
|
-
entry.reason = "absent at base";
|
|
196
|
-
} else if (headCount === 0) {
|
|
197
|
-
entry.classification = "resolved";
|
|
198
|
-
} else if (headCount > baseCount) {
|
|
199
|
-
entry.classification = "introduced";
|
|
200
|
-
entry.reason = `occurrence growth: ${baseCount} at base, ${headCount} at head`;
|
|
201
|
-
} else if (headCount < baseCount) {
|
|
202
|
-
// Still present at head — a shrink is NEVER a resolution.
|
|
203
|
-
entry.classification = "unchanged";
|
|
204
|
-
entry.note =
|
|
205
|
-
`occurrencesReduced: ${baseCount} at base, ${headCount} at head — the violation ` +
|
|
206
|
-
`still exists`;
|
|
207
|
-
} else {
|
|
208
|
-
entry.classification = "unchanged";
|
|
209
|
-
}
|
|
194
|
+
Object.assign(entry, occurrenceClassification(baseCount, headCount, "the violation"));
|
|
210
195
|
|
|
211
196
|
// A resolved item has no head occurrence left; its waive status is judged
|
|
212
197
|
// against the LAST places the violation existed (its base sites) — what
|
|
@@ -296,22 +281,7 @@ export function classifyUnresolvableRecords({ base, head, sourceProjectOf }) {
|
|
|
296
281
|
headSites: headGroup ? headGroup.sites : [],
|
|
297
282
|
};
|
|
298
283
|
|
|
299
|
-
|
|
300
|
-
entry.classification = "introduced";
|
|
301
|
-
entry.reason = "absent at base";
|
|
302
|
-
} else if (headCount === 0) {
|
|
303
|
-
entry.classification = "resolved";
|
|
304
|
-
} else if (headCount > baseCount) {
|
|
305
|
-
entry.classification = "introduced";
|
|
306
|
-
entry.reason = `occurrence growth: ${baseCount} at base, ${headCount} at head`;
|
|
307
|
-
} else if (headCount < baseCount) {
|
|
308
|
-
entry.classification = "unchanged";
|
|
309
|
-
entry.note =
|
|
310
|
-
`occurrencesReduced: ${baseCount} at base, ${headCount} at head — the site still ` +
|
|
311
|
-
`exists`;
|
|
312
|
-
} else {
|
|
313
|
-
entry.classification = "unchanged";
|
|
314
|
-
}
|
|
284
|
+
Object.assign(entry, occurrenceClassification(baseCount, headCount, "the site"));
|
|
315
285
|
|
|
316
286
|
bucketFor(entry.classification, { introduced, resolved, unchanged }).push(entry);
|
|
317
287
|
}
|
|
@@ -319,6 +289,165 @@ export function classifyUnresolvableRecords({ base, head, sourceProjectOf }) {
|
|
|
319
289
|
return { introduced, resolved, unchanged, unknown };
|
|
320
290
|
}
|
|
321
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Computes a custom finding's identity, or the reason it has none.
|
|
294
|
+
*
|
|
295
|
+
* The key is `["custom", ruleName, findingId, project ?? null]`: which rule,
|
|
296
|
+
* which of its declared findings, against which project — the architectural
|
|
297
|
+
* facts a rule states about a finding. `sourceFile`/`line`/`column` are
|
|
298
|
+
* attached evidence exactly as a violation's sites are, never identity, and a
|
|
299
|
+
* finding that names no project keys on `null` rather than being dropped. A
|
|
300
|
+
* finding with no usable id has nothing to identify it by and is never
|
|
301
|
+
* guessed into a bucket — the same refusal `violationIdentity` makes for a
|
|
302
|
+
* violation with no messageId.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} ruleName The judged rule's declared name.
|
|
305
|
+
* @param {unknown} finding One finding from a rule's verdict document.
|
|
306
|
+
* @returns {{ok: true, key: string, identity: object, site: object}
|
|
307
|
+
* |{ok: false, reason: string, finding: unknown}}
|
|
308
|
+
*/
|
|
309
|
+
function customFindingIdentity(ruleName, finding) {
|
|
310
|
+
if (finding === null || typeof finding !== "object" || Array.isArray(finding)) {
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
reason: `custom rule "${ruleName}" reported a finding that is ${describe(finding)}, not an object`,
|
|
314
|
+
finding,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
const { id, project } = /** @type {Record<string, unknown>} */ (finding);
|
|
318
|
+
if (typeof id !== "string" || id === "") {
|
|
319
|
+
return {
|
|
320
|
+
ok: false,
|
|
321
|
+
reason:
|
|
322
|
+
`custom rule "${ruleName}" reported a finding with no usable id — got ${describe(id)}, ` +
|
|
323
|
+
`and a finding that cannot be named cannot be matched across the two sides`,
|
|
324
|
+
finding,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
const projectName = typeof project === "string" && project !== "" ? project : null;
|
|
328
|
+
const record = /** @type {Record<string, unknown>} */ (finding);
|
|
329
|
+
return {
|
|
330
|
+
ok: true,
|
|
331
|
+
key: JSON.stringify(["custom", ruleName, id, projectName]),
|
|
332
|
+
identity: {
|
|
333
|
+
rule: ruleName,
|
|
334
|
+
findingId: id,
|
|
335
|
+
ruleId: namespacedId(ruleName, id),
|
|
336
|
+
project: projectName,
|
|
337
|
+
message: record.message,
|
|
338
|
+
},
|
|
339
|
+
site: { file: record.sourceFile, line: record.line, column: record.column },
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Classifies the custom-rule findings of a two-sided judgment
|
|
345
|
+
* (`./custom-rules.mjs`'s `customRulesForDelta`).
|
|
346
|
+
*
|
|
347
|
+
* Same identity discipline, same occurrence ladder, same fail-closed unknowns
|
|
348
|
+
* as the two classifiers above — with one deliberate absence: there is NO
|
|
349
|
+
* `waived` annotation. Suppressions key on a `messageId`
|
|
350
|
+
* (`../config.mjs`'s `suppressionCovers`), and a custom finding has none — its
|
|
351
|
+
* id lives in the `custom/<rule>/<finding>` namespace no suppression row can
|
|
352
|
+
* name — so by construction every introduced custom finding gates. Every rule
|
|
353
|
+
* in `unknownRules` becomes one `unknown` entry carrying the rule's reason:
|
|
354
|
+
* a rule that could not be judged is a question this delta could not answer,
|
|
355
|
+
* never a silently thinner report.
|
|
356
|
+
*
|
|
357
|
+
* @param {object} input
|
|
358
|
+
* @param {{name: string, baseFindings: object[], headFindings: object[]}[]}
|
|
359
|
+
* input.judged Rules evaluated on both sides.
|
|
360
|
+
* @param {{name: string, reason: string}[]} [input.unknownRules] Rules that
|
|
361
|
+
* could not be judged, each with its mandatory reason.
|
|
362
|
+
* @returns {{introduced: object[], resolved: object[], unchanged: object[],
|
|
363
|
+
* unknown: object[]}} Classified entries carry `rule`, `findingId`,
|
|
364
|
+
* `ruleId`, `project`, `message`, both sides' counts and sites, and the
|
|
365
|
+
* ladder's optional `reason`/`note`. When a rule produced at least one
|
|
366
|
+
* no-id finding on either side, every classified entry of that rule also
|
|
367
|
+
* carries (or extends) a `note` saying its classification may be incomplete
|
|
368
|
+
* — the no-id finding fell out of the grouping, so a counterpart it should
|
|
369
|
+
* have matched reads introduced or resolved. Unknown entries are
|
|
370
|
+
* `{classification, rule, reason}` plus the offending `finding` where one
|
|
371
|
+
* exists.
|
|
372
|
+
*/
|
|
373
|
+
export function classifyCustomFindings({ judged, unknownRules = [] }) {
|
|
374
|
+
const introduced = [];
|
|
375
|
+
const resolved = [];
|
|
376
|
+
const unchanged = [];
|
|
377
|
+
const unknown = [];
|
|
378
|
+
|
|
379
|
+
for (const rule of unknownRules) {
|
|
380
|
+
unknown.push({ classification: "unknown", rule: rule.name, reason: rule.reason });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
for (const rule of judged) {
|
|
384
|
+
const baseIdentified = rule.baseFindings.map((finding) =>
|
|
385
|
+
customFindingIdentity(rule.name, finding),
|
|
386
|
+
);
|
|
387
|
+
const headIdentified = rule.headFindings.map((finding) =>
|
|
388
|
+
customFindingIdentity(rule.name, finding),
|
|
389
|
+
);
|
|
390
|
+
let namelessCount = 0;
|
|
391
|
+
for (const identified of baseIdentified.concat(headIdentified)) {
|
|
392
|
+
// The same non-strict narrowing constraint as `classifyViolations`' loop.
|
|
393
|
+
if (identified.ok === false) {
|
|
394
|
+
namelessCount += 1;
|
|
395
|
+
unknown.push({
|
|
396
|
+
classification: "unknown",
|
|
397
|
+
rule: rule.name,
|
|
398
|
+
reason: identified.reason,
|
|
399
|
+
finding: identified.finding,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// A no-id finding fell out of the grouping below, so its identical
|
|
404
|
+
// counterpart on the other side — if one exists — reads introduced or
|
|
405
|
+
// resolved with nothing to match against. The unknown entries above keep
|
|
406
|
+
// the run loud (exit 3); this note keeps the CLASSIFIED entries honest,
|
|
407
|
+
// because a reader acting on this rule's buckets is acting on a grouping
|
|
408
|
+
// that may be missing occurrences.
|
|
409
|
+
const incompleteNote =
|
|
410
|
+
namelessCount === 0
|
|
411
|
+
? null
|
|
412
|
+
: `classification for this rule may be incomplete: ${namelessCount} finding` +
|
|
413
|
+
`${namelessCount === 1 ? "" : "s"} had no usable id and could not be matched ` +
|
|
414
|
+
`across the two sides`;
|
|
415
|
+
|
|
416
|
+
const baseGroups = groupBy(baseIdentified);
|
|
417
|
+
const headGroups = groupBy(headIdentified);
|
|
418
|
+
const keys = [...new Set([...baseGroups.keys(), ...headGroups.keys()])].sort(cmpString);
|
|
419
|
+
for (const key of keys) {
|
|
420
|
+
const baseGroup = baseGroups.get(key);
|
|
421
|
+
const headGroup = headGroups.get(key);
|
|
422
|
+
const baseCount = baseGroup ? baseGroup.sites.length : 0;
|
|
423
|
+
const headCount = headGroup ? headGroup.sites.length : 0;
|
|
424
|
+
const identity = (baseGroup ?? headGroup).identity;
|
|
425
|
+
|
|
426
|
+
/** @type {Record<string, unknown>} */
|
|
427
|
+
const entry = {
|
|
428
|
+
classification: "",
|
|
429
|
+
rule: identity.rule,
|
|
430
|
+
findingId: identity.findingId,
|
|
431
|
+
ruleId: identity.ruleId,
|
|
432
|
+
project: identity.project,
|
|
433
|
+
message: identity.message,
|
|
434
|
+
baseCount,
|
|
435
|
+
headCount,
|
|
436
|
+
baseSites: baseGroup ? baseGroup.sites : [],
|
|
437
|
+
headSites: headGroup ? headGroup.sites : [],
|
|
438
|
+
};
|
|
439
|
+
Object.assign(entry, occurrenceClassification(baseCount, headCount, "the finding"));
|
|
440
|
+
if (incompleteNote !== null) {
|
|
441
|
+
entry.note =
|
|
442
|
+
typeof entry.note === "string" ? `${entry.note}; ${incompleteNote}` : incompleteNote;
|
|
443
|
+
}
|
|
444
|
+
bucketFor(entry.classification, { introduced, resolved, unchanged }).push(entry);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return { introduced, resolved, unchanged, unknown };
|
|
449
|
+
}
|
|
450
|
+
|
|
322
451
|
/**
|
|
323
452
|
* Runs both classifications over one pair of evidence sets: the raw violations
|
|
324
453
|
* and the unresolvable-site records, each into its own category.
|
|
@@ -428,6 +557,39 @@ function isUnresolvable(record) {
|
|
|
428
557
|
);
|
|
429
558
|
}
|
|
430
559
|
|
|
560
|
+
/**
|
|
561
|
+
* The occurrence-count ladder every classifier above shares — one statement of
|
|
562
|
+
* the header's per-identity rules, so the three cannot drift on the one
|
|
563
|
+
* decision most likely to be re-litigated (a shrink is NEVER a resolution).
|
|
564
|
+
*
|
|
565
|
+
* @param {number} baseCount
|
|
566
|
+
* @param {number} headCount
|
|
567
|
+
* @param {string} subject What still exists on a shrink, for the note — "the
|
|
568
|
+
* violation", "the site", "the finding".
|
|
569
|
+
* @returns {{classification: "introduced"|"resolved"|"unchanged",
|
|
570
|
+
* reason?: string, note?: string}}
|
|
571
|
+
*/
|
|
572
|
+
function occurrenceClassification(baseCount, headCount, subject) {
|
|
573
|
+
if (baseCount === 0) return { classification: "introduced", reason: "absent at base" };
|
|
574
|
+
if (headCount === 0) return { classification: "resolved" };
|
|
575
|
+
if (headCount > baseCount) {
|
|
576
|
+
return {
|
|
577
|
+
classification: "introduced",
|
|
578
|
+
reason: `occurrence growth: ${baseCount} at base, ${headCount} at head`,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
if (headCount < baseCount) {
|
|
582
|
+
// Still present at head — a shrink is NEVER a resolution.
|
|
583
|
+
return {
|
|
584
|
+
classification: "unchanged",
|
|
585
|
+
note:
|
|
586
|
+
`occurrencesReduced: ${baseCount} at base, ${headCount} at head — ${subject} still ` +
|
|
587
|
+
`exists`,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
return { classification: "unchanged" };
|
|
591
|
+
}
|
|
592
|
+
|
|
431
593
|
/**
|
|
432
594
|
* Folds identified items into groups keyed by identity: occurrences become a
|
|
433
595
|
* multiset of sites, so duplicate import sites in one file count twice.
|
|
@@ -19,6 +19,23 @@
|
|
|
19
19
|
* this file is read back by `parseEvidenceSnapshot` alone, never by the report
|
|
20
20
|
* renderers, and the two formats will evolve on different clocks.
|
|
21
21
|
*
|
|
22
|
+
* ## The two OPTIONAL blocks, and why the version stays 1
|
|
23
|
+
*
|
|
24
|
+
* `customRules` (the declared rows: name, artifact, sha256, params) and
|
|
25
|
+
* `owned` (the workspace's file→project ownership map) are stored only when
|
|
26
|
+
* the capturing policy declares `customRules` — a workspace that declares
|
|
27
|
+
* none produces byte-identical snapshots before and after this addition, and
|
|
28
|
+
* a reader of version 1 that predates the blocks ignores keys it never asks
|
|
29
|
+
* for. Both are evidence in the same sense the records are: the rows are what
|
|
30
|
+
* lets a compare run say whether the custom LAW moved between capture and
|
|
31
|
+
* head (digest or params drift), and `owned` is what lets the base-side
|
|
32
|
+
* evidence bundle attribute each stored record — ownership is the workspace
|
|
33
|
+
* layer's answer (`../workspace.mjs`) and cannot be re-derived from a graph
|
|
34
|
+
* that may have changed since. A baseline WITHOUT the blocks is still legal
|
|
35
|
+
* (an old capture, or one whose policy declared no rules); downstream every
|
|
36
|
+
* custom finding then classifies `unknown` with a re-capture reason rather
|
|
37
|
+
* than the blocks' absence reading as "no custom rules existed at base".
|
|
38
|
+
*
|
|
22
39
|
* ## Purity seam
|
|
23
40
|
*
|
|
24
41
|
* Everything decidable is pure: `buildEvidenceSnapshot` takes already-resolved
|
|
@@ -72,6 +89,14 @@ export const EVIDENCE_SNAPSHOT_SCHEMA_VERSION = 1;
|
|
|
72
89
|
* @param {object[]} input.records The raw import-site records — the analysis
|
|
73
90
|
* envelope's `imports` array verbatim (`../analysis/contract.md`), including
|
|
74
91
|
* the `resolved: null` rows.
|
|
92
|
+
* @param {{name: string, artifact: string, sha256: string,
|
|
93
|
+
* params?: Record<string, any>}[]} [input.customRules] The declared
|
|
94
|
+
* custom-rule rows, when the capturing policy declares any — see the header's
|
|
95
|
+
* optional-blocks section. Omitted means "the capturing policy declared no
|
|
96
|
+
* custom rules", and the snapshot carries no key at all.
|
|
97
|
+
* @param {{file: string, project: string}[]} [input.owned] The ownership map,
|
|
98
|
+
* required exactly when `customRules` is given: the base-side evidence
|
|
99
|
+
* bundle cannot attribute a record without it. Stored sorted by file.
|
|
75
100
|
* @returns {object} The snapshot, ready for `serializeEvidenceSnapshot`.
|
|
76
101
|
* @throws {Error} naming the first piece of required structure that is missing
|
|
77
102
|
* or malformed — a snapshot built over half-specified evidence would fail
|
|
@@ -85,6 +110,8 @@ export function buildEvidenceSnapshot({
|
|
|
85
110
|
coverage,
|
|
86
111
|
graph,
|
|
87
112
|
records,
|
|
113
|
+
customRules,
|
|
114
|
+
owned,
|
|
88
115
|
}) {
|
|
89
116
|
if (!tool || typeof tool.name !== "string" || tool.name === "") {
|
|
90
117
|
throw new Error(
|
|
@@ -147,6 +174,22 @@ export function buildEvidenceSnapshot({
|
|
|
147
174
|
}
|
|
148
175
|
}
|
|
149
176
|
|
|
177
|
+
if (customRules !== undefined) {
|
|
178
|
+
const customProblems = describeCustomRuleBlockProblems(customRules, owned);
|
|
179
|
+
if (customProblems.length > 0) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
"archkeep: cannot build an evidence snapshot — the custom-rule evidence is malformed:\n " +
|
|
182
|
+
customProblems.join("\n "),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
} else if (owned !== undefined) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
"archkeep: cannot build an evidence snapshot with an `owned` map but no `customRules` " +
|
|
188
|
+
"rows — the map exists to attribute the base side of a custom-rule re-judgment, and " +
|
|
189
|
+
"storing it alone would claim custom-rule evidence the snapshot does not hold",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
150
193
|
const projects = buildProjects(graph.nodes).map((project) => {
|
|
151
194
|
// Re-attach the three rule-relevant fields `buildProjects` strips for the
|
|
152
195
|
// public graph contract. Each is attached only when the node DECLARES it —
|
|
@@ -179,7 +222,8 @@ export function buildEvidenceSnapshot({
|
|
|
179
222
|
storedGraph.exemptedFiles = graph.exemptedFiles.slice().sort(cmpString);
|
|
180
223
|
}
|
|
181
224
|
|
|
182
|
-
|
|
225
|
+
/** @type {Record<string, unknown>} */
|
|
226
|
+
const snapshot = {
|
|
183
227
|
schemaVersion: EVIDENCE_SNAPSHOT_SCHEMA_VERSION,
|
|
184
228
|
tool: { name: tool.name, version: tool.version },
|
|
185
229
|
provider,
|
|
@@ -194,6 +238,25 @@ export function buildEvidenceSnapshot({
|
|
|
194
238
|
graph: storedGraph,
|
|
195
239
|
records,
|
|
196
240
|
};
|
|
241
|
+
if (customRules !== undefined) {
|
|
242
|
+
// The declared rows, each reduced to the four fields a compare run reads:
|
|
243
|
+
// identity (name), the pinned law (artifact + sha256), and the parameters
|
|
244
|
+
// that ride inside the evidence bundle — params drift is law drift, the
|
|
245
|
+
// same as digest drift. `reason` and the governance block stay out: they
|
|
246
|
+
// explain the row to a human and change no judgment.
|
|
247
|
+
snapshot.customRules = customRules.map((row) => ({
|
|
248
|
+
name: row.name,
|
|
249
|
+
artifact: row.artifact,
|
|
250
|
+
sha256: row.sha256,
|
|
251
|
+
...(row.params === undefined ? {} : { params: row.params }),
|
|
252
|
+
}));
|
|
253
|
+
// Sorted by file for byte-determinism; `createWorkspace` derives the map
|
|
254
|
+
// from a Set walk whose order is an accident of the file listing.
|
|
255
|
+
snapshot.owned = /** @type {{file: string, project: string}[]} */ (owned)
|
|
256
|
+
.map(({ file, project }) => ({ file, project }))
|
|
257
|
+
.sort((a, b) => cmpString(a.file, b.file));
|
|
258
|
+
}
|
|
259
|
+
return snapshot;
|
|
197
260
|
}
|
|
198
261
|
|
|
199
262
|
/**
|
|
@@ -400,6 +463,18 @@ export function parseEvidenceSnapshot(text, path) {
|
|
|
400
463
|
});
|
|
401
464
|
}
|
|
402
465
|
|
|
466
|
+
// The optional custom-rule pair: absence is a legal old-or-undeclared
|
|
467
|
+
// baseline, presence must be sound — a half-readable block consumed
|
|
468
|
+
// silently would attribute base records against a map that is not one.
|
|
469
|
+
if (parsed.customRules !== undefined) {
|
|
470
|
+
problems.push(...describeCustomRuleBlockProblems(parsed.customRules, parsed.owned));
|
|
471
|
+
} else if (parsed.owned !== undefined) {
|
|
472
|
+
problems.push(
|
|
473
|
+
"owned: present without customRules — the map only exists as custom-rule evidence, and " +
|
|
474
|
+
"half the pair is a snapshot no release ever wrote",
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
403
478
|
if (problems.length > 0) {
|
|
404
479
|
throw new Error(
|
|
405
480
|
`archkeep: the evidence snapshot '${path}' is not a usable baseline:\n ` +
|
|
@@ -487,6 +562,86 @@ function describeCoverageProblems(coverage) {
|
|
|
487
562
|
return problems;
|
|
488
563
|
}
|
|
489
564
|
|
|
565
|
+
/**
|
|
566
|
+
* Everything wrong with the optional custom-rule evidence pair, as messages —
|
|
567
|
+
* shared by capture-time construction and load-time parsing for the same
|
|
568
|
+
* reason `describeCoverageProblems` is: one statement of what the blocks are.
|
|
569
|
+
*
|
|
570
|
+
* Called only when `customRules` is PRESENT: absence is legal (an old
|
|
571
|
+
* baseline, or a policy that declares none) and is judged by the caller. When
|
|
572
|
+
* the rows are present the `owned` map must be too — a base-side re-judgment
|
|
573
|
+
* without attribution would hand every rule evidence it must refuse, and the
|
|
574
|
+
* time to say so is when the snapshot is built or read, not per rule at
|
|
575
|
+
* compare time.
|
|
576
|
+
*
|
|
577
|
+
* @param {unknown} customRules The stored (or to-be-stored) rule rows.
|
|
578
|
+
* @param {unknown} owned The stored (or to-be-stored) ownership map.
|
|
579
|
+
* @returns {string[]} One entry per problem, empty when both are sound.
|
|
580
|
+
*/
|
|
581
|
+
function describeCustomRuleBlockProblems(customRules, owned) {
|
|
582
|
+
const problems = [];
|
|
583
|
+
if (!Array.isArray(customRules)) {
|
|
584
|
+
problems.push(
|
|
585
|
+
`customRules: must be an array of declared rule rows when present, got ` +
|
|
586
|
+
`${describe(customRules)}`,
|
|
587
|
+
);
|
|
588
|
+
} else {
|
|
589
|
+
/** @type {Map<string, number>} */
|
|
590
|
+
const firstIndexOfName = new Map();
|
|
591
|
+
customRules.forEach((row, index) => {
|
|
592
|
+
if (!isPlainObject(row)) {
|
|
593
|
+
problems.push(`customRules[${index}]: must be an object, got ${describe(row)}`);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
for (const field of ["name", "artifact", "sha256"]) {
|
|
597
|
+
if (typeof row[field] !== "string" || row[field] === "") {
|
|
598
|
+
problems.push(`customRules[${index}].${field}: must be a non-empty string`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// A duplicate name is a loud refusal, not a last-one-wins: the compare
|
|
602
|
+
// side keys stored rows by name (`./custom-rules.mjs`'s
|
|
603
|
+
// `customRulesForDelta` builds a Map over them), so a second row under
|
|
604
|
+
// one name would silently shadow the first — and which law the delta
|
|
605
|
+
// then matched against would be an accident of row order.
|
|
606
|
+
if (typeof row.name === "string" && row.name !== "") {
|
|
607
|
+
const first = firstIndexOfName.get(row.name);
|
|
608
|
+
if (first !== undefined) {
|
|
609
|
+
problems.push(
|
|
610
|
+
`customRules[${index}].name: duplicates customRules[${first}].name ("${row.name}") — ` +
|
|
611
|
+
`rule names are the identity a delta matches base rows by, and two rows under one ` +
|
|
612
|
+
`name would silently shadow each other`,
|
|
613
|
+
);
|
|
614
|
+
} else {
|
|
615
|
+
firstIndexOfName.set(row.name, index);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (row.params !== undefined && !isPlainObject(row.params)) {
|
|
619
|
+
problems.push(`customRules[${index}].params: must be a plain object when present`);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
if (!Array.isArray(owned)) {
|
|
624
|
+
problems.push(
|
|
625
|
+
`owned: must be an array of {file, project} rows whenever customRules is stored — the ` +
|
|
626
|
+
`base side of a custom-rule re-judgment cannot attribute a record without it, got ` +
|
|
627
|
+
`${describe(owned)}`,
|
|
628
|
+
);
|
|
629
|
+
} else {
|
|
630
|
+
owned.forEach((row, index) => {
|
|
631
|
+
if (!isPlainObject(row)) {
|
|
632
|
+
problems.push(`owned[${index}]: must be an object, got ${describe(row)}`);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
for (const field of ["file", "project"]) {
|
|
636
|
+
if (typeof row[field] !== "string" || row[field] === "") {
|
|
637
|
+
problems.push(`owned[${index}].${field}: must be a non-empty string`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
return problems;
|
|
643
|
+
}
|
|
644
|
+
|
|
490
645
|
/**
|
|
491
646
|
* The reason a coverage summary cannot serve as a delta BASELINE, or `null`.
|
|
492
647
|
*
|