@ecoma-io/archkeep 0.14.0 → 0.15.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 +9 -3
- package/cli.mjs +447 -59
- package/commands.mjs +51 -0
- package/package.json +3 -1
- package/src/analysis/typescript.mjs +2 -1
- 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 +40 -2
- 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/trajectory.mjs +437 -0
- package/src/path-util.mjs +40 -0
- 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/tsconfig-paths.mjs +3 -2
|
@@ -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
|
*
|
package/src/commands/delta.mjs
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
* Unlike `diff` — which compares two GRAPH snapshots edge by edge and never
|
|
20
20
|
* exits 1 — `delta` is a gate: a non-waived introduced violation is a finding
|
|
21
21
|
* (exit 1), which is the whole point of carrying re-judgeable evidence rather
|
|
22
|
-
* than a graph. That
|
|
23
|
-
* exit 1, beside `check` and `fitness
|
|
22
|
+
* than a graph. That made `delta` the third verb whose verdict carries
|
|
23
|
+
* exit 1, beside `check` and `fitness`; `./change.mjs` later became the
|
|
24
|
+
* fourth, over a different question — declared intent versus observed delta.
|
|
24
25
|
*
|
|
25
26
|
* Refusals (each a throw, exit 3 upstream — a delta that could not honestly
|
|
26
27
|
* classify must never read as "no change"):
|
|
@@ -48,12 +49,15 @@
|
|
|
48
49
|
import { createRequire } from "node:module";
|
|
49
50
|
|
|
50
51
|
import { isWholeFileFailure } from "../analysis/source-util.mjs";
|
|
52
|
+
import { stripTrailingSlashes } from "../path-util.mjs";
|
|
51
53
|
import { referenceTime } from "../governance/clock.mjs";
|
|
52
54
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
53
55
|
import { buildDecision } from "../report/evidence.mjs";
|
|
54
56
|
import { formatDeltaReport } from "../report/delta-text.mjs";
|
|
57
|
+
import { formatDeltaSarif } from "../report/sarif.mjs";
|
|
55
58
|
import { evaluateRun } from "../rules/index.mjs";
|
|
56
|
-
import {
|
|
59
|
+
import { customRulesForDelta, declaresCustomRules } from "./custom-rules.mjs";
|
|
60
|
+
import { classifyCustomFindings, classifyDelta } from "./delta-classify.mjs";
|
|
57
61
|
import {
|
|
58
62
|
buildEvidenceSnapshot,
|
|
59
63
|
providerMismatch,
|
|
@@ -72,11 +76,17 @@ const { name: TOOL_NAME, version: TOOL_VERSION } = require("../../package.json")
|
|
|
72
76
|
* Refuses the two head states no delta side may be built over, shared by both
|
|
73
77
|
* modes: the unregistered-plugin graph and incomplete analysis coverage.
|
|
74
78
|
*
|
|
79
|
+
* Exported since `change` arrived because that command builds its comparison
|
|
80
|
+
* over the same two head states — a graph that under-represents the tree and
|
|
81
|
+
* an analysis with holes would reconcile a declaration against architecture
|
|
82
|
+
* nobody observed — and a second copy of the refusal is where the two
|
|
83
|
+
* commands would drift into answering "may this head be judged?" differently.
|
|
84
|
+
*
|
|
75
85
|
* @param {object} commandContext From `resolveCommandContext`.
|
|
76
86
|
* @param {string} activity Which mode is refusing, for the message.
|
|
77
87
|
* @throws {Error} on either condition.
|
|
78
88
|
*/
|
|
79
|
-
function refuseUnjudgeableHead(commandContext, activity) {
|
|
89
|
+
export function refuseUnjudgeableHead(commandContext, activity) {
|
|
80
90
|
const { provider, pluginGap } = commandContext;
|
|
81
91
|
if (provider === "nx" && !pluginGap.registered && pluginGap.manifests.length > 0) {
|
|
82
92
|
throw new Error(
|
|
@@ -123,6 +133,12 @@ export function captureDelta(commandContext, { config }) {
|
|
|
123
133
|
}
|
|
124
134
|
const { root, provider, graph, analysis } = commandContext;
|
|
125
135
|
const snapshot = buildEvidenceSnapshot({
|
|
136
|
+
// The two optional custom-rule blocks, stored exactly when the capturing
|
|
137
|
+
// policy declares rules — an undeclaring workspace's snapshot stays
|
|
138
|
+
// byte-identical (`./delta-snapshot.mjs`, the optional-blocks section).
|
|
139
|
+
...(declaresCustomRules(config)
|
|
140
|
+
? { customRules: config.customRules, owned: commandContext.owned }
|
|
141
|
+
: {}),
|
|
126
142
|
tool: { name: TOOL_NAME, version: TOOL_VERSION },
|
|
127
143
|
provenance: resolveProvenance(root),
|
|
128
144
|
provider,
|
|
@@ -223,11 +239,16 @@ export function evidenceGraphToProjectGraph(storedGraph) {
|
|
|
223
239
|
* head does not already claim — so a base-side record living in a directory
|
|
224
240
|
* the head no longer has still attributes to the project that owned it.
|
|
225
241
|
*
|
|
242
|
+
* Exported for `./change.mjs`, which classifies the same two evidence sets
|
|
243
|
+
* through `./delta-classify.mjs` and must attribute unresolvable records the
|
|
244
|
+
* same way a delta does — a second attribution rule beside this one is how
|
|
245
|
+
* the two commands would disagree about which project carried a site.
|
|
246
|
+
*
|
|
226
247
|
* @param {object} headGraph The current run's graph (`nodes` map).
|
|
227
248
|
* @param {object[]} baselineProjects The snapshot's stored project rows.
|
|
228
249
|
* @returns {(record: object) => string|null}
|
|
229
250
|
*/
|
|
230
|
-
function sourceProjectAttributor(headGraph, baselineProjects) {
|
|
251
|
+
export function sourceProjectAttributor(headGraph, baselineProjects) {
|
|
231
252
|
/** @type {Map<string, string>} root → project name, head winning ties. */
|
|
232
253
|
const byRoot = new Map();
|
|
233
254
|
for (const node of Object.values(headGraph.nodes ?? {})) {
|
|
@@ -240,7 +261,7 @@ function sourceProjectAttributor(headGraph, baselineProjects) {
|
|
|
240
261
|
}
|
|
241
262
|
}
|
|
242
263
|
const entries = [...byRoot.entries()]
|
|
243
|
-
.map(([root, name]) => [root
|
|
264
|
+
.map(([root, name]) => [stripTrailingSlashes(root), name])
|
|
244
265
|
.sort((a, b) => b[0].length - a[0].length);
|
|
245
266
|
return (record) => {
|
|
246
267
|
const file = record?.sourceFile;
|
|
@@ -273,20 +294,36 @@ const short = (fingerprint) =>
|
|
|
273
294
|
* a tracked acceptance, not a fix — but do not fail the gate, which is what
|
|
274
295
|
* a waiver is for.
|
|
275
296
|
*
|
|
297
|
+
* Custom-rule (wasm) findings join the classification when either side
|
|
298
|
+
* declares them: `./custom-rules.mjs`'s `customRulesForDelta` judges every
|
|
299
|
+
* head-declared rule over both evidence sets, `./delta-classify.mjs`'s
|
|
300
|
+
* `classifyCustomFindings` buckets the findings, and the result rides the
|
|
301
|
+
* envelope as `result.customRules` — a block that is ABSENT (never `null`)
|
|
302
|
+
* when neither side declares any, so an undeclaring workspace's envelope
|
|
303
|
+
* stays byte-identical. An introduced custom finding gates exactly as an
|
|
304
|
+
* introduced violation does, with no waiver lane by construction
|
|
305
|
+
* (suppressions key on a `messageId` custom findings do not have); an
|
|
306
|
+
* unclassifiable one is a no-verdict. This is also why the function is async:
|
|
307
|
+
* the wasm host is.
|
|
308
|
+
*
|
|
276
309
|
* @param {string} baselinePath Absolute path to the evidence snapshot.
|
|
277
310
|
* @param {object} commandContext From `resolveCommandContext`.
|
|
278
311
|
* @param {{config: object|null, readBaseline?: (path: string) => object,
|
|
279
|
-
* now?: string
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
312
|
+
* now?: string, readArtifact?: (artifact: string) => Uint8Array|null,
|
|
313
|
+
* timeoutMs?: number}} io The resolved boundary config (required
|
|
314
|
+
* — both sides are re-judged under it), an injectable baseline reader, the
|
|
315
|
+
* one shared reference instant (defaults to the shared governance clock),
|
|
316
|
+
* and the custom-rule host's two injectable seams, passed through to
|
|
317
|
+
* `customRulesForDelta`.
|
|
318
|
+
* @returns {Promise<{status: "ok"|"findings"|"no-verdict", delta: object,
|
|
319
|
+
* coverage: object, report: {text: string, json: string, sarif: string}}>}
|
|
320
|
+
* @throws {Error} on every refusal the module header lists, and on a
|
|
321
|
+
* custom-rule LOAD failure (`./custom-rules.mjs` argues the split).
|
|
285
322
|
*/
|
|
286
|
-
export function deltaCommand(
|
|
323
|
+
export async function deltaCommand(
|
|
287
324
|
baselinePath,
|
|
288
325
|
commandContext,
|
|
289
|
-
{ config, readBaseline = readEvidenceSnapshot, now = referenceTime() },
|
|
326
|
+
{ config, readBaseline = readEvidenceSnapshot, now = referenceTime(), ...customRuleIo },
|
|
290
327
|
) {
|
|
291
328
|
const { root, provider, marker, graph, analysis } = commandContext;
|
|
292
329
|
|
|
@@ -378,23 +415,93 @@ export function deltaCommand(
|
|
|
378
415
|
);
|
|
379
416
|
}
|
|
380
417
|
|
|
418
|
+
// The custom-rule half, present exactly when a side declares rules: judged
|
|
419
|
+
// two-sided where the law is identical, `unknown` with a mandatory reason
|
|
420
|
+
// everywhere else (`./custom-rules.mjs`'s `customRulesForDelta` owns the
|
|
421
|
+
// routes). `null` when NEITHER side declares any — the envelope block and
|
|
422
|
+
// the summary key are then absent, and an undeclaring workspace's envelope
|
|
423
|
+
// stays byte-identical (`../../../../AGENTS.md`, "a change to what is
|
|
424
|
+
// reported on an unchanged workspace is a breaking change").
|
|
425
|
+
/** @type {{judged: object[], skipped: object[], removed: string[],
|
|
426
|
+
* findings: {introduced: object[], resolved: object[], unchanged: object[],
|
|
427
|
+
* unknown: object[]}}|null} */
|
|
428
|
+
let custom = null;
|
|
429
|
+
// The head-declared finding catalogue, held for the SARIF face alone: the
|
|
430
|
+
// envelope deliberately does not carry it (the JSON contract predates the
|
|
431
|
+
// SARIF face and must stay byte-identical), while `sarifRules` needs it so
|
|
432
|
+
// an introduced custom finding's `ruleId` resolves to a descriptor.
|
|
433
|
+
/** @type {{ruleId: string, rule: string, findingId: string, message: string}[]} */
|
|
434
|
+
let customCatalogue = [];
|
|
435
|
+
if (declaresCustomRules(config)) {
|
|
436
|
+
const twoSided = await customRulesForDelta(commandContext, {
|
|
437
|
+
rows: config.customRules,
|
|
438
|
+
policy: config,
|
|
439
|
+
baseline,
|
|
440
|
+
...customRuleIo,
|
|
441
|
+
});
|
|
442
|
+
customCatalogue = twoSided.catalogue;
|
|
443
|
+
custom = {
|
|
444
|
+
judged: twoSided.judged.map(({ name, sha256, notes: ruleNotes }) => ({
|
|
445
|
+
name,
|
|
446
|
+
sha256,
|
|
447
|
+
...(ruleNotes === undefined ? {} : { notes: ruleNotes }),
|
|
448
|
+
})),
|
|
449
|
+
skipped: twoSided.unknownRules,
|
|
450
|
+
removed: twoSided.removedRules,
|
|
451
|
+
findings: classifyCustomFindings({
|
|
452
|
+
judged: twoSided.judged,
|
|
453
|
+
unknownRules: twoSided.unknownRules,
|
|
454
|
+
}),
|
|
455
|
+
};
|
|
456
|
+
} else if (baseline.customRules !== undefined) {
|
|
457
|
+
// The head declares nothing, so there is no law to judge either side
|
|
458
|
+
// under — every baseline rule is a removal, disclosed rather than judged.
|
|
459
|
+
custom = {
|
|
460
|
+
judged: [],
|
|
461
|
+
skipped: [],
|
|
462
|
+
removed: baseline.customRules.map((row) => row.name),
|
|
463
|
+
findings: { introduced: [], resolved: [], unchanged: [], unknown: [] },
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (custom !== null) {
|
|
467
|
+
for (const skipped of custom.skipped) {
|
|
468
|
+
notes.push(`custom rule "${skipped.name}" was not classified — ${skipped.reason}`);
|
|
469
|
+
}
|
|
470
|
+
for (const name of custom.removed) {
|
|
471
|
+
notes.push(
|
|
472
|
+
`custom rule "${name}" is declared in the baseline but not by the current policy — ` +
|
|
473
|
+
`nothing was judged for it, so its base-side findings are not classified as resolved`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
for (const rule of custom.judged) {
|
|
477
|
+
for (const note of rule.notes ?? []) {
|
|
478
|
+
notes.push(`custom rule "${rule.name}": ${note}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
381
483
|
const { violations, unresolvable } = classification;
|
|
382
484
|
const introducedWaived = violations.introduced.filter((entry) => entry.waived === true).length;
|
|
383
485
|
const introducedNotWaived = violations.introduced.length - introducedWaived;
|
|
384
|
-
|
|
486
|
+
// Custom findings have no waiver lane (`./delta-classify.mjs`'s
|
|
487
|
+
// `classifyCustomFindings` argues the by-construction absence), so every
|
|
488
|
+
// introduced one gates.
|
|
489
|
+
const customIntroduced = custom === null ? 0 : custom.findings.introduced.length;
|
|
490
|
+
const customUnknown = custom === null ? 0 : custom.findings.unknown.length;
|
|
491
|
+
const unknownCount = violations.unknown.length + unresolvable.unknown.length + customUnknown;
|
|
385
492
|
|
|
386
493
|
/** @type {"ok"|"findings"|"no-verdict"} */
|
|
387
494
|
let status;
|
|
388
495
|
/** @type {0|1|3} */
|
|
389
496
|
let exitCode;
|
|
390
497
|
let decision;
|
|
391
|
-
if (introducedNotWaived > 0) {
|
|
498
|
+
if (introducedNotWaived + customIntroduced > 0) {
|
|
392
499
|
status = "findings";
|
|
393
500
|
exitCode = 1;
|
|
394
501
|
decision = buildDecision({
|
|
395
502
|
status,
|
|
396
503
|
coverageComplete: true,
|
|
397
|
-
findings: introducedNotWaived,
|
|
504
|
+
findings: introducedNotWaived + customIntroduced,
|
|
398
505
|
});
|
|
399
506
|
} else if (unknownCount > 0) {
|
|
400
507
|
status = "no-verdict";
|
|
@@ -405,6 +512,9 @@ export function deltaCommand(
|
|
|
405
512
|
findings: 0,
|
|
406
513
|
reason:
|
|
407
514
|
`${unknownCount} delta item${unknownCount === 1 ? "" : "s"} could not be classified — ` +
|
|
515
|
+
(customUnknown > 0
|
|
516
|
+
? `${customUnknown} of them custom-rule item${customUnknown === 1 ? "" : "s"} — `
|
|
517
|
+
: "") +
|
|
408
518
|
`an item whose identity cannot be stated is never guessed into a bucket`,
|
|
409
519
|
});
|
|
410
520
|
} else {
|
|
@@ -454,9 +564,20 @@ export function deltaCommand(
|
|
|
454
564
|
unchanged: unresolvable.unchanged.length,
|
|
455
565
|
unknown: unresolvable.unknown.length,
|
|
456
566
|
},
|
|
567
|
+
...(custom === null
|
|
568
|
+
? {}
|
|
569
|
+
: {
|
|
570
|
+
customFindings: {
|
|
571
|
+
introduced: custom.findings.introduced.length,
|
|
572
|
+
resolved: custom.findings.resolved.length,
|
|
573
|
+
unchanged: custom.findings.unchanged.length,
|
|
574
|
+
unknown: custom.findings.unknown.length,
|
|
575
|
+
},
|
|
576
|
+
}),
|
|
457
577
|
},
|
|
458
578
|
violations,
|
|
459
579
|
unresolvable,
|
|
580
|
+
...(custom === null ? {} : { customRules: custom }),
|
|
460
581
|
};
|
|
461
582
|
|
|
462
583
|
const envelope = jsonEnvelope({
|
|
@@ -476,6 +597,10 @@ export function deltaCommand(
|
|
|
476
597
|
report: {
|
|
477
598
|
text: formatDeltaReport({ delta: result, coverage }),
|
|
478
599
|
json: renderJson(envelope),
|
|
600
|
+
// Eager beside the other two faces: the render is pure and cheap, and a
|
|
601
|
+
// lazy face is one a caller can forget to build — the SARIF is the same
|
|
602
|
+
// verdict, ready whichever face `--format` selects.
|
|
603
|
+
sarif: formatDeltaSarif({ delta: result, coverage, customCatalogue }),
|
|
479
604
|
},
|
|
480
605
|
};
|
|
481
606
|
}
|
package/src/commands/diff.mjs
CHANGED
|
@@ -216,13 +216,29 @@ function buildHeadSnapshot(commandContext) {
|
|
|
216
216
|
};
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The identity key of one dependency record: the `(source, target, type)`
|
|
221
|
+
* triple joined with `\0`. Extracted from `computeDiff`'s index maps so a
|
|
222
|
+
* second consumer (`./trajectory.mjs`'s persistence sets) keys edges the SAME
|
|
223
|
+
* way the diff does — a second spelling of this string would be a second
|
|
224
|
+
* definition of "same edge", and two definitions drift.
|
|
225
|
+
*
|
|
226
|
+
* A `static` edge becoming `dynamic` is therefore a removed edge under the
|
|
227
|
+
* old type and an added edge under the new one, which is exactly what a
|
|
228
|
+
* consumer wants to see: it is a real architectural event, not an
|
|
229
|
+
* implementation detail.
|
|
230
|
+
*
|
|
231
|
+
* @param {{source: string, target: string, type: string}} edge
|
|
232
|
+
* @returns {string}
|
|
233
|
+
*/
|
|
234
|
+
export function edgeIdentityKey({ source, target, type }) {
|
|
235
|
+
return `${source}\0${target}\0${type}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
219
238
|
/**
|
|
220
239
|
* Computes the diff between two graph snapshots.
|
|
221
240
|
*
|
|
222
|
-
* Edge identity is `(source, target, type)` —
|
|
223
|
-
* `dynamic` is an added edge under the new type and a removed edge under the
|
|
224
|
-
* old one, which is exactly what a consumer wants to see: it is a real
|
|
225
|
-
* architectural event, not an implementation detail.
|
|
241
|
+
* Edge identity is `(source, target, type)` — see `edgeIdentityKey` above.
|
|
226
242
|
*
|
|
227
243
|
* @param {{projects: object[], dependencies: object[]}} baseline
|
|
228
244
|
* @param {{projects: object[], dependencies: object[]}} head
|
|
@@ -282,13 +298,9 @@ export function computeDiff(baseline, head) {
|
|
|
282
298
|
removedProjects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
283
299
|
changedProjects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
284
300
|
|
|
285
|
-
// Index edges by the
|
|
286
|
-
const baselineEdges = new Map(
|
|
287
|
-
|
|
288
|
-
);
|
|
289
|
-
const headEdges = new Map(
|
|
290
|
-
head.dependencies.map((e) => [`${e.source}\0${e.target}\0${e.type}`, e]),
|
|
291
|
-
);
|
|
301
|
+
// Index edges by the identity key — `edgeIdentityKey` above owns the triple.
|
|
302
|
+
const baselineEdges = new Map(baseline.dependencies.map((e) => [edgeIdentityKey(e), e]));
|
|
303
|
+
const headEdges = new Map(head.dependencies.map((e) => [edgeIdentityKey(e), e]));
|
|
292
304
|
|
|
293
305
|
const addedEdges = [];
|
|
294
306
|
const removedEdges = [];
|
|
@@ -410,8 +422,11 @@ export function diffCommand(
|
|
|
410
422
|
// classifies as changes, compared here through the shared
|
|
411
423
|
// `./snapshot-meta.mjs` so the two commands cannot disagree about them.
|
|
412
424
|
// Each mismatch becomes a `coverage.notes` warning rather than a refusal:
|
|
413
|
-
// a provider migration, a cross-repository diff,
|
|
414
|
-
// baseline and head are all legitimate states a
|
|
425
|
+
// a provider migration, a cross-repository diff, a dirty-tree side, or a
|
|
426
|
+
// policy change between baseline and head are all legitimate states a
|
|
427
|
+
// consumer must be told about. The dirty-tree wording is the one `delta` and
|
|
428
|
+
// `change` emit over the same metadata — three commands, one sentence each,
|
|
429
|
+
// so a consumer reading any report reads the same caveat.
|
|
415
430
|
const headProvenance = resolveProvenance(root);
|
|
416
431
|
const headFingerprint = config ? computePolicyFingerprint(config) : null;
|
|
417
432
|
const meta = compareSnapshotMetadata({
|
|
@@ -445,6 +460,19 @@ export function diffCommand(
|
|
|
445
460
|
);
|
|
446
461
|
}
|
|
447
462
|
|
|
463
|
+
if (meta.dirtyBaseline) {
|
|
464
|
+
coverage.notes.push(
|
|
465
|
+
"the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
|
|
466
|
+
"claim about the commit it names",
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if (meta.dirtyHead) {
|
|
470
|
+
coverage.notes.push(
|
|
471
|
+
"this run's working tree is dirty — the head side describes uncommitted state, not the " +
|
|
472
|
+
"commit HEAD names",
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
448
476
|
if (meta.policyChanged === true) {
|
|
449
477
|
// A policy change between baseline and head means every "introduced" or
|
|
450
478
|
// "resolved" violation in the rule-impact analysis may be an artefact of
|