@ecoma-io/archkeep 0.13.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 +599 -55
- package/commands.mjs +51 -0
- package/package.json +3 -1
- package/src/analysis/typescript.mjs +2 -1
- package/src/commands/README.md +70 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +84 -17
- package/src/commands/context.mjs +92 -16
- package/src/commands/coverage-acceptance.mjs +113 -0
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +664 -0
- package/src/commands/delta-snapshot.mjs +672 -0
- package/src/commands/delta.mjs +606 -0
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/explain.mjs +39 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +93 -1
- package/src/commands/trajectory.mjs +437 -0
- package/src/commands/waivers.mjs +53 -3
- package/src/config.mjs +129 -11
- package/src/lsp/boundary-config.mjs +9 -4
- package/src/path-util.mjs +40 -0
- package/src/providers/native/model.mjs +17 -0
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +264 -0
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/explain-text.mjs +27 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +280 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/text.mjs +36 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/report/waivers-text.mjs +35 -2
- package/src/tsconfig-paths.mjs +3 -2
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The delta classifier: given the violations two evidence sets produced when
|
|
3
|
+
* judged under ONE boundary config and ONE shared reference instant, classify
|
|
4
|
+
* each violation `introduced` | `resolved` | `unchanged` | `unknown`.
|
|
5
|
+
*
|
|
6
|
+
* The classifier is pure: it takes violation arrays and record arrays, never
|
|
7
|
+
* files, and no clock beyond the `now` it is handed. Judging happens OUTSIDE —
|
|
8
|
+
* a delta run re-judges both sides' stored evidence through
|
|
9
|
+
* `../rules/index.mjs`'s engine under the current law, then hands this module
|
|
10
|
+
* the two RAW (pre-suppression) violation arrays. Because both sides were
|
|
11
|
+
* judged under one law and one clock, a policy edit or a waiver expiring
|
|
12
|
+
* between base capture and head cannot fabricate an introduced/resolved pair;
|
|
13
|
+
* only the architecture can move a classification.
|
|
14
|
+
*
|
|
15
|
+
* ## Identity is architectural, never textual
|
|
16
|
+
*
|
|
17
|
+
* A violation's identity is `(messageId, sourceProject, targetProject,
|
|
18
|
+
* constraint)` — which rule fired, from which project, against which target,
|
|
19
|
+
* under which declared constraint. Where no project target exists (an external
|
|
20
|
+
* or unresolvable specifier), the raw specifier stands in as the target: five
|
|
21
|
+
* of the fifteen message ids are decided on the specifier itself
|
|
22
|
+
* (`../analysis/contract.md`), so for those the specifier IS the
|
|
23
|
+
* architectural fact, not a spelling detail. `file:line:column` sites are
|
|
24
|
+
* attached evidence on every classified entry and are never part of identity:
|
|
25
|
+
* renaming a file, moving code within one, or adding a second occurrence in
|
|
26
|
+
* another file changes counts and sites, not what the violation IS.
|
|
27
|
+
*
|
|
28
|
+
* Occurrences are counted per side (a multiset, so duplicate sites in one file
|
|
29
|
+
* count twice). Classification per identity:
|
|
30
|
+
*
|
|
31
|
+
* - absent at base, present at head → `introduced`;
|
|
32
|
+
* - present at base, absent at head → `resolved`;
|
|
33
|
+
* - equal counts → `unchanged`;
|
|
34
|
+
* - head count GREATER than base → `introduced` with a `reason` naming the
|
|
35
|
+
* growth — growth is more of a violation that already existed, and the loud
|
|
36
|
+
* direction wins;
|
|
37
|
+
* - head count smaller but still above zero → `unchanged` with an
|
|
38
|
+
* `occurrencesReduced` note. The violation STILL EXISTS; calling that
|
|
39
|
+
* "resolved" would let a partial fix read as a clean boundary.
|
|
40
|
+
* - any violation whose identity cannot be stated at all → `unknown`, with a
|
|
41
|
+
* `reason`. An unidentifiable violation is never guessed into a bucket.
|
|
42
|
+
*
|
|
43
|
+
* ## Renames are not guessed
|
|
44
|
+
*
|
|
45
|
+
* There is deliberately NO rename matching — not of files, not of projects.
|
|
46
|
+
* Renaming a project makes its every violation read as one loud
|
|
47
|
+
* introduced-at-new-name + resolved-at-old-name pair, and a human decides
|
|
48
|
+
* whether that pair is a move. Guessing would silently merge two identities
|
|
49
|
+
* the evidence cannot prove are one, and a wrong guess is invisible in the
|
|
50
|
+
* output by construction — the exact silent direction this tool refuses
|
|
51
|
+
* (`../../../../AGENTS.md`: an empty result is a claim, not a shrug).
|
|
52
|
+
*
|
|
53
|
+
* ## Waiver annotation, not waiver filtering
|
|
54
|
+
*
|
|
55
|
+
* Suppressions NEVER remove an item here — classification compares raw
|
|
56
|
+
* violations precisely so a suppressed-then-regressed violation stays visible.
|
|
57
|
+
* Instead every entry is annotated with whether the CURRENT suppressions table
|
|
58
|
+
* covers it, reusing `../config.mjs`'s `suppressionCovers` and
|
|
59
|
+
* `../governance/waiver.mjs`'s `suppressionFate` at the ONE shared `now` —
|
|
60
|
+
* never a reimplementation. An entry whose every live site is covered by a
|
|
61
|
+
* legacy suppression row or an ACTIVE waiver reads `waived: true`; a site
|
|
62
|
+
* covered only by an EXPIRED waiver reads `waived: false`, because an expired
|
|
63
|
+
* waiver covers nothing (`../governance/waiver.mjs`).
|
|
64
|
+
*/
|
|
65
|
+
import { canonicalizeJson } from "../canonical.mjs";
|
|
66
|
+
import { suppressionCovers } from "../config.mjs";
|
|
67
|
+
import { referenceTime } from "../governance/clock.mjs";
|
|
68
|
+
import { suppressionFate } from "../governance/waiver.mjs";
|
|
69
|
+
import { namespacedId } from "./custom-rules.mjs";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Computes a violation's architectural identity.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} violation One raw violation — see the `Violation` typedef in
|
|
75
|
+
* `../rules/index.mjs`.
|
|
76
|
+
* @returns {{ok: true, key: string, identity: {messageId: string,
|
|
77
|
+
* sourceProject: string|null, target: string, targetIsSpecifier: boolean,
|
|
78
|
+
* constraint: object|null}}|{ok: false, reason: string}}
|
|
79
|
+
*/
|
|
80
|
+
export function violationIdentity(violation) {
|
|
81
|
+
if (violation === null || typeof violation !== "object" || Array.isArray(violation)) {
|
|
82
|
+
return { ok: false, reason: `violation is ${describe(violation)}, not an object` };
|
|
83
|
+
}
|
|
84
|
+
const { messageId, sourceProject, targetProject, specifier, constraint } = violation;
|
|
85
|
+
if (typeof messageId !== "string" || messageId === "") {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: `violation carries no usable messageId — got ${describe(messageId)}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const hasTarget = typeof targetProject === "string" && targetProject !== "";
|
|
92
|
+
const hasSpecifier = typeof specifier === "string" && specifier !== "";
|
|
93
|
+
if (!hasTarget && !hasSpecifier) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
reason:
|
|
97
|
+
"violation names neither a target project nor a specifier — there is nothing " +
|
|
98
|
+
"architectural to identify it by",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// The constraint row that fired is part of identity: the same edge may be
|
|
102
|
+
// condemned by different rows, and "which law condemns it" is part of WHAT
|
|
103
|
+
// the violation is. Both sides were judged under ONE config, so identical
|
|
104
|
+
// constraints serialize identically — the canonical form makes the comparison
|
|
105
|
+
// structural rather than referential.
|
|
106
|
+
let constraintCanonical;
|
|
107
|
+
try {
|
|
108
|
+
constraintCanonical = canonicalizeJson(constraint ?? null);
|
|
109
|
+
} catch {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
reason: "the constraint row could not be canonicalized — it is not plain data",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const target = hasTarget
|
|
116
|
+
? /** @type {string} */ (targetProject)
|
|
117
|
+
: /** @type {string} */ (specifier);
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
key: JSON.stringify([messageId, sourceProject ?? null, target, constraintCanonical]),
|
|
121
|
+
identity: {
|
|
122
|
+
messageId,
|
|
123
|
+
sourceProject: typeof sourceProject === "string" ? sourceProject : null,
|
|
124
|
+
target,
|
|
125
|
+
targetIsSpecifier: !hasTarget,
|
|
126
|
+
constraint: constraint ?? null,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Classifies the RAW violations of two runs judged under one config and one
|
|
133
|
+
* shared `now`.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} input
|
|
136
|
+
* @param {object[]} input.base Raw violations from the base side's re-judgment.
|
|
137
|
+
* @param {object[]} input.head Raw violations from the head side's re-judgment.
|
|
138
|
+
* @param {object[]} [input.suppressions] The CURRENT `boundarySuppressions`
|
|
139
|
+
* table, used only to annotate `waived` — never to filter.
|
|
140
|
+
* @param {string} [input.now] The ONE shared reference instant (ISO-8601) the
|
|
141
|
+
* waiver clock reads; defaults to the shared governance clock.
|
|
142
|
+
* @returns {{introduced: object[], resolved: object[], unchanged: object[],
|
|
143
|
+
* unknown: object[]}} Each known-classification entry carries its identity
|
|
144
|
+
* fields (`messageId`, `sourceProject`, `target`, `targetIsSpecifier`,
|
|
145
|
+
* `constraint`), both sides' counts and attached sites, an optional
|
|
146
|
+
* `reason`/`note`, and the `waived` annotation. Unknown entries carry the
|
|
147
|
+
* original violation plus the `reason` its identity could not be stated.
|
|
148
|
+
*/
|
|
149
|
+
export function classifyViolations({ base, head, suppressions = [], now = referenceTime() }) {
|
|
150
|
+
const baseIdentified = base.map(identityOf);
|
|
151
|
+
const headIdentified = head.map(identityOf);
|
|
152
|
+
|
|
153
|
+
const introduced = [];
|
|
154
|
+
const resolved = [];
|
|
155
|
+
const unchanged = [];
|
|
156
|
+
const unknown = [];
|
|
157
|
+
for (const identified of baseIdentified.concat(headIdentified)) {
|
|
158
|
+
// `ok === false` rather than `!ok`: this package typechecks without
|
|
159
|
+
// strictNullChecks (`tsconfig.json`), where truthiness does not
|
|
160
|
+
// discriminate a literal-boolean union — the equality test does.
|
|
161
|
+
if (identified.ok === false) {
|
|
162
|
+
unknown.push({
|
|
163
|
+
classification: "unknown",
|
|
164
|
+
reason: identified.reason,
|
|
165
|
+
violation: identified.violation,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const baseGroups = groupBy(baseIdentified);
|
|
171
|
+
const headGroups = groupBy(headIdentified);
|
|
172
|
+
const keys = [...new Set([...baseGroups.keys(), ...headGroups.keys()])].sort(cmpString);
|
|
173
|
+
for (const key of keys) {
|
|
174
|
+
const baseGroup = baseGroups.get(key);
|
|
175
|
+
const headGroup = headGroups.get(key);
|
|
176
|
+
const baseCount = baseGroup ? baseGroup.sites.length : 0;
|
|
177
|
+
const headCount = headGroup ? headGroup.sites.length : 0;
|
|
178
|
+
const identity = (baseGroup ?? headGroup).identity;
|
|
179
|
+
|
|
180
|
+
/** @type {Record<string, unknown>} */
|
|
181
|
+
const entry = {
|
|
182
|
+
classification: "",
|
|
183
|
+
messageId: identity.messageId,
|
|
184
|
+
sourceProject: identity.sourceProject,
|
|
185
|
+
target: identity.target,
|
|
186
|
+
targetIsSpecifier: identity.targetIsSpecifier,
|
|
187
|
+
constraint: identity.constraint,
|
|
188
|
+
baseCount,
|
|
189
|
+
headCount,
|
|
190
|
+
baseSites: baseGroup ? baseGroup.sites : [],
|
|
191
|
+
headSites: headGroup ? headGroup.sites : [],
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
Object.assign(entry, occurrenceClassification(baseCount, headCount, "the violation"));
|
|
195
|
+
|
|
196
|
+
// A resolved item has no head occurrence left; its waive status is judged
|
|
197
|
+
// against the LAST places the violation existed (its base sites) — what
|
|
198
|
+
// "would the current law cover this if it came back" can honestly ask.
|
|
199
|
+
// Everything else is judged against its live head sites.
|
|
200
|
+
const liveSites = headCount > 0 ? entry.headSites : entry.baseSites;
|
|
201
|
+
Object.assign(entry, waiveAnnotation(liveSites, identity.messageId, suppressions, now));
|
|
202
|
+
|
|
203
|
+
bucketFor(entry.classification, { introduced, resolved, unchanged }).push(entry);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { introduced, resolved, unchanged, unknown };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Classifies UNRESOLVABLE import-site records — records whose analysis could
|
|
211
|
+
* not say where the specifier points (`resolved: null`,
|
|
212
|
+
* `../analysis/contract.md`) — as their OWN delta category.
|
|
213
|
+
*
|
|
214
|
+
* These records are carried through, never dropped and never counted as
|
|
215
|
+
* violations: no rule reached a verdict about them, so folding them into
|
|
216
|
+
* either side's violation counts would fabricate findings. Their identity is
|
|
217
|
+
* necessarily narrower than a violation's — there is no resolved target — so
|
|
218
|
+
* it keys on the specifier (the architectural handle upstream itself uses for
|
|
219
|
+
* unresolved specifiers), the import kind (static vs dynamic is a real
|
|
220
|
+
* distinction), and — when the caller supplies `sourceProjectOf` — the project
|
|
221
|
+
* the record's file belongs to. Without attribution the same specifier in two
|
|
222
|
+
* different projects merges into one identity; supply `sourceProjectOf` where
|
|
223
|
+
* the workspace is known.
|
|
224
|
+
*
|
|
225
|
+
* No suppression annotation here BY CONSTRUCTION: the suppression vocabulary
|
|
226
|
+
* names a path glob and a violation id (`../config.mjs`), and an unresolvable
|
|
227
|
+
* record has no verdict for any table row to cover.
|
|
228
|
+
*
|
|
229
|
+
* @param {object} input
|
|
230
|
+
* @param {object[]} input.base Base-side import-site records.
|
|
231
|
+
* @param {object[]} input.head Head-side import-site records.
|
|
232
|
+
* @param {(record: object) => string|null} [input.sourceProjectOf] Pure
|
|
233
|
+
* resolver attributing a record to its project name; omitted means every
|
|
234
|
+
* record is unattributed (`null`) and identity rests on specifier+kind.
|
|
235
|
+
* @returns {{introduced: object[], resolved: object[], unchanged: object[],
|
|
236
|
+
* unknown: object[]}}
|
|
237
|
+
*/
|
|
238
|
+
export function classifyUnresolvableRecords({ base, head, sourceProjectOf }) {
|
|
239
|
+
const attribute = sourceProjectOf ?? (() => null);
|
|
240
|
+
const baseIdentified = base
|
|
241
|
+
.filter(isUnresolvable)
|
|
242
|
+
.map((record) => recordIdentity(record, attribute));
|
|
243
|
+
const headIdentified = head
|
|
244
|
+
.filter(isUnresolvable)
|
|
245
|
+
.map((record) => recordIdentity(record, attribute));
|
|
246
|
+
|
|
247
|
+
const introduced = [];
|
|
248
|
+
const resolved = [];
|
|
249
|
+
const unchanged = [];
|
|
250
|
+
const unknown = [];
|
|
251
|
+
for (const identified of baseIdentified.concat(headIdentified)) {
|
|
252
|
+
// The same non-strict narrowing constraint as `classifyViolations`' loop.
|
|
253
|
+
if (identified.ok === false) {
|
|
254
|
+
unknown.push({
|
|
255
|
+
classification: "unknown",
|
|
256
|
+
reason: identified.reason,
|
|
257
|
+
record: identified.record,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const baseGroups = groupBy(baseIdentified);
|
|
263
|
+
const headGroups = groupBy(headIdentified);
|
|
264
|
+
const keys = [...new Set([...baseGroups.keys(), ...headGroups.keys()])].sort(cmpString);
|
|
265
|
+
for (const key of keys) {
|
|
266
|
+
const baseGroup = baseGroups.get(key);
|
|
267
|
+
const headGroup = headGroups.get(key);
|
|
268
|
+
const baseCount = baseGroup ? baseGroup.sites.length : 0;
|
|
269
|
+
const headCount = headGroup ? headGroup.sites.length : 0;
|
|
270
|
+
const identity = (baseGroup ?? headGroup).identity;
|
|
271
|
+
|
|
272
|
+
/** @type {Record<string, unknown>} */
|
|
273
|
+
const entry = {
|
|
274
|
+
classification: "",
|
|
275
|
+
specifier: identity.specifier,
|
|
276
|
+
kind: identity.kind,
|
|
277
|
+
sourceProject: identity.sourceProject,
|
|
278
|
+
baseCount,
|
|
279
|
+
headCount,
|
|
280
|
+
baseSites: baseGroup ? baseGroup.sites : [],
|
|
281
|
+
headSites: headGroup ? headGroup.sites : [],
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
Object.assign(entry, occurrenceClassification(baseCount, headCount, "the site"));
|
|
285
|
+
|
|
286
|
+
bucketFor(entry.classification, { introduced, resolved, unchanged }).push(entry);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { introduced, resolved, unchanged, unknown };
|
|
290
|
+
}
|
|
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
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Runs both classifications over one pair of evidence sets: the raw violations
|
|
453
|
+
* and the unresolvable-site records, each into its own category.
|
|
454
|
+
*
|
|
455
|
+
* @param {object} input As `classifyViolations` plus
|
|
456
|
+
* `classifyUnresolvableRecords`.
|
|
457
|
+
* @param {object[]} input.baseViolations Raw base-side violations.
|
|
458
|
+
* @param {object[]} input.headViolations Raw head-side violations.
|
|
459
|
+
* @param {object[]} input.baseRecords Base-side import-site records.
|
|
460
|
+
* @param {object[]} input.headRecords Head-side import-site records.
|
|
461
|
+
* @param {object[]} [input.suppressions] The current suppressions table.
|
|
462
|
+
* @param {string} [input.now] The ONE shared reference instant.
|
|
463
|
+
* @param {(record: object) => string|null} [input.sourceProjectOf]
|
|
464
|
+
* @returns {{violations: {introduced: object[], resolved: object[],
|
|
465
|
+
* unchanged: object[], unknown: object[]},
|
|
466
|
+
* unresolvable: {introduced: object[], resolved: object[],
|
|
467
|
+
* unchanged: object[], unknown: object[]}}}
|
|
468
|
+
*/
|
|
469
|
+
export function classifyDelta(input) {
|
|
470
|
+
const {
|
|
471
|
+
baseViolations,
|
|
472
|
+
headViolations,
|
|
473
|
+
baseRecords,
|
|
474
|
+
headRecords,
|
|
475
|
+
suppressions,
|
|
476
|
+
now,
|
|
477
|
+
sourceProjectOf,
|
|
478
|
+
} = input;
|
|
479
|
+
return {
|
|
480
|
+
violations: classifyViolations({
|
|
481
|
+
base: baseViolations,
|
|
482
|
+
head: headViolations,
|
|
483
|
+
...(suppressions === undefined ? {} : { suppressions }),
|
|
484
|
+
...(now === undefined ? {} : { now }),
|
|
485
|
+
}),
|
|
486
|
+
unresolvable: classifyUnresolvableRecords({
|
|
487
|
+
base: baseRecords,
|
|
488
|
+
head: headRecords,
|
|
489
|
+
...(sourceProjectOf === undefined ? {} : { sourceProjectOf }),
|
|
490
|
+
}),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Identity-or-reason wrapper applied to every raw violation. */
|
|
495
|
+
function identityOf(violation) {
|
|
496
|
+
const result = violationIdentity(violation);
|
|
497
|
+
if (result.ok) {
|
|
498
|
+
return {
|
|
499
|
+
...result,
|
|
500
|
+
site: {
|
|
501
|
+
file: violation.sourceFile,
|
|
502
|
+
line: violation.line,
|
|
503
|
+
column: violation.column,
|
|
504
|
+
specifier: violation.specifier,
|
|
505
|
+
kind: violation.kind,
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
return { ...result, violation };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Identity-or-reason wrapper applied to every unresolvable record. */
|
|
513
|
+
function recordIdentity(record, attribute) {
|
|
514
|
+
const { specifier, kind } = record;
|
|
515
|
+
if (typeof specifier !== "string" || specifier === "") {
|
|
516
|
+
return {
|
|
517
|
+
ok: false,
|
|
518
|
+
reason: `unresolvable record carries no usable specifier — got ${describe(specifier)}`,
|
|
519
|
+
record,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
let attributed;
|
|
523
|
+
try {
|
|
524
|
+
attributed = attribute(record);
|
|
525
|
+
} catch (cause) {
|
|
526
|
+
return {
|
|
527
|
+
ok: false,
|
|
528
|
+
reason: `attributing the record to its project threw: ${cause?.message ?? cause}`,
|
|
529
|
+
record,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
const normalizedKind = typeof kind === "string" ? kind : "";
|
|
533
|
+
const sourceProject = typeof attributed === "string" ? attributed : null;
|
|
534
|
+
return {
|
|
535
|
+
ok: true,
|
|
536
|
+
key: JSON.stringify(["unresolvable", sourceProject, normalizedKind, specifier]),
|
|
537
|
+
identity: { specifier, kind: normalizedKind, sourceProject },
|
|
538
|
+
site: {
|
|
539
|
+
file: record.sourceFile,
|
|
540
|
+
line: record.line,
|
|
541
|
+
column: record.column,
|
|
542
|
+
specifier,
|
|
543
|
+
kind: normalizedKind,
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** A record that did not resolve — including one whose shape broke — is carried. */
|
|
549
|
+
function isUnresolvable(record) {
|
|
550
|
+
return (
|
|
551
|
+
record !== null &&
|
|
552
|
+
typeof record === "object" &&
|
|
553
|
+
!Array.isArray(record) &&
|
|
554
|
+
(record.resolved === null ||
|
|
555
|
+
record.resolved === undefined ||
|
|
556
|
+
typeof record.resolved !== "object")
|
|
557
|
+
);
|
|
558
|
+
}
|
|
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
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Folds identified items into groups keyed by identity: occurrences become a
|
|
595
|
+
* multiset of sites, so duplicate import sites in one file count twice.
|
|
596
|
+
*/
|
|
597
|
+
function groupBy(identifiedItems) {
|
|
598
|
+
/** @type {Map<string, {identity: object, sites: object[]}>} */
|
|
599
|
+
const groups = new Map();
|
|
600
|
+
for (const item of identifiedItems) {
|
|
601
|
+
if (!item.ok) continue;
|
|
602
|
+
const existing = groups.get(item.key);
|
|
603
|
+
if (existing) existing.sites.push(item.site);
|
|
604
|
+
else groups.set(item.key, { identity: item.identity, sites: [item.site] });
|
|
605
|
+
}
|
|
606
|
+
for (const group of groups.values()) {
|
|
607
|
+
group.sites.sort(compareSites);
|
|
608
|
+
}
|
|
609
|
+
return groups;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function compareSites(a, b) {
|
|
613
|
+
if ((a.file ?? "") !== (b.file ?? "")) return cmpString(a.file ?? "", b.file ?? "");
|
|
614
|
+
if ((a.line ?? 0) !== (b.line ?? 0)) return (a.line ?? 0) - (b.line ?? 0);
|
|
615
|
+
if ((a.column ?? 0) !== (b.column ?? 0)) return (a.column ?? 0) - (b.column ?? 0);
|
|
616
|
+
if ((a.specifier ?? "") !== (b.specifier ?? "")) {
|
|
617
|
+
return cmpString(a.specifier ?? "", b.specifier ?? "");
|
|
618
|
+
}
|
|
619
|
+
return cmpString(a.kind ?? "", b.kind ?? "");
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Whether the current suppressions table covers EVERY live site at `now` —
|
|
624
|
+
* reusing `suppressionCovers`/`suppressionFate` rather than reimplementing
|
|
625
|
+
* either. First-covering-row semantics match the engine's own annotation walk:
|
|
626
|
+
* the first row covering a site decides that site's fate, and an EXPIRED
|
|
627
|
+
* waiver (fate `reassert`) covers nothing.
|
|
628
|
+
*/
|
|
629
|
+
function waiveAnnotation(sites, messageId, suppressions, now) {
|
|
630
|
+
if (suppressions.length === 0 || sites.length === 0) return { waived: false };
|
|
631
|
+
let waivedBy = null;
|
|
632
|
+
for (const site of sites) {
|
|
633
|
+
const covering = suppressions.find((row) =>
|
|
634
|
+
suppressionCovers(row, { sourceFile: site.file, messageId }),
|
|
635
|
+
);
|
|
636
|
+
if (!covering) return { waived: false };
|
|
637
|
+
if (suppressionFate(covering, now) === "reassert") return { waived: false };
|
|
638
|
+
if (waivedBy === null) waivedBy = covering;
|
|
639
|
+
}
|
|
640
|
+
return { waived: true, waivedBy };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Picks the destination array for a finished classification. */
|
|
644
|
+
function bucketFor(classification, buckets) {
|
|
645
|
+
if (classification === "introduced") return buckets.introduced;
|
|
646
|
+
if (classification === "resolved") return buckets.resolved;
|
|
647
|
+
if (classification === "unchanged") return buckets.unchanged;
|
|
648
|
+
throw new Error(
|
|
649
|
+
`archkeep: classifier produced the classification '${classification}', which is none of ` +
|
|
650
|
+
`introduced | resolved | unchanged — refusing to place it silently`,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Plain lexicographic comparison — never localeCompare (byte-determinism). */
|
|
655
|
+
function cmpString(a, b) {
|
|
656
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Describes a value for error messages without dumping it. */
|
|
660
|
+
function describe(value) {
|
|
661
|
+
if (value === null) return "null";
|
|
662
|
+
if (Array.isArray(value)) return "an array";
|
|
663
|
+
return typeof value;
|
|
664
|
+
}
|