@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.
Files changed (37) hide show
  1. package/README.md +9 -3
  2. package/cli.mjs +599 -55
  3. package/commands.mjs +51 -0
  4. package/package.json +3 -1
  5. package/src/analysis/typescript.mjs +2 -1
  6. package/src/commands/README.md +70 -1
  7. package/src/commands/change-intent.mjs +461 -0
  8. package/src/commands/change.mjs +612 -0
  9. package/src/commands/check.mjs +84 -17
  10. package/src/commands/context.mjs +92 -16
  11. package/src/commands/coverage-acceptance.mjs +113 -0
  12. package/src/commands/custom-rules.mjs +286 -2
  13. package/src/commands/delta-classify.mjs +664 -0
  14. package/src/commands/delta-snapshot.mjs +672 -0
  15. package/src/commands/delta.mjs +606 -0
  16. package/src/commands/diff.mjs +41 -13
  17. package/src/commands/evolution.mjs +473 -0
  18. package/src/commands/explain.mjs +39 -0
  19. package/src/commands/history.mjs +130 -103
  20. package/src/commands/policy.mjs +93 -1
  21. package/src/commands/trajectory.mjs +437 -0
  22. package/src/commands/waivers.mjs +53 -3
  23. package/src/config.mjs +129 -11
  24. package/src/lsp/boundary-config.mjs +9 -4
  25. package/src/path-util.mjs +40 -0
  26. package/src/providers/native/model.mjs +17 -0
  27. package/src/report/change-text.mjs +148 -0
  28. package/src/report/delta-text.mjs +264 -0
  29. package/src/report/evolution-text.mjs +83 -0
  30. package/src/report/explain-text.mjs +27 -0
  31. package/src/report/history-text.mjs +4 -114
  32. package/src/report/sarif.mjs +280 -0
  33. package/src/report/snapshot-text.mjs +123 -0
  34. package/src/report/text.mjs +36 -0
  35. package/src/report/trajectory-text.mjs +143 -0
  36. package/src/report/waivers-text.mjs +35 -2
  37. 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
+ }