@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
@@ -43,6 +43,20 @@
43
43
  * instead. Dirty base provenance and a dirty head are notes too: weaker
44
44
  * evidence, not unreadable evidence.
45
45
  *
46
+ * (wave 3, additive) Each compare run maps its own capture output into the
47
+ * canonical evolution event (`../governance/evolution-event.mjs`, design §1):
48
+ * a `kind: "transition"`, `source: "delta"` record whose classifications come
49
+ * from the ONE predicate home `classifyEvolution` (through
50
+ * `./delta-classify.mjs`'s `classifyDeltaEvolution`), whose observed/findings/
51
+ * fitness facts are mapped — never re-derived — from the structural signal,
52
+ * the policy comparison and the violation classification this run already
53
+ * computed, and whose disposition is a function of the delta's own verb
54
+ * (`deltaDisposition`). The event rides the result envelope as the additive
55
+ * `classifications`/`affected` fields, and `--event-out <dir>` (owned by
56
+ * `../../cli.mjs`) additionally appends the record to the event store
57
+ * (`../governance/evolution-store.mjs`). Absent the flag, no file is written
58
+ * and nothing else about the run changes byte-for-byte.
59
+ *
46
60
  * This module computes and returns; `../../cli.mjs`'s `runDelta` owns argv,
47
61
  * output destination and the process exit code (`./README.md`).
48
62
  */
@@ -51,20 +65,38 @@ import { createRequire } from "node:module";
51
65
  import { isWholeFileFailure } from "../analysis/source-util.mjs";
52
66
  import { stripTrailingSlashes } from "../path-util.mjs";
53
67
  import { referenceTime } from "../governance/clock.mjs";
68
+ import {
69
+ eventDedupeKey,
70
+ eventId,
71
+ EVOLUTION_EVENT_SCHEMA_VERSION,
72
+ } from "../governance/evolution-event.mjs";
73
+ import { writeEvent } from "../governance/evolution-store.mjs";
74
+ import { recordOrigin } from "../governance/provenance-record.mjs";
54
75
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
55
76
  import { buildDecision } from "../report/evidence.mjs";
56
77
  import { formatDeltaReport } from "../report/delta-text.mjs";
57
78
  import { formatDeltaSarif } from "../report/sarif.mjs";
58
79
  import { evaluateRun } from "../rules/index.mjs";
80
+ import { judgeIntent } from "../architecture-intent/judge.mjs";
81
+ import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
82
+ import { debtChangeDiff } from "../governance/debt-ledger.mjs";
59
83
  import { customRulesForDelta, declaresCustomRules } from "./custom-rules.mjs";
60
- import { classifyCustomFindings, classifyDelta } from "./delta-classify.mjs";
84
+ import {
85
+ classifyCustomFindings,
86
+ classifyDelta,
87
+ classifyDeltaEvolution,
88
+ deltaFindings,
89
+ deltaVerdictDeltas,
90
+ edgeEvolutionIdentity,
91
+ } from "./delta-classify.mjs";
61
92
  import {
62
93
  buildEvidenceSnapshot,
63
94
  providerMismatch,
64
95
  readEvidenceSnapshot,
65
96
  serializeEvidenceSnapshot,
66
97
  } from "./delta-snapshot.mjs";
67
- import { computePolicyFingerprint } from "./graph.mjs";
98
+ import { computeDiff } from "./diff.mjs";
99
+ import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
68
100
  import { resolveProvenance } from "./provenance.mjs";
69
101
  import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
70
102
 
@@ -275,6 +307,40 @@ export function sourceProjectAttributor(headGraph, baselineProjects) {
275
307
  };
276
308
  }
277
309
 
