@ecoma-io/archkeep 0.16.1 → 0.18.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 (42) hide show
  1. package/README.md +1 -1
  2. package/cli.mjs +258 -20
  3. package/package.json +2 -2
  4. package/src/architecture-intent/judge.mjs +19 -6
  5. package/src/commands/adr.mjs +45 -4
  6. package/src/commands/change-intent.mjs +55 -8
  7. package/src/commands/change.mjs +332 -11
  8. package/src/commands/debt.mjs +26 -5
  9. package/src/commands/decisions.mjs +291 -0
  10. package/src/commands/delta-classify.mjs +257 -0
  11. package/src/commands/delta.mjs +269 -8
  12. package/src/commands/evolution.mjs +758 -5
  13. package/src/commands/explain.mjs +207 -1
  14. package/src/commands/history.mjs +81 -5
  15. package/src/commands/plan-context-command.mjs +163 -2
  16. package/src/commands/provenance-command.mjs +86 -17
  17. package/src/commands/provenance.mjs +60 -0
  18. package/src/commands/report.mjs +48 -1
  19. package/src/commands/trajectory.mjs +89 -3
  20. package/src/fixtures/evolution-lifecycle/workspace.mjs +242 -0
  21. package/src/governance/adr-registry.mjs +252 -15
  22. package/src/governance/debt-ledger.mjs +261 -19
  23. package/src/governance/decision-fitness.mjs +213 -0
  24. package/src/governance/decision-graph.mjs +483 -0
  25. package/src/governance/decision-lineage.mjs +250 -0
  26. package/src/governance/evolution-event.mjs +470 -0
  27. package/src/governance/evolution-store.mjs +362 -0
  28. package/src/governance/provenance-record.mjs +150 -0
  29. package/src/providers/native/model.mjs +18 -4
  30. package/src/report/adr-text.mjs +109 -4
  31. package/src/report/change-text.mjs +21 -3
  32. package/src/report/debt-text.mjs +42 -6
  33. package/src/report/decisions-text.mjs +164 -0
  34. package/src/report/delta-text.mjs +36 -1
  35. package/src/report/evolution-text.mjs +231 -2
  36. package/src/report/explain-text.mjs +122 -1
  37. package/src/report/history-text.mjs +9 -3
  38. package/src/report/plan-context-text.mjs +94 -0
  39. package/src/report/provenance-text.mjs +67 -1
  40. package/src/report/report-text.mjs +53 -18
  41. package/src/report/snapshot-text.mjs +35 -1
  42. package/src/report/trajectory-text.mjs +30 -1
@@ -69,8 +69,24 @@
69
69
  import { mkdtempSync, rmSync } from "node:fs";
70
70
  import { tmpdir } from "node:os";
71
71
  import { join } from "node:path";
72
+ import { createRequire } from "node:module";
72
73
 
73
74
  import { isWholeFileFailure } from "../analysis/source-util.mjs";
75
+ import { loadIntent, INTENT_FILE } from "../architecture-intent/model.mjs";
76
+ import { judgeIntent } from "../architecture-intent/judge.mjs";
77
+ import { readAdrContext } from "./adr.mjs";
78
+ import { referenceTime } from "../governance/clock.mjs";
79
+ import { debtChangeDiff, debtFactId, driftFactOf } from "../governance/debt-ledger.mjs";
80
+ import { computeAffectedDecisions } from "../governance/decision-lineage.mjs";
81
+ import {
82
+ classifyEvolution,
83
+ eventDedupeKey,
84
+ eventId,
85
+ EVOLUTION_EVENT_SCHEMA_VERSION,
86
+ } from "../governance/evolution-event.mjs";
87
+ import { writeEvent } from "../governance/evolution-store.mjs";
88
+ import { recordOrigin } from "../governance/provenance-record.mjs";
89
+ import { fitnessForCheck } from "./fitness.mjs";
74
90
  import { runProcess } from "../process.mjs";
75
91
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
76
92
  import { formatEvolutionReport } from "../report/evolution-text.mjs";
@@ -80,6 +96,11 @@ import { resolveProvenance } from "./provenance.mjs";
80
96
  import { resolveDescribedPolicy } from "./policy.mjs";
81
97
  import { resolveCommandContext, describeWorkspaceRoot } from "./context.mjs";
82
98
 
99
+ const require = createRequire(import.meta.url);
100
+
101
+ /** The tool identity stamped into every emitted event's provenance (delta.mjs's pattern). */
102
+ const { version: TOOL_VERSION } = require("../../package.json");
103
+
83
104
  /** A full SHA-1 object name, as `git rev-parse` answers it. */
84
105
  const FULL_SHA = /^[0-9a-f]{40}$/;
85
106
 
@@ -227,7 +248,8 @@ export function selectLinearRange(root, { base, head }, { run = runProcess } = {
227
248
  * @param {{resolveContext?: Function, resolveProvenance?: Function}} [io]
228
249
  * @returns {Promise<{sha: string, id: string, provider: string, provenance: object|null,
229
250
  * projects: object[], dependencies: object[], fingerprint: string|null,
230
- * coverage: {projects: number, analyzedFiles: number, imports: number}}>}
251
+ * coverage: {projects: number, analyzedFiles: number, imports: number},
252
+ * evidence?: object|null}>}
231
253
  * @throws {Error} when the revision is not a readable workspace, when whole-file
