@ecoma-io/archkeep 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/cli.mjs +447 -59
- package/commands.mjs +51 -0
- package/package.json +3 -1
- package/src/analysis/typescript.mjs +2 -1
- package/src/commands/README.md +52 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +2 -1
- package/src/commands/context.mjs +40 -2
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +195 -33
- package/src/commands/delta-snapshot.mjs +156 -1
- package/src/commands/delta.mjs +142 -17
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +57 -0
- package/src/commands/trajectory.mjs +437 -0
- package/src/path-util.mjs +40 -0
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +82 -1
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +255 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/tsconfig-paths.mjs +3 -2
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
import { readFileSync } from "node:fs";
|
|
74
74
|
import { join } from "node:path";
|
|
75
75
|
|
|
76
|
+
import { canonicalizeJson } from "../canonical.mjs";
|
|
76
77
|
import { containmentViolation } from "../containment.mjs";
|
|
77
78
|
import { buildEvidenceBundle, serializeEvidenceBundle } from "../custom-rules/evidence.mjs";
|
|
78
79
|
import {
|
|
@@ -92,13 +93,14 @@ import { fitnessVerdict } from "../governance/verdict.mjs";
|
|
|
92
93
|
* `custom/<ruleName>/<findingId>`. Built here, once, and carried on the
|
|
93
94
|
* records this module hands back — so `../report/text.mjs`, `../report/sarif.mjs`
|
|
94
95
|
* and `../../cli.mjs`'s JSON envelope render an id rather than compose one,
|
|
95
|
-
* and three faces cannot come to spell the same finding three ways.
|
|
96
|
+
* and three faces cannot come to spell the same finding three ways. Exported
|
|
97
|
+
* for `./delta-classify.mjs`, whose classified entries name the same id.
|
|
96
98
|
*
|
|
97
99
|
* @param {string} ruleName
|
|
98
100
|
* @param {string} findingId
|
|
99
101
|
* @returns {string}
|
|
100
102
|
*/
|
|
101
|
-
function namespacedId(ruleName, findingId) {
|
|
103
|
+
export function namespacedId(ruleName, findingId) {
|
|
102
104
|
return `custom/${ruleName}/${findingId}`;
|
|
103
105
|
}
|
|
104
106
|
|
|
@@ -426,3 +428,285 @@ export async function customRulesForCheck(
|
|
|
426
428
|
|
|
427
429
|
return { decisions, overall: fitnessVerdictFor(decisions), catalogue, evidence };
|
|
428
430
|
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The base side's evidence facts, rebuilt from a validated snapshot.
|
|
434
|
+
*
|
|
435
|
+
* The mirror of `observedFacts`, over stored evidence: projects and edges come
|
|
436
|
+
* from the snapshot's `graph` section (already the normalized rows
|
|
437
|
+
* `buildProjects`/`buildDependencies` wrote), imports from the stored records
|
|
438
|
+
* attributed through the stored `owned` map. Attribution is NOT re-derived
|
|
439
|
+
* from root prefixes — ownership is the workspace layer's answer and the base
|
|
440
|
+
* workspace no longer exists to ask, which is exactly why the snapshot stores
|
|
441
|
+
* the map (`./delta-snapshot.mjs`, the optional-blocks section). A record the
|
|
442
|
+
* stored map does not claim keeps `sourceProject: undefined`, and
|
|
443
|
+
* `buildEvidenceBundle` refuses it by name — the caller routes that refusal to
|
|
444
|
+
* an `unknown` rule rather than a silently thinner evidence set.
|
|
445
|
+
*
|
|
446
|
+
* @param {{graph: {projects: object[], dependencies: object[]},
|
|
447
|
+
* records: object[], owned?: {file: string, project: string}[]}} baseline
|
|
448
|
+
* A validated snapshot (`parseEvidenceSnapshot`).
|
|
449
|
+
* @param {object} policy The CURRENT loaded boundary policy — both sides are
|
|
450
|
+
* judged under one law, the same bargain the boundary re-judgment strikes.
|
|
451
|
+
* @returns {{projects: object[], edges: object[], imports: object[], policy: object}}
|
|
452
|
+
*/
|
|
453
|
+
function baselineFacts(baseline, policy) {
|
|
454
|
+
const projectOfFile = new Map((baseline.owned ?? []).map(({ file, project }) => [file, project]));
|
|
455
|
+
return {
|
|
456
|
+
projects: baseline.graph.projects.map((project) => ({
|
|
457
|
+
name: project.name,
|
|
458
|
+
root: project.root,
|
|
459
|
+
tags: Array.isArray(project.tags) ? project.tags : [],
|
|
460
|
+
})),
|
|
461
|
+
edges: baseline.graph.dependencies,
|
|
462
|
+
imports: baseline.records.map((site) => ({
|
|
463
|
+
site,
|
|
464
|
+
sourceProject: projectOfFile.get(site.sourceFile),
|
|
465
|
+
})),
|
|
466
|
+
policy,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The reason a declared head row cannot be judged on both sides, or `null`
|
|
472
|
+
* when the baseline row pins the identical law.
|
|
473
|
+
*
|
|
474
|
+
* Digest drift and params drift are the same refusal: the artifact bytes and
|
|
475
|
+
* the declared parameters together ARE the rule's law (params ride inside the
|
|
476
|
+
* evidence bundle), and a finding difference under a law that itself moved
|
|
477
|
+
* cannot be attributed to the code — the same reasoning the policy-fingerprint
|
|
478
|
+
* note states for the boundary side, but per rule and fail-closed to
|
|
479
|
+
* `unknown` because unlike the boundary law the OLD custom law cannot be
|
|
480
|
+
* re-applied: only the head artifact exists to run.
|
|
481
|
+
*
|
|
482
|
+
* @param {{name: string, sha256: string, params?: object}} row Head-declared.
|
|
483
|
+
* @param {{sha256: string, params?: object}|undefined} baseRow The stored row.
|
|
484
|
+
* @returns {string|null}
|
|
485
|
+
*/
|
|
486
|
+
function unjudgeableRowReason(row, baseRow) {
|
|
487
|
+
if (baseRow === undefined) {
|
|
488
|
+
return (
|
|
489
|
+
"no base-side evidence exists for this rule — the baseline does not declare it, so its " +
|
|
490
|
+
"findings cannot be told apart from pre-existing ones; re-capture the baseline with the " +
|
|
491
|
+
"rule declared"
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
if (baseRow.sha256 !== row.sha256) {
|
|
495
|
+
return (
|
|
496
|
+
`the rule's artifact digest changed between capture (${baseRow.sha256}) and head ` +
|
|
497
|
+
`(${row.sha256}) — the law itself moved, so a finding difference cannot be attributed ` +
|
|
498
|
+
`to the code; re-capture the baseline under the current artifact`
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
if (canonicalizeJson(baseRow.params ?? null) !== canonicalizeJson(row.params ?? null)) {
|
|
502
|
+
return (
|
|
503
|
+
"the rule's declared params changed between capture and head — params ride inside the " +
|
|
504
|
+
"evidence bundle, so this is law drift exactly as a digest change is; re-capture the " +
|
|
505
|
+
"baseline under the current declaration"
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Every custom rule the head policy declares, evaluated over BOTH sides of a
|
|
513
|
+
* delta — the current tree's facts and a baseline snapshot's stored ones —
|
|
514
|
+
* under the current declaration.
|
|
515
|
+
*
|
|
516
|
+
* Fail-closed throughout: every path that cannot produce a two-sided judgment
|
|
517
|
+
* lands the rule in `unknownRules` with a reason a reader can act on, never in
|
|
518
|
+
* `judged` with a thinner answer — with ONE exception, deliberately shared
|
|
519
|
+
* with `customRulesForCheck`: a LOAD-class failure (unreadable artifact, hash
|
|
520
|
+
* mismatch, bytes that are not the contract) THROWS, because the head law
|
|
521
|
+
* could not be read at all and `check` on the same tree would refuse the same
|
|
522
|
+
* way; a delta that soft-reported it would let a permanently unloadable rule
|
|
523
|
+
* ride every delta as one more unknown row.
|
|
524
|
+
*
|
|
525
|
+
* The routes into `unknownRules`, each with its reason:
|
|
526
|
+
* - the baseline carries no custom-rule blocks at all (`baselineAbsentReason`
|
|
527
|
+
* below rides every rule);
|
|
528
|
+
* - the baseline never declared this rule (added since capture);
|
|
529
|
+
* - digest or params drift (`unjudgeableRowReason`);
|
|
530
|
+
* - either side's evidence bundle refuses to build (an unattributable stored
|
|
531
|
+
* record, a graph row the bundle cannot read);
|
|
532
|
+
* - either side's evaluation fails, or the rule itself answers `unknown`;
|
|
533
|
+
* - the rule answers `not_applicable` on exactly one side (the asymmetric
|
|
534
|
+
* case the paragraph below argues).
|
|
535
|
+
*
|
|
536
|
+
* A rule that answers `not_applicable` on BOTH sides contributes an EMPTY
|
|
537
|
+
* finding list per side plus a note naming each reason — not applicable is a
|
|
538
|
+
* judged answer, not a failure (`../governance/fitness-rules.mjs` draws the
|
|
539
|
+
* same line). A rule that answers `not_applicable` on only ONE side lands in
|
|
540
|
+
* `unknownRules` instead: an empty list for the inapplicable side beside real
|
|
541
|
+
* findings (or a judged pass) on the other would classify every base finding
|
|
542
|
+
* as resolved — or every head finding as introduced — on the strength of a
|
|
543
|
+
* side the rule never judged, which is the silent direction.
|
|
544
|
+
*
|
|
545
|
+
* Rules the baseline declares that the head no longer does are returned as
|
|
546
|
+
* `removedRules` — nothing is judged for them (the head declares no law to
|
|
547
|
+
* run), and the caller turns the list into a coverage note.
|
|
548
|
+
*
|
|
549
|
+
* @param {object} commandContext From `./context.mjs`'s `resolveCommandContext`.
|
|
550
|
+
* @param {{rows: object[], policy: object, baseline: object,
|
|
551
|
+
* readArtifact?: (artifact: string) => Uint8Array|null, timeoutMs?: number}} run
|
|
552
|
+
* `rows` is the head policy's validated `customRules` list, `policy` the
|
|
553
|
+
* loaded head policy, `baseline` the validated evidence snapshot.
|
|
554
|
+
* @returns {Promise<{judged: {name: string, sha256: string,
|
|
555
|
+
* baseFindings: object[], headFindings: object[], notes?: string[]}[],
|
|
556
|
+
* unknownRules: {name: string, reason: string}[], removedRules: string[],
|
|
557
|
+
* catalogue: {ruleId: string, rule: string, findingId: string, message: string}[]}>}
|
|
558
|
+
* `judged` findings are each side's verdict-document findings verbatim
|
|
559
|
+
* (un-namespaced ids — `./delta-classify.mjs` namespaces per entry).
|
|
560
|
+
* @throws {Error} on any load-class failure, naming the rule and the reason.
|
|
561
|
+
*/
|
|
562
|
+
export async function customRulesForDelta(
|
|
563
|
+
commandContext,
|
|
564
|
+
{ rows, policy, baseline, readArtifact, timeoutMs = CUSTOM_RULE_TIMEOUT_MS },
|
|
565
|
+
) {
|
|
566
|
+
/** @type {{name: string, reason: string}[]} */
|
|
567
|
+
const unknownRules = [];
|
|
568
|
+
/** @type {object[]} */
|
|
569
|
+
const judgeableRows = [];
|
|
570
|
+
const baseRows =
|
|
571
|
+
baseline.customRules === undefined
|
|
572
|
+
? null
|
|
573
|
+
: new Map(baseline.customRules.map((row) => [row.name, row]));
|
|
574
|
+
|
|
575
|
+
for (const row of rows) {
|
|
576
|
+
if (baseRows === null) {
|
|
577
|
+
unknownRules.push({
|
|
578
|
+
name: row.name,
|
|
579
|
+
reason:
|
|
580
|
+
"the baseline carries no custom-rule evidence — it was captured before custom rules " +
|
|
581
|
+
"were declared, or by a version that did not store them; re-capture the baseline",
|
|
582
|
+
});
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
const reason = unjudgeableRowReason(row, baseRows.get(row.name));
|
|
586
|
+
if (reason !== null) {
|
|
587
|
+
unknownRules.push({ name: row.name, reason });
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
judgeableRows.push(row);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const removedRules =
|
|
594
|
+
baseRows === null
|
|
595
|
+
? []
|
|
596
|
+
: baseline.customRules
|
|
597
|
+
.filter((baseRow) => !rows.some((row) => row.name === baseRow.name))
|
|
598
|
+
.map((baseRow) => baseRow.name);
|
|
599
|
+
|
|
600
|
+
// The load pass, whole-law-or-refuse, exactly as `customRulesForCheck`:
|
|
601
|
+
// every judgeable rule is loaded before any is evaluated.
|
|
602
|
+
const read = readArtifact ?? readArtifactBytes(commandContext.root);
|
|
603
|
+
/** @type {{row: object, module: WebAssembly.Module, describe: Record<string, any>}[]} */
|
|
604
|
+
const loaded = [];
|
|
605
|
+
for (const row of judgeableRows) {
|
|
606
|
+
const artifactBytes = read(row.artifact);
|
|
607
|
+
if (artifactBytes === null || artifactBytes === undefined) {
|
|
608
|
+
refuseLoad(
|
|
609
|
+
row.name,
|
|
610
|
+
`the artifact "${row.artifact}" could not be read — a path that does not exist, cannot ` +
|
|
611
|
+
`be opened, or resolves through a symlink out of the workspace all reach this run as ` +
|
|
612
|
+
`no bytes at all, and a declared law with no bytes behind it is a run that refuses ` +
|
|
613
|
+
`rather than a rule that quietly judges nothing`,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
const outcome = await loadCustomRule({
|
|
617
|
+
name: row.name,
|
|
618
|
+
artifactBytes,
|
|
619
|
+
declaredSha256: row.sha256,
|
|
620
|
+
timeoutMs,
|
|
621
|
+
});
|
|
622
|
+
if (!outcome.ok) throw new Error(`archkeep: ${outcome.failure.reason}`);
|
|
623
|
+
loaded.push({ row, module: outcome.module, describe: outcome.describe });
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const catalogue = loaded.flatMap(({ row, describe }) =>
|
|
627
|
+
describe.findings.map((entry) => ({
|
|
628
|
+
ruleId: namespacedId(row.name, entry.id),
|
|
629
|
+
rule: row.name,
|
|
630
|
+
findingId: entry.id,
|
|
631
|
+
message: entry.message,
|
|
632
|
+
})),
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
const sides = [
|
|
636
|
+
{ side: "base", observed: baselineFacts(baseline, policy) },
|
|
637
|
+
{ side: "head", observed: observedFacts(commandContext, policy) },
|
|
638
|
+
];
|
|
639
|
+
|
|
640
|
+
/** @type {{name: string, sha256: string, baseFindings: object[], headFindings: object[], notes?: string[]}[]} */
|
|
641
|
+
const judged = [];
|
|
642
|
+
for (const { row, module, describe } of loaded) {
|
|
643
|
+
/** @type {Record<string, object[]>} */
|
|
644
|
+
const findingsBySide = {};
|
|
645
|
+
/** @type {string[]} */
|
|
646
|
+
const notes = [];
|
|
647
|
+
/** @type {string[]} */
|
|
648
|
+
const notApplicableSides = [];
|
|
649
|
+
/** @type {string|null} */
|
|
650
|
+
let unknownReason = null;
|
|
651
|
+
for (const { side, observed } of sides) {
|
|
652
|
+
let evidenceBytes;
|
|
653
|
+
try {
|
|
654
|
+
evidenceBytes = serializeEvidenceBundle(buildEvidenceBundle({ ...observed, rule: row }));
|
|
655
|
+
} catch (cause) {
|
|
656
|
+
// Fail-closed on BOTH sides, base and head alike: an evidence set the
|
|
657
|
+
// bundle refuses (an unattributable record above all) is a side this
|
|
658
|
+
// rule cannot honestly judge, and the rule says so rather than being
|
|
659
|
+
// judged over the records that survived.
|
|
660
|
+
unknownReason =
|
|
661
|
+
`the ${side}-side evidence could not be assembled: ` +
|
|
662
|
+
`${cause instanceof Error ? cause.message : String(cause)}`;
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
const outcome = await evaluateCustomRule({ module, describe, evidenceBytes, timeoutMs });
|
|
666
|
+
if (!outcome.ok) {
|
|
667
|
+
unknownReason = `on the ${side} side, ${outcome.failure.reason}`;
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
if (outcome.verdict.verdict === "unknown") {
|
|
671
|
+
unknownReason = `the rule could not judge the ${side} side — ${outcome.verdict.reason}`;
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
if (outcome.verdict.verdict === "not_applicable") {
|
|
675
|
+
notes.push(`${side} side: not applicable — ${outcome.verdict.notApplicableReason}`);
|
|
676
|
+
notApplicableSides.push(side);
|
|
677
|
+
findingsBySide[side] = [];
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
findingsBySide[side] = outcome.verdict.findings;
|
|
681
|
+
}
|
|
682
|
+
// Applicability must be SYMMETRIC to judge a delta: a rule that did not
|
|
683
|
+
// apply on one side contributed an empty list there, and classifying real
|
|
684
|
+
// findings from the other side against that emptiness would call base
|
|
685
|
+
// findings resolved — or head findings introduced — on the strength of a
|
|
686
|
+
// side the rule never judged. Both-sides not_applicable stays a judged
|
|
687
|
+
// (empty) answer; one-sided lands in `unknownRules`, fail-closed.
|
|
688
|
+
if (unknownReason === null && notApplicableSides.length === 1) {
|
|
689
|
+
const inapplicable = notApplicableSides[0];
|
|
690
|
+
const applicable = inapplicable === "base" ? "head" : "base";
|
|
691
|
+
unknownReason =
|
|
692
|
+
`the rule did not apply at ${inapplicable} while it judged the ${applicable} side — ` +
|
|
693
|
+
(inapplicable === "head"
|
|
694
|
+
? `base findings cannot be called resolved by a side the rule did not judge`
|
|
695
|
+
: `head findings cannot be called introduced against a side the rule did not judge`) +
|
|
696
|
+
`; ${notes.join("; ")}`;
|
|
697
|
+
}
|
|
698
|
+
if (unknownReason !== null) {
|
|
699
|
+
unknownRules.push({ name: row.name, reason: unknownReason });
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
judged.push({
|
|
703
|
+
name: row.name,
|
|
704
|
+
sha256: row.sha256,
|
|
705
|
+
baseFindings: findingsBySide.base,
|
|
706
|
+
headFindings: findingsBySide.head,
|
|
707
|
+
...(notes.length === 0 ? {} : { notes }),
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return { judged, unknownRules, removedRules, catalogue };
|
|
712
|
+
}
|
|
@@ -66,6 +66,7 @@ import { canonicalizeJson } from "../canonical.mjs";
|
|
|
66
66
|
import { suppressionCovers } from "../config.mjs";
|
|
67
67
|
import { referenceTime } from "../governance/clock.mjs";
|
|
68
68
|
import { suppressionFate } from "../governance/waiver.mjs";
|
|
69
|
+
import { namespacedId } from "./custom-rules.mjs";
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* Computes a violation's architectural identity.
|
|
@@ -190,23 +191,7 @@ export function classifyViolations({ base, head, suppressions = [], now = refere
|
|
|
190
191
|
headSites: headGroup ? headGroup.sites : [],
|
|
191
192
|
};
|
|
192
193
|
|
|
193
|
-
|
|
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
|
-
}
|
|
194
|
+
Object.assign(entry, occurrenceClassification(baseCount, headCount, "the violation"));
|
|
210
195
|
|
|
211
196
|
// A resolved item has no head occurrence left; its waive status is judged
|
|
212
197
|
// against the LAST places the violation existed (its base sites) — what
|
|
@@ -296,22 +281,7 @@ export function classifyUnresolvableRecords({ base, head, sourceProjectOf }) {
|
|
|
296
281
|
headSites: headGroup ? headGroup.sites : [],
|
|
297
282
|
};
|
|
298
283
|
|
|
299
|
-
|
|
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
|
-
}
|
|
284
|
+
Object.assign(entry, occurrenceClassification(baseCount, headCount, "the site"));
|
|
315
285
|
|
|
316
286
|
bucketFor(entry.classification, { introduced, resolved, unchanged }).push(entry);
|
|
317
287
|
}
|
|
@@ -319,6 +289,165 @@ export function classifyUnresolvableRecords({ base, head, sourceProjectOf }) {
|
|
|
319
289
|
return { introduced, resolved, unchanged, unknown };
|
|
320
290
|
}
|
|
321
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
|
+
|
|
322
451
|
/**
|
|
323
452
|
* Runs both classifications over one pair of evidence sets: the raw violations
|
|
324
453
|
* and the unresolvable-site records, each into its own category.
|
|
@@ -428,6 +557,39 @@ function isUnresolvable(record) {
|
|
|
428
557
|
);
|
|
429
558
|
}
|
|
430
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
|
+
|
|
431
593
|
/**
|
|
432
594
|
* Folds identified items into groups keyed by identity: occurrences become a
|
|
433
595
|
* multiset of sites, so duplicate import sites in one file count twice.
|