310
+ /**
311
+ * The delta event's disposition, mapped from the delta's OWN verb contract —
312
+ * the status `deltaCommand` already folds — so the event's evaluative stance
313
+ * is a function of the run consumers already know, never a second opinion:
314
+ *
315
+ * - `no-verdict` status (any unclassifiable item — violation, unresolvable
316
+ * record, or custom finding) ⇒ `no-verdict`, never a fabricated
317
+ * accepted/rejected;
318
+ * - `findings` status ⇒ `rejected`, unconditionally — `findings` means some
319
+ * introduced gating finding survived the current waiver table, WHATEVER
320
+ * class it carries. The classifications are deliberately NOT consulted:
321
+ * a custom-rule-only introduced finding never reaches the VIOLATION
322
+ * predicate, so a classifications scan would read "accepted" on an exit-1
323
+ * run — the silent direction;
324
+ * - everything else — a clean comparable capture (`ok`, `[]` classifications),
325
+ * an `ok` capture with a fact class (REPAIR, CHANGE, DRIFT,
326
+ * DECISION_CHANGE — each accepted by the vocabulary `classification` earns),
327
+ * or an `ok` capture holding a WAIVED violation (a waiver is a tracked
328
+ * acceptance — which is exactly what kept the gate `ok`) ⇒ `accepted`.
329
+ *
330
+ * The two refusals that can never reach this mapping — an unjudgeable head
331
+ * and a provider mismatch — THROW before any event exists, so a delta that
332
+ * could not be computed has no record at all, never a record with a guessed
333
+ * disposition.
334
+ *
335
+ * @param {{status: "ok"|"findings"|"no-verdict"}} input
336
+ * @returns {"accepted"|"rejected"|"no-verdict"}
337
+ */
338
+ export function deltaDisposition({ status }) {
339
+ if (status === "no-verdict") return "no-verdict";
340
+ if (status === "findings") return "rejected";
341
+ return "accepted";
342
+ }
343
+
278
344
  /** First eight hex characters of a fingerprint, for prose that names one. */
279
345
  const short = (fingerprint) =>
280
346
  typeof fingerprint === "string" ? fingerprint.slice(0, 8) : String(fingerprint);
@@ -305,27 +371,45 @@ const short = (fingerprint) =>
305
371
  * (suppressions key on a `messageId` custom findings do not have); an
306
372
  * unclassifiable one is a no-verdict. This is also why the function is async:
307
373
  * the wasm host is.
308
- *
309
374
  * @param {string} baselinePath Absolute path to the evidence snapshot.
310
375
  * @param {object} commandContext From `resolveCommandContext`.
311
376
  * @param {{config: object|null, readBaseline?: (path: string) => object,
312
- * now?: string, readArtifact?: (artifact: string) => Uint8Array|null,
377
+ * now?: string, eventOut?: string|null,
378
+ * loadIntentOverride?: (root: string, opts?: object) => Promise<object|undefined>,
379
+ * readArtifact?: (artifact: string) => Uint8Array|null,
313
380
  * timeoutMs?: number}} io The resolved boundary config (required
314
381
  * — both sides are re-judged under it), an injectable baseline reader, the
315
382
  * one shared reference instant (defaults to the shared governance clock),
316
- * and the custom-rule host's two injectable seams, passed through to
383
+ * `eventOut` the directory an evolution event is appended to when given
384
+ * (absent or `null` ⇒ no event file, byte-identical behavior),
385
+ * `loadIntentOverride` — the architecture-intent reader the event's `debt`
386
+ * sub-ledger judges over this run's base and head graphs (defaults to
387
+ * `loadIntent`; absent intent ⇒ no ids, an in-band note says so), and the
388
+ * custom-rule host's two injectable seams, passed through to
317
389
  * `customRulesForDelta`.