232
254
  * analysis failures leave the record under-represented, or when the law the
233
255
  * revision names will not load.
@@ -272,6 +294,16 @@ async function analyzeRevision(input, io = {}) {
272
294
  const projects = buildProjects(context.graph.nodes);
273
295
  const dependencies = buildDependencies(context.graph.dependencies);
274
296
 
297
+ // Wave 3 W7 (design §7): the per-revision comparable evidence for the
298
+ // fitness, debt and decision axes — the only comparable evidence those axes
299
+ // have, because each analyzed revision carries its own graph, law and
300
+ // decision registry. Computed ADDITIVELY and FAIL-SOFT: an axis that cannot
301
+ // be judged (an absent intent, an undeclared fitness block, an unjudgeable
302
+ // law) produces `null` with an in-band reason rather than a fabricated
303
+ // verdict, and never changes the existing `evolution` behavior (a revision
304
+ // that lacks an intent today still analyzes; it must keep doing so).
305
+ const evidence = await revisionEvidence(context, config, dir);
306
+
275
307
  return {
276
308
  sha,
277
309
  id: snapshotIdentity({
@@ -289,6 +321,678 @@ async function analyzeRevision(input, io = {}) {
289
321
  analyzedFiles: context.analysis.analyzed,
290
322
  imports: context.analysis.imports.length,
291
323
  },
324
+ ...(evidence === null ? {} : { evidence }),
325
+ };
326
+ }
327
+
328
+ /**
329
+ * The per-revision comparable evidence for the fitness, debt and decision
330
+ * axes (design §7): the observed architecture facts each revision's FULL
331
+ * analysis carries — the intent verdict over its own graph, the fitness
332
+ * verdicts its own law declares, and the ADR registry its own tree records.
333
+ * The `evolution` command judges every transition against exactly this
334
+ * evidence, never a second opinion: an axis that cannot be compared is `n/a`
335
+ * with a reason, never zero and never folded into a clean "none" (the wave's
336
+ * invariant, `../governance/evolution-event.mjs`).
337
+ *
338
+ * Fail-soft by contract: every destination here is evidence worth disclosing,
339
+ * not a gate. An absent intent, an undeclared fitness block, or an
340
+ * unjudgeable law returns `null` (with the caller's sense of "could not be
341
+ * compared") instead of throwing, so a revision that lacks the evidence still
342
+ * analyses exactly as it always did.
343
+ *
344
+ * @param {object} context The `resolveCommandContext` record for one revision.
345
+ * @param {object|null} config The revision's resolved boundary law.
346
+ * @param {string} dir The revision's materialized worktree.
347
+ * @returns {Promise<object|null>} The evidence record, or `null` when the
348
+ * revision supplies nothing to compare (no intent, no fitness, no decision
349
+ * registry) — the axes then read as incomparable with a reason.
350
+ */
351
+ async function revisionEvidence(context, config, dir) {
352
+ /** The intent judgement over this revision's own graph, or null. */
353
+ let intentVerdict = null;
354
+ let intentNote = null;
355
+ try {
356
+ if (context.tracked?.includes(INTENT_FILE)) {
357
+ const intent = await loadIntent(dir, { tracked: context.tracked });
358
+ if (intent !== undefined && intent !== null) {
359
+ intentVerdict = judgeIntent(intent, {
360
+ nodes: context.graph.nodes,
361
+ dependencies: context.graph.dependencies,
362
+ });
363
+ }
364
+ }
365
+ } catch (cause) {
366
+ intentVerdict = null;
367
+ intentNote = `architecture intent could not be judged — ${cause?.message ?? cause}`;
368
+ }
369
+
370
+ /** The per-constraint fitness verdicts this revision's law declares, or null. */
371
+ let fitness = null;
372
+ let fitnessNote = null;
373
+ if (config?.fitness !== undefined) {
374
+ try {
375
+ const judged = fitnessForCheck(context, {
376
+ rows: config.fitness,
377
+ intent:
378
+ intentVerdict === null
379
+ ? null
380
+ : {
381
+ verdict:
382
+ intentVerdict.findings.length > 0
383
+ ? "findings"
384
+ : intentVerdict.unresolved.length > 0
385
+ ? "no-verdict"
386
+ : "ok",
387
+ boundaries: intentVerdict.boundaries,
388
+ findings: intentVerdict.findings,
389
+ unresolved: intentVerdict.unresolved,
390
+ notes: intentVerdict.notes,
391
+ },
392
+ suppressions: config.suppressions ?? [],
393
+ scoped: false,
394
+ });
395
+ fitness = {
396
+ decisions: judged.decisions,
397
+ overall: judged.overall,
398
+ };
399
+ } catch (cause) {
400
+ fitness = null;
401
+ fitnessNote = `fitness could not be judged — ${cause?.message ?? cause}`;
402
+ }
403
+ }
404
+ /** The ADR registry this revision's own tree records, or null when unreadable. */
405
+ let adrContext;
406
+ let adrNote = null;
407
+ try {
408
+ adrContext = readAdrContext(dir, { tracked: context.tracked });
409
+ } catch (cause) {
410
+ // A malformed registry is a disclosure, not a gate: the decision axis
411
+ // reads as "could not be compared" with the reason, and the revision
412
+ // still analyzes exactly as it always did (the existing `evolution`
413
+ // never read ADR, so this catch is strictly additive).
414
+ adrContext = null;
415
+ adrNote = `ADR registry could not be read — ${cause?.message ?? cause}`;
416
+ }
417
+
418
+ const hasAny =
419
+ intentVerdict !== null ||
420
+ fitness !== null ||
421
+ (adrContext !== null && adrContext.records.length > 0);
422
+ if (!hasAny) {
423
+ return null;
424
+ }
425
+ return {
426
+ ...(intentVerdict === null
427
+ ? { intent: { verdict: "no-verdict", note: intentNote ?? "no intent compared" } }
428
+ : { intent: intentVerdict }),
429
+ fitness:
430
+ fitness === null
431
+ ? config?.fitness !== undefined
432
+ ? { verdict: "unknown", note: fitnessNote ?? "fitness could not be judged" }
433
+ : { verdict: "not_applicable", note: "no fitness block declared" }
434
+ : {
435
+ verdict: fitness.overall.verdict,
436
+ decisions: fitness.decisions,
437
+ overall: fitness.overall,
438
+ },
439
+ adr:
440
+ adrContext === null
441
+ ? {
442
+ records: [],
443
+ byId: new Map(),
444
+ knownFitness: new Set(),
445
+ note: adrNote ?? "ADR registry could not be read",
446
+ }
447
+ : adrContext,
448
+ };
449
+ }
450
+
451
+ /**
452
+ * The per-revision comparable evidence two adjacent snapshots both carry, as
453
+ * the facts `classifyEvolution` and the 8-question report read.
454
+ *
455
+ * @typedef {object} TransitionEvidence
456
+ * @property {object} from
457
+ * @property {object} to
458
+ */
459
+
460
+ /**
461
+ * Whether a per-revision `evidence.intent` is a REAL `judgeIntent` result (and
462
+ * so comparable) rather than the `{verdict: "no-verdict", note}` shape used to
463
+ * disclose an intent that could not be judged. `debtChangeDiff` reads
464
+ * `findings`/`gaps` from it, and the refusal shape carries neither — diffing
465
+ * one would fabricate a clean empty-diff, so it is never treated as verdict
466
+ * evidence.
467
+ *
468
+ * @param {object|undefined} intent The `evidence.intent` value.
469
+ * @returns {boolean}
470
+ */
471
+ function isRealIntentVerdict(intent) {
472
+ return (
473
+ intent !== undefined &&
474
+ intent !== null &&
475
+ typeof intent === "object" &&
476
+ Array.isArray(intent.findings)
477
+ );
478
+ }
479
+
480
+ /**
481
+ * The observed architecture-change struct `classifyEvolution` and the report
482
+ * read: the graph diff between two snapshots, plus the carrier-change flags the
483
+ * transition already classified. A transition whose graph did not change
484
+ * (`changes === null`) carries an empty structure — a pure policy or provider
485
+ * carrier change is still disclosed by the flags, never by fabricated diff
486
+ * rows.
487
+ *
488
+ * @param {object} transition A `computeEvolution` transition record.
489
+ * @returns {{architectureChanged: boolean, projects: {added: object[], removed: object[], changed: object[]},
490
+ * edges: {added: object[], removed: object[]}, policyChanged: boolean|null,
491
+ * policyOneSided: boolean, providerChanged: boolean, provenanceChanged: boolean|null}}
492
+ */
493
+ function transitionObserved(transition) {
494
+ const diff = transition.changes ?? {
495
+ addedProjects: [],
496
+ removedProjects: [],
497
+ changedProjects: [],
498
+ addedEdges: [],
499
+ removedEdges: [],
500
+ };
501
+ return {
502
+ architectureChanged: transition.architectureChanged,
503
+ projects: {
504
+ added: diff.addedProjects ?? [],
505
+ removed: diff.removedProjects ?? [],
506
+ changed: diff.changedProjects ?? [],
507
+ },
508
+ edges: { added: diff.addedEdges ?? [], removed: diff.removedEdges ?? [] },
509
+ policyChanged: transition.policyChanged,
510
+ policyOneSided: transition.policyOneSided,
511
+ providerChanged: transition.providerChanged,
512
+ provenanceChanged: transition.provenanceChanged,
513
+ };
514
+ }
515
+
516
+ /**
517
+ * The stable drift-finding id one intent finding owns — the SAME id the debt
518
+ * ledger derives for the same fact (`debtFactId` over `driftFactOf`), so an
519
+ * event's `findings.introduced`/`findings.resolved` link the W5 ledger.
520
+ *
521
+ * @param {object} finding A `judgeIntent` finding (`{source, target, rule, …}`).
522
+ * @returns {string}
523
+ */
524
+ function driftFindingId(finding) {
525
+ return debtFactId("drift", driftFactOf(finding));
526
+ }
527
+
528
+ /**
529
+ * The comparable axis answer when a side's intent could not be judged.
530
+ *
531
+ * @param {object|undefined} baseIntent
532
+ * @param {object|undefined} headIntent
533
+ * @returns {string}
534
+ */
535
+ function intentIncomparableReason(baseIntent, headIntent) {
536
+ const sideReason = (side, intent) =>
537
+ side === "base"
538
+ ? `base intent unjudgeable${typeof intent?.note === "string" ? ` — ${intent.note}` : ""}`
539
+ : `head intent unjudgeable${typeof intent?.note === "string" ? ` — ${intent.note}` : ""}`;
540
+ if (!isRealIntentVerdict(baseIntent)) return sideReason("base", baseIntent);
541
+ if (!isRealIntentVerdict(headIntent)) return sideReason("head", headIntent);
542
+ return "intent could not be compared";
543
+ }
544
+
545
+ /**
546
+ * Builds the per-transition 8-question comparison — the comparable evidence
547
+ * for one base→head revision pair (design §7/§8). Every question field EXISTS
548
+ * with either real facts or the `{available: false, reason}` marker; an
549
+ * incomparable axis is NEVER folded into a clean empty result (the invariant:
550
+ * "an empty result is a claim, not a shrug").
551
+ *
552
+ * @param {object} from The base snapshot (`snapshots[i]`, with its `evidence`).
553
+ * @param {object} to The head snapshot (`snapshots[i+1]`, with its `evidence`).
554
+ * @param {object} transition The `evolution.transitions[i]` record.
555
+ * @returns {object} The comparison object for the envelope and report.
556
+ */
557
+ function buildTransitionComparison(from, to, transition) {
558
+ const observed = transitionObserved(transition);
559
+
560
+ const fromEvidence = from.evidence ?? {};
561
+ const toEvidence = to.evidence ?? {};
562
+ const baseIntent = fromEvidence.intent;
563
+ const headIntent = toEvidence.intent;
564
+ const intentComparable = isRealIntentVerdict(baseIntent) && isRealIntentVerdict(headIntent);
565
+
566
+ /** @type {{introduced: string[], resolved: string[], unknown: string[], note?: string}|{available: false, reason: string}} */
567
+ let findings;
568
+ if (intentComparable) {
569
+ const baseDrift = new Set((baseIntent.findings ?? []).map(driftFindingId));
570
+ const headDrift = new Set((headIntent.findings ?? []).map(driftFindingId));
571
+ findings = {
572
+ introduced: [...headDrift].filter((id) => !baseDrift.has(id)).sort(),
573
+ resolved: [...baseDrift].filter((id) => !headDrift.has(id)).sort(),
574
+ unknown: [],
575
+ };
576
+ } else {
577
+ findings = { available: false, reason: intentIncomparableReason(baseIntent, headIntent) };
578
+ }
579
+
580
+ /** @type {{introduced: string[], resolved: string[]}|{available: false, reason: string}} */
581
+ let debt;
582
+ if (intentComparable) {
583
+ debt = debtChangeDiff(baseIntent, headIntent);
584
+ } else {
585
+ debt = { available: false, reason: intentIncomparableReason(baseIntent, headIntent) };
586
+ }
587
+
588
+ // The drift-finding feed for `classifyEvolution` — supplied ONLY when both
589
+ // intents were actually judged, so a could-not-look is never absorbed into a
590
+ // clean class set. Resolved drift findings feed REPAIR through
591
+ // `driftFindingsResolved`; `debtResolved` carries the same closed-debt fact
592
+ // in the aggregate (drift ids plus gap ids), so a gap closure the per-finding
593
+ // diff cannot see still classifies as REPAIR rather than folding into a clean
594
+ // class set. `violations.resolved` stays out: the debt answer already carries
595
+ // the closed-debt fact.
596
+ const driftComparable = intentComparable;
597
+ const headDriftIds = new Set((headIntent?.findings ?? []).map(driftFindingId));
598
+ const baseDriftIds = new Set((baseIntent?.findings ?? []).map(driftFindingId));
599
+ const violationsIntroduced = driftComparable
600
+ ? [...headDriftIds]
601
+ .filter((id) => !baseDriftIds.has(id))
602
+ .sort()
603
+ .map((id) => ({ id, waived: false }))
604
+ : [];
605
+ const driftFindingsResolved = driftComparable
606
+ ? [...baseDriftIds].filter((id) => !headDriftIds.has(id)).sort()
607
+ : [];
608
+
609
+ // Declared constraints: the HEAD-side fitness decisions that carry a
610
+ // verdict classifyEvolution can act on (`pass`/`fail` classify; `unknown`
611
+ // discloses a could-not-determine → no-verdict). `not_applicable` rows are
612
+ // skipped here — a declared-but-matching-nothing function is a report row,
613
+ // not a violation and not an unknown, so it must not force a fabricated
614
+ // no-verdict; it rides only the fitness verdictDeltas.
615
+ const headDecisions = Array.isArray(toEvidence.fitness?.decisions)
616
+ ? toEvidence.fitness.decisions
617
+ : [];
618
+ const declaredConstraints = headDecisions
619
+ .filter((d) => d.verdict === "pass" || d.verdict === "fail" || d.verdict === "unknown")
620
+ .map((d) => ({ id: d.name, verdict: d.verdict }));
621
+
622
+ // The ADR registry each side records: `{records}` when the side's registry
623
+ // is readable AND non-empty; `null` when the side records no registry or its
624
+ // registry is unreadable (the note is surfaced into the comparison's notes).
625
+ const adrSide = (side) => {
626
+ const evidence = side === "base" ? fromEvidence : toEvidence;
627
+ const adr = evidence.adr;
628
+ if (
629
+ adr === undefined ||
630
+ adr === null ||
631
+ typeof adr.note === "string" ||
632
+ !Array.isArray(adr.records) ||
633
+ adr.records.length === 0
634
+ ) {
635
+ return null;
636
+ }
637
+ return { records: adr.records };
638
+ };
639
+ const adrBase = adrSide("base");
640
+ const adrHead = adrSide("head");
641
+
642
+ const directionNotes = [];
643
+ const adrNote = (side) => {
644
+ const adr = (side === "base" ? fromEvidence : toEvidence).adr;
645
+ return typeof adr?.note === "string" ? adr.note : null;
646
+ };
647
+ const baseAdrNote = adrNote("base");
648
+ const headAdrNote = adrNote("head");
649
+ if (baseAdrNote !== null) {
650
+ directionNotes.push(`base decision registry unreadable — ${baseAdrNote}`);
651
+ }
652
+ if (headAdrNote !== null) {
653
+ directionNotes.push(`head decision registry unreadable — ${headAdrNote}`);
654
+ }
655
+
656
+ const fitnessComparable =
657
+ Array.isArray(fromEvidence.fitness?.decisions) && Array.isArray(toEvidence.fitness?.decisions);
658
+
659
+ /** @type {{verdictDeltas: object[]}|{available: false, reason: string}} */
660
+ let fitness;
661
+ if (fitnessComparable) {
662
+ const names = new Set([
663
+ ...fromEvidence.fitness.decisions.map((d) => d.name),
664
+ ...toEvidence.fitness.decisions.map((d) => d.name),
665
+ ]);
666
+ const verdictDelta = (side, name) => {
667
+ const decisions = (side === "base" ? fromEvidence : toEvidence).fitness.decisions;
668
+ const found = decisions.find((d) => d.name === name);
669
+ if (found !== undefined) return found.verdict;
670
+ return {
671
+ available: false,
672
+ reason: `not declared/judged at ${side}`,
673
+ };
674
+ };
675
+ fitness = {
676
+ verdictDeltas: [...names].sort().map((name) => ({
677
+ id: name,
678
+ base: verdictDelta("base", name),
679
+ head: verdictDelta("head", name),
680
+ })),
681
+ };
682
+ } else {
683
+ const baseFitness = fromEvidence.fitness;
684
+ const headFitness = toEvidence.fitness;
685
+ let reason;
686
+ if (baseFitness?.verdict === "not_applicable") {
687
+ reason = "no fitness block declared at base";
688
+ } else if (headFitness?.verdict === "not_applicable") {
689
+ reason = "no fitness block declared at head";
690
+ } else if (!Array.isArray(baseFitness?.decisions)) {
691
+ reason = `base fitness unjudgeable${typeof baseFitness?.note === "string" ? ` — ${baseFitness.note}` : ""}`;
692
+ } else {
693
+ reason = `head fitness unjudgeable${typeof headFitness?.note === "string" ? ` — ${headFitness.note}` : ""}`;
694
+ }
695
+ fitness = { available: false, reason };
696
+ }
697
+
698
+ const coverageAnswer = {
699
+ base: {
700
+ projects: from.coverage?.projects ?? 0,
701
+ analyzedFiles: from.coverage?.analyzedFiles ?? 0,
702
+ imports: from.coverage?.imports ?? 0,
703
+ },
704
+ head: {
705
+ projects: to.coverage?.projects ?? 0,
706
+ analyzedFiles: to.coverage?.analyzedFiles ?? 0,
707
+ imports: to.coverage?.imports ?? 0,
708
+ },
709
+ };
710
+
711
+ /* F-EVO-4: when intent exists on only one side (or a side is a refusal
712
+ * object rather than a real verdict), the pair is NOT fully comparable and
713
+ * its disposition must never read `accepted`. `classifyEvolution` only
714
+ * reaches that conclusion through `verdictRelevantUnknown`, so we feed it
715
+ * an unknown entry — unless BOTH sides carry no intent evidence at all
716
+ * (base and head both `no-verdict` refusals), which is the status-quo
717
+ * baseline W8 pins as `DRIFT`/`accepted`. */
718
+ const bothAbsent = !isRealIntentVerdict(baseIntent) && !isRealIntentVerdict(headIntent);
719
+ const classification = classifyEvolution({
720
+ observed,
721
+ codeDrift: transition.codeDrift === true,
722
+ ...(driftComparable
723
+ ? {
724
+ violations: { introduced: violationsIntroduced },
725
+ driftFindingsResolved,
726
+ debtResolved: "resolved" in debt ? debt.resolved : [],
727
+ }
728
+ : {}),
729
+ ...(!driftComparable && !bothAbsent
730
+ ? {
731
+ violations: {
732
+ unknown: [{ id: "intent", reason: intentIncomparableReason(baseIntent, headIntent) }],
733
+ },
734
+ }
735
+ : {}),
736
+ ...(declaredConstraints.length > 0 ? { declaredConstraints } : {}),
737
+ adrBase,
738
+ adrHead,
739
+ });
740
+
741
+ // The richer affected-decisions lineage: which ADR each head-side fitness
742
+ // id binds to, resolved against the head registry. `classifyEvolution`
743
+ // owns the DECISION_CHANGE predicate (`classification.affected.decisions`);
744
+ // this reuses the shared resolver for the per-id binding detail.
745
+ let affectedLineage;
746
+ const headAdr = toEvidence.adr;
747
+ const headRegistry =
748
+ headAdr !== undefined &&
749
+ headAdr !== null &&
750
+ typeof headAdr.note !== "string" &&
751
+ headAdr.byId instanceof Map
752
+ ? { records: headAdr.records, byId: headAdr.byId }
753
+ : null;
754
+ if (headRegistry !== null) {
755
+ affectedLineage = computeAffectedDecisions(
756
+ headRegistry,
757
+ headDecisions.map((d) => ({ id: d.name, verdict: d.verdict })),
758
+ [],
759
+ );
760
+ }
761
+
762
+ const notes = [...classification.notes, ...transition.notes, ...directionNotes];
763
+ const affected = {
764
+ ...classification.affected,
765
+ ...(affectedLineage !== undefined ? { lineage: affectedLineage.lineage } : {}),
766
+ };
767
+
768
+ return {
769
+ observed,
770
+ findings,
771
+ debt,
772
+ fitness,
773
+ coverage: coverageAnswer,
774
+ ...(classification.classifications.length > 0
775
+ ? { classifications: classification.classifications }
776
+ : { classifications: [] }),
777
+ disposition: classification.disposition,
778
+ affected,
779
+ notes: [...new Set(notes)],
780
+ };
781
+ }
782
+
783
+ /**
784
+ * Assembles the transition EvolutionEvent for one revision pair (design §3/§4)
785
+ * — the record `writeEvent` persists when `--event-out` is set. The event's
786
+ * identity (`id`/`dedupeKey`) derives from `{base, head}` alone, so only the
787
+ * full SHA + snapshot id ride in those two fields: a re-run over the same
788
+ * pair is byte-identical and the store proves idempotency. Wall-clock-bound
789
+ * fields (`recordedAt`) are carried but never key the event, mirroring
790
+ * delta.mjs's assembly.
791
+ *
792
+ * @param {object} from The base snapshot.
793
+ * @param {object} to The head snapshot.
794
+ * @param {object} transition The `evolution.transitions[i]` record.
795
+ * @param {object} comparison The comparison object for this pair.
796
+ * @returns {object} The event, minus `dedupeKey`/`id` (the caller sets those
797
+ * before `writeEvent`).
798
+ */
799
+ function buildTransitionEvent(from, to, transition, comparison) {
800
+ const provenance = [];
801
+ if (typeof from.provenance?.commit === "string") {
802
+ provenance.push({ kind: "git-commit", ref: from.provenance.commit });
803
+ }
804
+ if (typeof to.provenance?.commit === "string") {
805
+ provenance.push({ kind: "git-commit", ref: to.provenance.commit });
806
+ }
807
+ return {
808
+ schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
809
+ kind: "transition",
810
+ source: "evolution",
811
+ base: { revision: from.sha, snapshot: from.id },
812
+ head: { revision: to.sha, snapshot: to.id },
813
+ recordedAt: recordOrigin({
814
+ by: "evolution",
815
+ tool: `archkeep:v${TOOL_VERSION}`,
816
+ clock: { now: referenceTime },
817
+ }),
818
+ observed: comparison.observed,
819
+ affected: comparison.affected,
820
+ findings: comparison.findings,
821
+ fitness: comparison.fitness,
822
+ debt: comparison.debt,
823
+ classifications: comparison.classifications,
824
+ disposition: comparison.disposition,
825
+ notes: comparison.notes,
826
+ provenance,
827
+ };
828
+ }
829
+
830
+ /**
831
+ * Aggregates every transition comparison into one `result.summary` (design
832
+ * §8). The disposition is the worst across transitions; classifications and
833
+ * affected identities are unique sorted unions. An axis is unioned across
834
+ * transitions ONLY when every transition is comparable — an incomparable
835
+ * axis at any transition is surfaced as `{available: false, reason}` naming
836
+ * the transition index, never folded into a fabricated clean aggregate.
837
+ *
838
+ * @param {object[]} comparisons The per-transition comparison objects.
839
+ * @returns {object} The summary.
840
+ */
841
+ function buildEvolutionSummary(comparisons) {
842
+ const dispositionRank = { accepted: 1, rejected: 2, "no-verdict": 3 };
843
+ let disposition = "accepted";
844
+ for (const comparison of comparisons) {
845
+ if ((dispositionRank[comparison.disposition] ?? 1) > dispositionRank[disposition]) {
846
+ disposition = comparison.disposition;
847
+ }
848
+ }
849
+
850
+ const unique = (items) => [...new Set(items)].sort();
851
+ const unionAxis = (axis) => {
852
+ // `{available: false}` markers propagate: the summary of an incomparable
853
+ // axis is itself incomparable, never a union that hides the gap.
854
+ const markerIndex = comparisons.findIndex(
855
+ (c) => c[axis] !== undefined && c[axis].available === false,
856
+ );
857
+ if (markerIndex !== -1) {
858
+ return {
859
+ available: false,
860
+ reason: `transition ${markerIndex} not comparable: ${comparisons[markerIndex][axis].reason}`,
861
+ };
862
+ }
863
+ const collect = (field) =>
864
+ unique(comparisons.flatMap((c) => (c[axis] !== undefined ? (c[axis][field] ?? []) : [])));
865
+ if (axis === "fitness") {
866
+ // Fitness aggregates on verdictDeltas: one delta per fitness id across
867
+ // the whole range. The id's summary base/head are the range's first base
868
+ // and last head ONLY when the id carries a real verdict on both sides of
869
+ // EVERY transition that declares it — a transition that never judges the
870
+ // id (or carries an incomparable marker) must surface as a marker naming
871
+ // the first broken transition, never as a fabricated pass→pass.
872
+ const ids = unique(
873
+ comparisons.flatMap((c) => (c.fitness.verdictDeltas ?? []).map((d) => d.id)),
874
+ );
875
+ const verdictDeltas = ids.map((id) => {
876
+ const deltas = comparisons.flatMap((c) =>
877
+ (c.fitness.verdictDeltas ?? [])
878
+ .filter((d) => d.id === id)
879
+ .map((d) => ({ delta: d, index: comparisons.indexOf(c) })),
880
+ );
881
+ // The first transition that declares the id without a real verdict on
882
+ // both sides names the break; an id never declared is its own marker.
883
+ const broken = deltas.find(
884
+ ({ delta }) =>
885
+ delta.base === undefined ||
886
+ delta.base === null ||
887
+ typeof delta.base !== "string" ||
888
+ delta.head === undefined ||
889
+ delta.head === null ||
890
+ typeof delta.head !== "string",
891
+ );
892
+ if (broken !== undefined) {
893
+ return {
894
+ id,
895
+ base: {
896
+ available: false,
897
+ reason: `not declared/judged at transition ${broken.index}`,
898
+ },
899
+ head: {
900
+ available: false,
901
+ reason: `not declared/judged at transition ${broken.index}`,
902
+ },
903
+ };
904
+ }
905
+ if (deltas.length === 0) {
906
+ return {
907
+ id,
908
+ base: { available: false, reason: "never comparable at base" },
909
+ head: { available: false, reason: "never comparable at head" },
910
+ };
911
+ }
912
+ return {
913
+ id,
914
+ base: deltas[0].delta.base,
915
+ head: deltas[deltas.length - 1].delta.head,
916
+ };
917
+ });
918
+ return { verdictDeltas };
919
+ }
920
+ return {
921
+ introduced: collect("introduced"),
922
+ resolved: collect("resolved"),
923
+ ...(axis === "findings" ? { unknown: collect("unknown") } : {}),
924
+ };
925
+ };
926
+
927
+ const findings = unionAxis("findings");
928
+ const debt = unionAxis("debt");
929
+ const fitness = unionAxis("fitness");
930
+
931
+ const observed = {
932
+ architectureChanged: comparisons.filter((c) => c.observed.architectureChanged === true).length,
933
+ projects: {
934
+ added: unique(comparisons.flatMap((c) => c.observed.projects.added.map((p) => p.name ?? p))),
935
+ removed: unique(
936
+ comparisons.flatMap((c) => c.observed.projects.removed.map((p) => p.name ?? p)),
937
+ ),
938
+ changed: unique(
939
+ comparisons.flatMap((c) => c.observed.projects.changed.map((p) => p.name ?? p)),
940
+ ),
941
+ },
942
+ edges: {
943
+ added: unique(
944
+ comparisons.flatMap((c) =>
945
+ c.observed.edges.added.map((e) =>
946
+ e.source && e.target && e.type
947
+ ? `${e.source}→${e.target}:${e.type}`
948
+ : JSON.stringify(e),
949
+ ),
950
+ ),
951
+ ),
952
+ removed: unique(
953
+ comparisons.flatMap((c) =>
954
+ c.observed.edges.removed.map((e) =>
955
+ e.source && e.target && e.type
956
+ ? `${e.source}→${e.target}:${e.type}`
957
+ : JSON.stringify(e),
958
+ ),
959
+ ),
960
+ ),
961
+ },
962
+ // F-EVO-3: a transition whose policy change could not be compared reads
963
+ // as a count of how many changed, never a number that hides the axis was
964
+ // unjudgeable at one point — surface it as a marker naming the transition.
965
+ policyChanged: (() => {
966
+ const unjudgeable = comparisons.findIndex((c) => c.observed.policyChanged === null);
967
+ if (unjudgeable !== -1) {
968
+ return {
969
+ available: false,
970
+ reason: `policy could not be compared at transition ${unjudgeable}`,
971
+ };
972
+ }
973
+ return comparisons.filter((c) => c.observed.policyChanged === true).length;
974
+ })(),
975
+ providerChanged: comparisons.filter((c) => c.observed.providerChanged === true).length,
976
+ };
977
+
978
+ const affected = {
979
+ projects: unique(comparisons.flatMap((c) => c.affected?.projects ?? [])),
980
+ boundaries: unique(comparisons.flatMap((c) => c.affected?.boundaries ?? [])),
981
+ constraints: unique(comparisons.flatMap((c) => c.affected?.constraints ?? [])),
982
+ decisions: unique(comparisons.flatMap((c) => c.affected?.decisions ?? [])),
983
+ };
984
+
985
+ const notes = unique(comparisons.flatMap((c) => c.notes ?? []));
986
+ return {
987
+ transitions: comparisons.length,
988
+ disposition,
989
+ classifications: unique(comparisons.flatMap((c) => c.classifications ?? [])),
990
+ observed,
991
+ affected,
992
+ findings,
993
+ debt,
994
+ fitness,
995
+ notes,
292
996
  };
293
997
  }
294
998
 
@@ -326,19 +1030,22 @@ function releaseWorktree(root, dir, { run = runProcess } = {}) {
326
1030
  * invoked from — the envelope header describes THIS tree, and every git
327
1031
  * question is answered in it; the analyzed revisions are materialized
328
1032
  * elsewhere.
329
- * @param {{base: string, head?: string|null}} range The raw revisions.
1033
+ * @param {{base: string, head?: string|null, eventOut?: string|null}} range The raw
1034
+ * revisions. `eventOut` an optional directory; when set, one EvolutionEvent is
1035
+ * written per revision pair (idempotent, design §3/§4).
330
1036
  * @param {{run?: Function, makeTempRoot?: Function, resolveContext?: Function,
331
1037
  * resolveProvenance?: Function, readGraph?: Function, listFiles?: Function}} [io]
332
1038
  * Injectable seams. `makeTempRoot` defaults to a fresh `mkdtemp` directory
333
1039
  * under the OS temp dir; `readGraph`/`listFiles` thread into every analyzed
334
1040
  * revision's context the way `../../cli.mjs` threads them into one.
335
- * @returns {Promise<{status: "ok", result: object, coverage: object,
1041
+ * @returns {Promise<{status: "ok", result: {base: string, head: string,
1042
+ * revisions: object[], transitions: object[], summary: object}, coverage: object,
336
1043
  * report: {text: string, json: string}}>}
337
1044
  * @throws {Error} on every condition listed in this module's header — an
338
1045
  * unusable selection, an unanalyzable revision, a failed worktree, a git
339
1046
  * failure — never a shorter record for any of them.
340
1047
  */
341
- export async function evolutionCommand(root, { base, head = null }, io = {}) {
1048
+ export async function evolutionCommand(root, { base, head = null, eventOut = null }, io = {}) {
342
1049
  const run = io.run ?? runProcess;
343
1050
  const identity = describeWorkspaceRoot(root);
344
1051
  const provenanceResolver = io.resolveProvenance ?? resolveProvenance;
@@ -415,6 +1122,41 @@ export async function evolutionCommand(root, { base, head = null }, io = {}) {
415
1122
  })),
416
1123
  );
417
1124
 
1125
+ // Wave 3 W7 (design §7/§8): per-transition comparable evidence (the
1126
+ // 8-question comparison) plus, when `--event-out` is set, one transition
1127
+ // EvolutionEvent per revision pair — idempotent, keyed on byte-stable
1128
+ // base/head revisions.
1129
+ const comparisons = [];
1130
+ for (let index = 0; index < evolution.transitions.length; index++) {
1131
+ const transition = /** @type {object} */ (evolution.transitions[index]);
1132
+ const comparison = buildTransitionComparison(
1133
+ snapshots[index],
1134
+ snapshots[index + 1],
1135
+ transition,
1136
+ );
1137
+ transition.comparison = comparison;
1138
+ comparisons.push(comparison);
1139
+
1140
+ if (eventOut) {
1141
+ const event = buildTransitionEvent(
1142
+ snapshots[index],
1143
+ snapshots[index + 1],
1144
+ transition,
1145
+ comparison,
1146
+ );
1147
+ event.dedupeKey = eventDedupeKey(event);
1148
+ event.id = eventId(event);
1149
+ const write = writeEvent(eventOut, event, { root });
1150
+ transition.eventWrite = {
1151
+ dir: eventOut,
1152
+ id: write.id,
1153
+ duplicate: write.duplicate,
1154
+ };
1155
+ }
1156
+ }
1157
+
1158
+ const summary = buildEvolutionSummary(comparisons);
1159
+
418
1160
  const headSnapshot = snapshots[snapshots.length - 1];
419
1161
  const userProvenance = provenanceResolver(root);
420
1162
  const notes = [
@@ -443,8 +1185,19 @@ export async function evolutionCommand(root, { base, head = null }, io = {}) {
443
1185
  const result = {
444
1186
  base: selection.base,
445
1187
  head: selection.head,
446
- revisions: snapshots.map(({ sha, id }) => ({ commit: sha, id })),
1188
+ // Wave 3 W7 (design §7): each revision also carries its comparable
1189
+ // evidence — the intent verdict over its own graph, the fitness verdicts
1190
+ // its own law judges, and the ADR registry its own tree records — so a
1191
+ // trend report can name its basis per revision. Additive: `{commit, id}`
1192
+ // are unchanged, and a revision that supplied nothing to compare omits
1193
+ // the key entirely (an absent `evidence` reads as incomparable).
1194
+ revisions: snapshots.map(({ sha, id, evidence }) => ({
1195
+ commit: sha,
1196
+ id,
1197
+ ...(evidence == null ? {} : { evidence }),
1198
+ })),
447
1199
  transitions: evolution.transitions,
1200
+ summary,
448
1201
  };
449
1202
 
450
1203
  const envelope = jsonEnvelope({