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