318
390
  * @returns {Promise<{status: "ok"|"findings"|"no-verdict", delta: object,
319
- * coverage: object, report: {text: string, json: string, sarif: string}}>}
391
+ * coverage: object,
392
+ * eventWrite: {id: string, duplicate: boolean}|null,
393
+ * report: {text: string, json: string, sarif: string}}>} `delta` carries
394
+ * the additive `classifications`/`affected` fields (design §1); `eventWrite`
395
+ * is `null` unless `eventOut` was given, then the store's answer for the
396
+ * event that was (or already was) recorded.
320
397
  * @throws {Error} on every refusal the module header lists, and on a
321
398
  * custom-rule LOAD failure (`./custom-rules.mjs` argues the split).
322
399
  */
323
400
  export async function deltaCommand(
324
401
  baselinePath,
325
402
  commandContext,
326
- { config, readBaseline = readEvidenceSnapshot, now = referenceTime(), ...customRuleIo },
403
+ {
404
+ config,
405
+ readBaseline = readEvidenceSnapshot,
406
+ now = referenceTime(),
407
+ eventOut = null,
408
+ loadIntentOverride,
409
+ ...customRuleIo
410
+ },
327
411
  ) {
328
- const { root, provider, marker, graph, analysis } = commandContext;
412
+ const { root, provider, marker, graph, analysis, tracked } = commandContext;
329
413
 
330
414
  refuseUnjudgeableHead(commandContext, "compute a delta");
331
415
  if (!config) {
@@ -415,6 +499,53 @@ export async function deltaCommand(
415
499
  );
416
500
  }
417
501
 
502
+ // The event's structural signal: what moved between the two graphs the
503
+ // delta already holds, computed through `computeDiff` — the ONE shared
504
+ // structural vocabulary (`./diff.mjs`, shared with `trajectory`) — so a
505
+ // project or edge added/removed/changed is a fact about the evidence, never
506
+ // a second spelling of "changed". The head side is rebuilt by the same
507
+ // `buildProjects`/`buildDependencies` the snapshot stores
508
+ // (`./delta-snapshot.mjs`), which is what makes the two sides comparable.
509
+ const structuralDiff = computeDiff(baseline.graph, {
510
+ projects: buildProjects(graph.nodes),
511
+ dependencies: buildDependencies(graph.dependencies),
512
+ });
513
+ const structural = {
514
+ projects: {
515
+ added: structuralDiff.addedProjects.map((project) => project.name),
516
+ removed: structuralDiff.removedProjects.map((project) => project.name),
517
+ changed: structuralDiff.changedProjects.map((project) => project.name),
518
+ },
519
+ edges: {
520
+ added: structuralDiff.addedEdges.map(edgeEvolutionIdentity),
521
+ removed: structuralDiff.removedEdges.map(edgeEvolutionIdentity),
522
+ },
523
+ };
524
+ const structureChanged =
525
+ structural.projects.added.length +
526
+ structural.projects.removed.length +
527
+ structural.projects.changed.length +
528
+ structural.edges.added.length +
529
+ structural.edges.removed.length >
530
+ 0;
531
+ // The delta's `codeDrift` signal (design §2): provenance advanced — both
532
+ // sides carry commits, they differ, and neither side was captured dirty (a
533
+ // dirty tree is weaker evidence, not a claim about the commit it names) —
534
+ // the architecture did not move, and the policy was comparable AND
535
+ // unchanged. `policyChanged === null` (one-sided) never reads as "the
536
+ // same", and a policy change is disclosed, not folded into drift.
537
+ const baseCommit = baseline.provenance?.commit;
538
+ const headCommit = headProvenance?.commit;
539
+ const provenanceAdvanced =
540
+ typeof baseCommit === "string" &&
541
+ typeof headCommit === "string" &&
542
+ baseCommit !== headCommit &&
543
+ baseline.provenance?.dirty !== true &&
544
+ headProvenance?.dirty !== true &&
545
+ meta.crossRepo !== true &&
546
+ meta.provenanceOneSided !== true;
547
+ const codeDrift = provenanceAdvanced && !structureChanged && meta.policyChanged === false;
548
+
418
549
  // The custom-rule half, present exactly when a side declares rules: judged
419
550
  // two-sided where the law is identical, `unknown` with a mandatory reason
420
551
  // everywhere else (`./custom-rules.mjs`'s `customRulesForDelta` owns the
@@ -480,6 +611,29 @@ export async function deltaCommand(
480
611
  }
481
612
  }
