@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.
@@ -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
+ }