@ecoma-io/archkeep 0.13.0 → 0.14.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 +1 -1
- package/cli.mjs +158 -2
- package/package.json +1 -1
- package/src/commands/README.md +19 -1
- package/src/commands/check.mjs +82 -16
- package/src/commands/context.mjs +52 -14
- package/src/commands/coverage-acceptance.mjs +113 -0
- package/src/commands/delta-classify.mjs +502 -0
- package/src/commands/delta-snapshot.mjs +517 -0
- package/src/commands/delta.mjs +481 -0
- package/src/commands/explain.mjs +39 -0
- package/src/commands/policy.mjs +36 -1
- package/src/commands/waivers.mjs +53 -3
- package/src/config.mjs +129 -11
- package/src/lsp/boundary-config.mjs +9 -4
- package/src/providers/native/model.mjs +17 -0
- package/src/report/delta-text.mjs +183 -0
- package/src/report/explain-text.mjs +27 -0
- package/src/report/sarif.mjs +25 -0
- package/src/report/text.mjs +36 -0
- package/src/report/waivers-text.mjs +35 -2
|
@@ -0,0 +1,502 @@
|
|
|
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
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Computes a violation's architectural identity.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} violation One raw violation — see the `Violation` typedef in
|
|
74
|
+
* `../rules/index.mjs`.
|
|
75
|
+
* @returns {{ok: true, key: string, identity: {messageId: string,
|
|
76
|
+
* sourceProject: string|null, target: string, targetIsSpecifier: boolean,
|
|
77
|
+
* constraint: object|null}}|{ok: false, reason: string}}
|
|
78
|
+
*/
|
|
79
|
+
export function violationIdentity(violation) {
|
|
80
|
+
if (violation === null || typeof violation !== "object" || Array.isArray(violation)) {
|
|
81
|
+
return { ok: false, reason: `violation is ${describe(violation)}, not an object` };
|
|
82
|
+
}
|
|
83
|
+
const { messageId, sourceProject, targetProject, specifier, constraint } = violation;
|
|
84
|
+
if (typeof messageId !== "string" || messageId === "") {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
reason: `violation carries no usable messageId — got ${describe(messageId)}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const hasTarget = typeof targetProject === "string" && targetProject !== "";
|
|
91
|
+
const hasSpecifier = typeof specifier === "string" && specifier !== "";
|
|
92
|
+
if (!hasTarget && !hasSpecifier) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
reason:
|
|
96
|
+
"violation names neither a target project nor a specifier — there is nothing " +
|
|
97
|
+
"architectural to identify it by",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// The constraint row that fired is part of identity: the same edge may be
|
|
101
|
+
// condemned by different rows, and "which law condemns it" is part of WHAT
|
|
102
|
+
// the violation is. Both sides were judged under ONE config, so identical
|
|
103
|
+
// constraints serialize identically — the canonical form makes the comparison
|
|
104
|
+
// structural rather than referential.
|
|
105
|
+
let constraintCanonical;
|
|
106
|
+
try {
|
|
107
|
+
constraintCanonical = canonicalizeJson(constraint ?? null);
|
|
108
|
+
} catch {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
reason: "the constraint row could not be canonicalized — it is not plain data",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const target = hasTarget
|
|
115
|
+
? /** @type {string} */ (targetProject)
|
|
116
|
+
: /** @type {string} */ (specifier);
|
|
117
|
+
return {
|
|
118
|
+
ok: true,
|
|
119
|
+
key: JSON.stringify([messageId, sourceProject ?? null, target, constraintCanonical]),
|
|
120
|
+
identity: {
|
|
121
|
+
messageId,
|
|
122
|
+
sourceProject: typeof sourceProject === "string" ? sourceProject : null,
|
|
123
|
+
target,
|
|
124
|
+
targetIsSpecifier: !hasTarget,
|
|
125
|
+
constraint: constraint ?? null,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Classifies the RAW violations of two runs judged under one config and one
|
|
132
|
+
* shared `now`.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} input
|
|
135
|
+
* @param {object[]} input.base Raw violations from the base side's re-judgment.
|
|
136
|
+
* @param {object[]} input.head Raw violations from the head side's re-judgment.
|
|
137
|
+
* @param {object[]} [input.suppressions] The CURRENT `boundarySuppressions`
|
|
138
|
+
* table, used only to annotate `waived` — never to filter.
|
|
139
|
+
* @param {string} [input.now] The ONE shared reference instant (ISO-8601) the
|
|
140
|
+
* waiver clock reads; defaults to the shared governance clock.
|
|
141
|
+
* @returns {{introduced: object[], resolved: object[], unchanged: object[],
|
|
142
|
+
* unknown: object[]}} Each known-classification entry carries its identity
|
|
143
|
+
* fields (`messageId`, `sourceProject`, `target`, `targetIsSpecifier`,
|
|
144
|
+
* `constraint`), both sides' counts and attached sites, an optional
|
|
145
|
+
* `reason`/`note`, and the `waived` annotation. Unknown entries carry the
|
|
146
|
+
* original violation plus the `reason` its identity could not be stated.
|
|
147
|
+
*/
|
|
148
|
+
export function classifyViolations({ base, head, suppressions = [], now = referenceTime() }) {
|
|
149
|
+
const baseIdentified = base.map(identityOf);
|
|
150
|
+
const headIdentified = head.map(identityOf);
|
|
151
|
+
|
|
152
|
+
const introduced = [];
|
|
153
|
+
const resolved = [];
|
|
154
|
+
const unchanged = [];
|
|
155
|
+
const unknown = [];
|
|
156
|
+
for (const identified of baseIdentified.concat(headIdentified)) {
|
|
157
|
+
// `ok === false` rather than `!ok`: this package typechecks without
|
|
158
|
+
// strictNullChecks (`tsconfig.json`), where truthiness does not
|
|
159
|
+
// discriminate a literal-boolean union — the equality test does.
|
|
160
|
+
if (identified.ok === false) {
|
|
161
|
+
unknown.push({
|
|
162
|
+
classification: "unknown",
|
|
163
|
+
reason: identified.reason,
|
|
164
|
+
violation: identified.violation,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const baseGroups = groupBy(baseIdentified);
|
|
170
|
+
const headGroups = groupBy(headIdentified);
|
|
171
|
+
const keys = [...new Set([...baseGroups.keys(), ...headGroups.keys()])].sort(cmpString);
|
|
172
|
+
for (const key of keys) {
|
|
173
|
+
const baseGroup = baseGroups.get(key);
|
|
174
|
+
const headGroup = headGroups.get(key);
|
|
175
|
+
const baseCount = baseGroup ? baseGroup.sites.length : 0;
|
|
176
|
+
const headCount = headGroup ? headGroup.sites.length : 0;
|
|
177
|
+
const identity = (baseGroup ?? headGroup).identity;
|
|
178
|
+
|
|
179
|
+
/** @type {Record<string, unknown>} */
|
|
180
|
+
const entry = {
|
|
181
|
+
classification: "",
|
|
182
|
+
messageId: identity.messageId,
|
|
183
|
+
sourceProject: identity.sourceProject,
|
|
184
|
+
target: identity.target,
|
|
185
|
+
targetIsSpecifier: identity.targetIsSpecifier,
|
|
186
|
+
constraint: identity.constraint,
|
|
187
|
+
baseCount,
|
|
188
|
+
headCount,
|
|
189
|
+
baseSites: baseGroup ? baseGroup.sites : [],
|
|
190
|
+
headSites: headGroup ? headGroup.sites : [],
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
if (baseCount === 0) {
|
|
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
|
+
}
|
|
210
|
+
|
|
211
|
+
// A resolved item has no head occurrence left; its waive status is judged
|
|
212
|
+
// against the LAST places the violation existed (its base sites) — what
|
|
213
|
+
// "would the current law cover this if it came back" can honestly ask.
|
|
214
|
+
// Everything else is judged against its live head sites.
|
|
215
|
+
const liveSites = headCount > 0 ? entry.headSites : entry.baseSites;
|
|
216
|
+
Object.assign(entry, waiveAnnotation(liveSites, identity.messageId, suppressions, now));
|
|
217
|
+
|
|
218
|
+
bucketFor(entry.classification, { introduced, resolved, unchanged }).push(entry);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { introduced, resolved, unchanged, unknown };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Classifies UNRESOLVABLE import-site records — records whose analysis could
|
|
226
|
+
* not say where the specifier points (`resolved: null`,
|
|
227
|
+
* `../analysis/contract.md`) — as their OWN delta category.
|
|
228
|
+
*
|
|
229
|
+
* These records are carried through, never dropped and never counted as
|
|
230
|
+
* violations: no rule reached a verdict about them, so folding them into
|
|
231
|
+
* either side's violation counts would fabricate findings. Their identity is
|
|
232
|
+
* necessarily narrower than a violation's — there is no resolved target — so
|
|
233
|
+
* it keys on the specifier (the architectural handle upstream itself uses for
|
|
234
|
+
* unresolved specifiers), the import kind (static vs dynamic is a real
|
|
235
|
+
* distinction), and — when the caller supplies `sourceProjectOf` — the project
|
|
236
|
+
* the record's file belongs to. Without attribution the same specifier in two
|
|
237
|
+
* different projects merges into one identity; supply `sourceProjectOf` where
|
|
238
|
+
* the workspace is known.
|
|
239
|
+
*
|
|
240
|
+
* No suppression annotation here BY CONSTRUCTION: the suppression vocabulary
|
|
241
|
+
* names a path glob and a violation id (`../config.mjs`), and an unresolvable
|
|
242
|
+
* record has no verdict for any table row to cover.
|
|
243
|
+
*
|
|
244
|
+
* @param {object} input
|
|
245
|
+
* @param {object[]} input.base Base-side import-site records.
|
|
246
|
+
* @param {object[]} input.head Head-side import-site records.
|
|
247
|
+
* @param {(record: object) => string|null} [input.sourceProjectOf] Pure
|
|
248
|
+
* resolver attributing a record to its project name; omitted means every
|
|
249
|
+
* record is unattributed (`null`) and identity rests on specifier+kind.
|
|
250
|
+
* @returns {{introduced: object[], resolved: object[], unchanged: object[],
|
|
251
|
+
* unknown: object[]}}
|
|
252
|
+
*/
|
|
253
|
+
export function classifyUnresolvableRecords({ base, head, sourceProjectOf }) {
|
|
254
|
+
const attribute = sourceProjectOf ?? (() => null);
|
|
255
|
+
const baseIdentified = base
|
|
256
|
+
.filter(isUnresolvable)
|
|
257
|
+
.map((record) => recordIdentity(record, attribute));
|
|
258
|
+
const headIdentified = head
|
|
259
|
+
.filter(isUnresolvable)
|
|
260
|
+
.map((record) => recordIdentity(record, attribute));
|
|
261
|
+
|
|
262
|
+
const introduced = [];
|
|
263
|
+
const resolved = [];
|
|
264
|
+
const unchanged = [];
|
|
265
|
+
const unknown = [];
|
|
266
|
+
for (const identified of baseIdentified.concat(headIdentified)) {
|
|
267
|
+
// The same non-strict narrowing constraint as `classifyViolations`' loop.
|
|
268
|
+
if (identified.ok === false) {
|
|
269
|
+
unknown.push({
|
|
270
|
+
classification: "unknown",
|
|
271
|
+
reason: identified.reason,
|
|
272
|
+
record: identified.record,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const baseGroups = groupBy(baseIdentified);
|
|
278
|
+
const headGroups = groupBy(headIdentified);
|
|
279
|
+
const keys = [...new Set([...baseGroups.keys(), ...headGroups.keys()])].sort(cmpString);
|
|
280
|
+
for (const key of keys) {
|
|
281
|
+
const baseGroup = baseGroups.get(key);
|
|
282
|
+
const headGroup = headGroups.get(key);
|
|
283
|
+
const baseCount = baseGroup ? baseGroup.sites.length : 0;
|
|
284
|
+
const headCount = headGroup ? headGroup.sites.length : 0;
|
|
285
|
+
const identity = (baseGroup ?? headGroup).identity;
|
|
286
|
+
|
|
287
|
+
/** @type {Record<string, unknown>} */
|
|
288
|
+
const entry = {
|
|
289
|
+
classification: "",
|
|
290
|
+
specifier: identity.specifier,
|
|
291
|
+
kind: identity.kind,
|
|
292
|
+
sourceProject: identity.sourceProject,
|
|
293
|
+
baseCount,
|
|
294
|
+
headCount,
|
|
295
|
+
baseSites: baseGroup ? baseGroup.sites : [],
|
|
296
|
+
headSites: headGroup ? headGroup.sites : [],
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
if (baseCount === 0) {
|
|
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
|
+
}
|
|
315
|
+
|
|
316
|
+
bucketFor(entry.classification, { introduced, resolved, unchanged }).push(entry);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { introduced, resolved, unchanged, unknown };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Runs both classifications over one pair of evidence sets: the raw violations
|
|
324
|
+
* and the unresolvable-site records, each into its own category.
|
|
325
|
+
*
|
|
326
|
+
* @param {object} input As `classifyViolations` plus
|
|
327
|
+
* `classifyUnresolvableRecords`.
|
|
328
|
+
* @param {object[]} input.baseViolations Raw base-side violations.
|
|
329
|
+
* @param {object[]} input.headViolations Raw head-side violations.
|
|
330
|
+
* @param {object[]} input.baseRecords Base-side import-site records.
|
|
331
|
+
* @param {object[]} input.headRecords Head-side import-site records.
|
|
332
|
+
* @param {object[]} [input.suppressions] The current suppressions table.
|
|
333
|
+
* @param {string} [input.now] The ONE shared reference instant.
|
|
334
|
+
* @param {(record: object) => string|null} [input.sourceProjectOf]
|
|
335
|
+
* @returns {{violations: {introduced: object[], resolved: object[],
|
|
336
|
+
* unchanged: object[], unknown: object[]},
|
|
337
|
+
* unresolvable: {introduced: object[], resolved: object[],
|
|
338
|
+
* unchanged: object[], unknown: object[]}}}
|
|
339
|
+
*/
|
|
340
|
+
export function classifyDelta(input) {
|
|
341
|
+
const {
|
|
342
|
+
baseViolations,
|
|
343
|
+
headViolations,
|
|
344
|
+
baseRecords,
|
|
345
|
+
headRecords,
|
|
346
|
+
suppressions,
|
|
347
|
+
now,
|
|
348
|
+
sourceProjectOf,
|
|
349
|
+
} = input;
|
|
350
|
+
return {
|
|
351
|
+
violations: classifyViolations({
|
|
352
|
+
base: baseViolations,
|
|
353
|
+
head: headViolations,
|
|
354
|
+
...(suppressions === undefined ? {} : { suppressions }),
|
|
355
|
+
...(now === undefined ? {} : { now }),
|
|
356
|
+
}),
|
|
357
|
+
unresolvable: classifyUnresolvableRecords({
|
|
358
|
+
base: baseRecords,
|
|
359
|
+
head: headRecords,
|
|
360
|
+
...(sourceProjectOf === undefined ? {} : { sourceProjectOf }),
|
|
361
|
+
}),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Identity-or-reason wrapper applied to every raw violation. */
|
|
366
|
+
function identityOf(violation) {
|
|
367
|
+
const result = violationIdentity(violation);
|
|
368
|
+
if (result.ok) {
|
|
369
|
+
return {
|
|
370
|
+
...result,
|
|
371
|
+
site: {
|
|
372
|
+
file: violation.sourceFile,
|
|
373
|
+
line: violation.line,
|
|
374
|
+
column: violation.column,
|
|
375
|
+
specifier: violation.specifier,
|
|
376
|
+
kind: violation.kind,
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return { ...result, violation };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Identity-or-reason wrapper applied to every unresolvable record. */
|
|
384
|
+
function recordIdentity(record, attribute) {
|
|
385
|
+
const { specifier, kind } = record;
|
|
386
|
+
if (typeof specifier !== "string" || specifier === "") {
|
|
387
|
+
return {
|
|
388
|
+
ok: false,
|
|
389
|
+
reason: `unresolvable record carries no usable specifier — got ${describe(specifier)}`,
|
|
390
|
+
record,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
let attributed;
|
|
394
|
+
try {
|
|
395
|
+
attributed = attribute(record);
|
|
396
|
+
} catch (cause) {
|
|
397
|
+
return {
|
|
398
|
+
ok: false,
|
|
399
|
+
reason: `attributing the record to its project threw: ${cause?.message ?? cause}`,
|
|
400
|
+
record,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
const normalizedKind = typeof kind === "string" ? kind : "";
|
|
404
|
+
const sourceProject = typeof attributed === "string" ? attributed : null;
|
|
405
|
+
return {
|
|
406
|
+
ok: true,
|
|
407
|
+
key: JSON.stringify(["unresolvable", sourceProject, normalizedKind, specifier]),
|
|
408
|
+
identity: { specifier, kind: normalizedKind, sourceProject },
|
|
409
|
+
site: {
|
|
410
|
+
file: record.sourceFile,
|
|
411
|
+
line: record.line,
|
|
412
|
+
column: record.column,
|
|
413
|
+
specifier,
|
|
414
|
+
kind: normalizedKind,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** A record that did not resolve — including one whose shape broke — is carried. */
|
|
420
|
+
function isUnresolvable(record) {
|
|
421
|
+
return (
|
|
422
|
+
record !== null &&
|
|
423
|
+
typeof record === "object" &&
|
|
424
|
+
!Array.isArray(record) &&
|
|
425
|
+
(record.resolved === null ||
|
|
426
|
+
record.resolved === undefined ||
|
|
427
|
+
typeof record.resolved !== "object")
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Folds identified items into groups keyed by identity: occurrences become a
|
|
433
|
+
* multiset of sites, so duplicate import sites in one file count twice.
|
|
434
|
+
*/
|
|
435
|
+
function groupBy(identifiedItems) {
|
|
436
|
+
/** @type {Map<string, {identity: object, sites: object[]}>} */
|
|
437
|
+
const groups = new Map();
|
|
438
|
+
for (const item of identifiedItems) {
|
|
439
|
+
if (!item.ok) continue;
|
|
440
|
+
const existing = groups.get(item.key);
|
|
441
|
+
if (existing) existing.sites.push(item.site);
|
|
442
|
+
else groups.set(item.key, { identity: item.identity, sites: [item.site] });
|
|
443
|
+
}
|
|
444
|
+
for (const group of groups.values()) {
|
|
445
|
+
group.sites.sort(compareSites);
|
|
446
|
+
}
|
|
447
|
+
return groups;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function compareSites(a, b) {
|
|
451
|
+
if ((a.file ?? "") !== (b.file ?? "")) return cmpString(a.file ?? "", b.file ?? "");
|
|
452
|
+
if ((a.line ?? 0) !== (b.line ?? 0)) return (a.line ?? 0) - (b.line ?? 0);
|
|
453
|
+
if ((a.column ?? 0) !== (b.column ?? 0)) return (a.column ?? 0) - (b.column ?? 0);
|
|
454
|
+
if ((a.specifier ?? "") !== (b.specifier ?? "")) {
|
|
455
|
+
return cmpString(a.specifier ?? "", b.specifier ?? "");
|
|
456
|
+
}
|
|
457
|
+
return cmpString(a.kind ?? "", b.kind ?? "");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Whether the current suppressions table covers EVERY live site at `now` —
|
|
462
|
+
* reusing `suppressionCovers`/`suppressionFate` rather than reimplementing
|
|
463
|
+
* either. First-covering-row semantics match the engine's own annotation walk:
|
|
464
|
+
* the first row covering a site decides that site's fate, and an EXPIRED
|
|
465
|
+
* waiver (fate `reassert`) covers nothing.
|
|
466
|
+
*/
|
|
467
|
+
function waiveAnnotation(sites, messageId, suppressions, now) {
|
|
468
|
+
if (suppressions.length === 0 || sites.length === 0) return { waived: false };
|
|
469
|
+
let waivedBy = null;
|
|
470
|
+
for (const site of sites) {
|
|
471
|
+
const covering = suppressions.find((row) =>
|
|
472
|
+
suppressionCovers(row, { sourceFile: site.file, messageId }),
|
|
473
|
+
);
|
|
474
|
+
if (!covering) return { waived: false };
|
|
475
|
+
if (suppressionFate(covering, now) === "reassert") return { waived: false };
|
|
476
|
+
if (waivedBy === null) waivedBy = covering;
|
|
477
|
+
}
|
|
478
|
+
return { waived: true, waivedBy };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Picks the destination array for a finished classification. */
|
|
482
|
+
function bucketFor(classification, buckets) {
|
|
483
|
+
if (classification === "introduced") return buckets.introduced;
|
|
484
|
+
if (classification === "resolved") return buckets.resolved;
|
|
485
|
+
if (classification === "unchanged") return buckets.unchanged;
|
|
486
|
+
throw new Error(
|
|
487
|
+
`archkeep: classifier produced the classification '${classification}', which is none of ` +
|
|
488
|
+
`introduced | resolved | unchanged — refusing to place it silently`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Plain lexicographic comparison — never localeCompare (byte-determinism). */
|
|
493
|
+
function cmpString(a, b) {
|
|
494
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Describes a value for error messages without dumping it. */
|
|
498
|
+
function describe(value) {
|
|
499
|
+
if (value === null) return "null";
|
|
500
|
+
if (Array.isArray(value)) return "an array";
|
|
501
|
+
return typeof value;
|
|
502
|
+
}
|