482
613
 
614
+ // The §1 mapping: the delta's own signals through `classifyEvolution` — one
615
+ // definition (`./delta-classify.mjs`'s `classifyDeltaEvolution` maps, never
616
+ // re-decides a class). The result's additive `classifications`/`affected`
617
+ // ride the envelope, and the same classification feeds the event when
618
+ // `eventOut` is given.
619
+ const deltaPayload = {
620
+ violations: classification.violations,
621
+ unresolvable: classification.unresolvable,
622
+ policyChanged: meta.policyChanged,
623
+ // The one-sided/advanced facts ride the payload (never the event's
624
+ // `observed`, which is the stored record): classifyEvolution needs them
625
+ // as input facts — `policyOneSided` is never derived from `policyChanged
626
+ // === null`, because both-sides-absent is also `null` (F-HIST-1).
627
+ policyOneSided: meta.policyOneSided,
628
+ provenanceChanged: meta.provenanceChanged,
629
+ ...(custom === null ? {} : { customRules: custom }),
630
+ };
631
+ const evolution = classifyDeltaEvolution(deltaPayload, {
632
+ projects: structural.projects,
633
+ edges: structural.edges,
634
+ codeDrift,
635
+ });
636
+
483
637
  const { violations, unresolvable } = classification;
484
638
  const introducedWaived = violations.introduced.filter((entry) => entry.waived === true).length;
485
639
  const introducedNotWaived = violations.introduced.length - introducedWaived;
@@ -577,9 +731,115 @@ export async function deltaCommand(
577
731
  },
578
732
  violations,
579
733
  unresolvable,
734
+ classifications: evolution.classifications,
735
+ affected: evolution.affected,
580
736
  ...(custom === null ? {} : { customRules: custom }),
581
737
  };
582
738
 
739
+ // The evolution event (design §1), built by mapping — never re-deriving —
740
+ // the capture output the run already holds: `observed`/`affected`/
741
+ // `findings`/`fitness` come from the structural signal, the §1 mapping, and
742
+ // the classification above; `classifications` come from the same mapping;
743
+ // the disposition is a function of the delta's own verb through
744
+ // `deltaDisposition`. `recordedAt` carries the SAME injected reference
745
+ // instant the run judged waivers at — one clock, one transition. Written
746
+ // only when `eventOut` is given: absent ⇒ no file, byte-identical behavior.
747
+ /** @type {{id: string, duplicate: boolean}|null} */
748
+ let eventWrite = null;
749
+ if (eventOut !== null && eventOut !== undefined) {
750
+ // F-delta-event-id: an evolution event is only written from a reproducible
751
+ // identity — a committed, clean head and a clean base. A commitless head
752
+ // has no revision to name, and a dirty tree names a commit its evidence
753
+ // does not back; either way TWO distinct evidence states collapse onto ONE
754
+ // event id, so a later transition is silently lost or aliased (the silent
755
+ // direction). Refuse loudly instead. The same run without `--event-out`
756
+ // stays a byte-identical in-memory delta.
757
+ if (typeof headCommit !== "string") {
758
+ throw new Error(
759
+ "archkeep: refusing to write a delta event without a committed head — a commitless " +
760
+ "head has no reproducible event identity, and every distinct head state would " +
761
+ "collide on one event id. Commit the head, or capture without --event-out.",
762
+ );
763
+ }
764
+ if (baseline.provenance?.dirty === true || headProvenance?.dirty === true) {
765
+ throw new Error(
766
+ "archkeep: refusing to write a delta event from a dirty working tree — the event " +
767
+ "would name a commit whose evidence is uncommitted, and distinct uncommitted " +
768
+ "states would collide on one event id. Commit both sides first.",
769
+ );
770
+ }
771
+ // The architecture-debt sub-ledger (design §8): judged by re-running the
772
+ // current intent over this run's base and head graphs — a drift finding
773
+ // present at head but not base is introduced; one gone is resolved. Both
774
+ // ENGINE graphs exist here (the base from the captured snapshot, the head
775
+ // from the live capture), so unlike `change` there is no unproven-base
776
+ // gate; the fail-closed branches are an absent intent and an unjudgeable
777
+ // one, each emitting no ids and an in-band note rather than a fabricated
778
+ // clean ledger.
779
+ /** @type {{introduced: string[], resolved: string[], note?: string}} */
780
+ let debt;
781
+ try {
782
+ const archIntent = await (loadIntentOverride ?? loadIntent)(root, { tracked });
783
+ if (archIntent === undefined || archIntent === null) {
784
+ debt = {
785
+ introduced: [],
786
+ resolved: [],
787
+ note: `no '${INTENT_FILE}' tracked — the delta event carries no architecture debt ids`,
788
+ };
789
+ } else {
790
+ const baseVerdict = judgeIntent(archIntent, baseGraph);
791
+ const headVerdict = judgeIntent(archIntent, graph);
792
+ debt = debtChangeDiff(baseVerdict, headVerdict);
793
+ }
794
+ } catch (error) {
795
+ debt = {
796
+ introduced: [],
797
+ resolved: [],
798
+ note: `architecture intent could not be judged — no debt ids emitted (${error.message})`,
799
+ };
800
+ }
801
+ const event = {
802
+ schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
803
+ kind: "transition",
804
+ source: "delta",
805
+ base: {
806
+ ...(typeof baseCommit === "string" ? { revision: baseCommit } : {}),
807
+ // The evidence ref is the baseline file this run actually compared
808
+ // against — a pointer into the evidence, never a graph.
809
+ evidence: baselinePath,
810
+ },
811
+ head: typeof headCommit === "string" ? { revision: headCommit } : {},
812
+ recordedAt: recordOrigin({
813
+ by: "cli",
814
+ tool: `archkeep:v${TOOL_VERSION}`,
815
+ clock: { now: () => now },
816
+ }),
817
+ observed: {
818
+ architectureChanged: structureChanged,
819
+ projects: structural.projects,
820
+ edges: structural.edges,
821
+ policyChanged: meta.policyChanged,
822
+ // A delta that completed is a delta whose provider matched — the
823
+ // mismatch refusal above throws before any event could exist.
824
+ providerChanged: false,
825
+ },
826
+ affected: evolution.affected,
827
+ findings: deltaFindings(deltaPayload),
828
+ fitness: { verdictDeltas: deltaVerdictDeltas(deltaPayload) },
829
+ debt,
830
+ classifications: evolution.classifications,
831
+ disposition: deltaDisposition({ status }),
832
+ notes: [...notes, ...evolution.notes],
833
+ provenance: [
834
+ ...(typeof baseCommit === "string" ? [{ kind: "git-commit", ref: baseCommit }] : []),
835
+ ...(typeof headCommit === "string" ? [{ kind: "git-commit", ref: headCommit }] : []),
836
+ ],
837
+ };
838
+ event.dedupeKey = eventDedupeKey(event);
839
+ event.id = eventId(event);
840
+ eventWrite = writeEvent(eventOut, event, { root });
841
+ }
842
+
583
843
  const envelope = jsonEnvelope({
584
844
  command: "delta",
585
845
  context: { root, provider, marker, provenance: headProvenance },
@@ -592,6 +852,7 @@ export async function deltaCommand(
592
852
 
593
853
  return {
594
854
  status,
855
+ eventWrite,
595
856
  delta: result,
596
857
  coverage,
597
858
  report: {