@ecoma-io/archkeep 0.24.1 → 0.26.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 (125) hide show
  1. package/cli.mjs +166 -506
  2. package/commands.mjs +7 -1
  3. package/package.json +1 -1
  4. package/src/architecture-intent/judge.mjs +1 -1
  5. package/src/architecture-intent/model.mjs +34 -0
  6. package/src/canonical.mjs +2 -1
  7. package/src/commands/adr-for-workspace.mjs +63 -0
  8. package/src/commands/analyze-capability.mjs +13 -0
  9. package/src/commands/change-intent.mjs +11 -0
  10. package/src/commands/change.mjs +315 -55
  11. package/src/commands/check-capability.mjs +13 -0
  12. package/src/commands/check.mjs +13 -3
  13. package/src/commands/compare-capability.mjs +20 -0
  14. package/src/commands/completeness.mjs +12 -0
  15. package/src/commands/context-command.mjs +31 -0
  16. package/src/commands/coverage-verdict.mjs +15 -2
  17. package/src/commands/debt.mjs +30 -0
  18. package/src/commands/decisions.mjs +59 -10
  19. package/src/commands/delta-snapshot.mjs +25 -4
  20. package/src/commands/delta.mjs +301 -67
  21. package/src/commands/diff.mjs +48 -9
  22. package/src/commands/discover.mjs +37 -0
  23. package/src/commands/drift.mjs +45 -0
  24. package/src/commands/evolution.mjs +22 -2
  25. package/src/commands/explain-capability.mjs +11 -0
  26. package/src/commands/explain.mjs +24 -0
  27. package/src/commands/fitness.mjs +119 -10
  28. package/src/commands/govern-capability.mjs +16 -0
  29. package/src/commands/graph.mjs +39 -0
  30. package/src/commands/health.mjs +34 -0
  31. package/src/commands/history.mjs +119 -2
  32. package/src/commands/impact.mjs +23 -0
  33. package/src/commands/inspect-capability.mjs +16 -0
  34. package/src/commands/plan-context-command.mjs +35 -0
  35. package/src/commands/policy.mjs +42 -4
  36. package/src/commands/provenance-command.mjs +15 -24
  37. package/src/commands/report.mjs +38 -0
  38. package/src/commands/rules-capability.mjs +16 -0
  39. package/src/commands/rules.mjs +3 -1
  40. package/src/commands/scenario.mjs +23 -0
  41. package/src/commands/snapshot-meta.mjs +33 -0
  42. package/src/commands/trajectory.mjs +35 -0
  43. package/src/commands/waivers.mjs +30 -0
  44. package/src/corpus/goldens/.gitkeep +0 -0
  45. package/src/corpus/goldens/adr.json +62 -0
  46. package/src/corpus/goldens/adr.text +6 -0
  47. package/src/corpus/goldens/change.json +98 -0
  48. package/src/corpus/goldens/change.text +6 -0
  49. package/src/corpus/goldens/check.json +90 -0
  50. package/src/corpus/goldens/check.sarif +479 -0
  51. package/src/corpus/goldens/check.text +9 -0
  52. package/src/corpus/goldens/context.json +47 -0
  53. package/src/corpus/goldens/context.text +6 -0
  54. package/src/corpus/goldens/debt.json +59 -0
  55. package/src/corpus/goldens/debt.text +7 -0
  56. package/src/corpus/goldens/decisions.json +74 -0
  57. package/src/corpus/goldens/decisions.text +7 -0
  58. package/src/corpus/goldens/delta.json +94 -0
  59. package/src/corpus/goldens/delta.sarif +472 -0
  60. package/src/corpus/goldens/delta.text +5 -0
  61. package/src/corpus/goldens/diff.json +58 -0
  62. package/src/corpus/goldens/diff.text +4 -0
  63. package/src/corpus/goldens/discover.json +77 -0
  64. package/src/corpus/goldens/discover.text +10 -0
  65. package/src/corpus/goldens/drift.json +43 -0
  66. package/src/corpus/goldens/drift.text +3 -0
  67. package/src/corpus/goldens/evolution.json +328 -0
  68. package/src/corpus/goldens/evolution.text +32 -0
  69. package/src/corpus/goldens/explain.json +60 -0
  70. package/src/corpus/goldens/explain.text +7 -0
  71. package/src/corpus/goldens/fitness.json +45 -0
  72. package/src/corpus/goldens/fitness.text +3 -0
  73. package/src/corpus/goldens/graph.json +78 -0
  74. package/src/corpus/goldens/graph.text +10 -0
  75. package/src/corpus/goldens/health.json +72 -0
  76. package/src/corpus/goldens/health.text +10 -0
  77. package/src/corpus/goldens/help.text +104 -0
  78. package/src/corpus/goldens/history.json +43 -0
  79. package/src/corpus/goldens/history.text +4 -0
  80. package/src/corpus/goldens/impact.json +327 -0
  81. package/src/corpus/goldens/impact.text +10 -0
  82. package/src/corpus/goldens/provenance.json +453 -0
  83. package/src/corpus/goldens/provenance.text +14 -0
  84. package/src/corpus/goldens/reconcile.json +168 -0
  85. package/src/corpus/goldens/reconcile.text +3 -0
  86. package/src/corpus/goldens/report.json +168 -0
  87. package/src/corpus/goldens/report.text +42 -0
  88. package/src/corpus/goldens/rules verify.json +0 -0
  89. package/src/corpus/goldens/rules verify.text +0 -0
  90. package/src/corpus/goldens/scenario.json +441 -0
  91. package/src/corpus/goldens/scenario.text +28 -0
  92. package/src/corpus/goldens/trajectory.json +83 -0
  93. package/src/corpus/goldens/trajectory.text +10 -0
  94. package/src/corpus/goldens/usage-error.text +105 -0
  95. package/src/corpus/goldens/waivers.json +40 -0
  96. package/src/corpus/goldens/waivers.text +1 -0
  97. package/src/corpus/goldens-lsp/initialize.result.json +15 -0
  98. package/src/corpus/goldens-lsp/publish-cleared-empty.json +4 -0
  99. package/src/corpus/goldens-lsp/publish-fixed-empty.json +5 -0
  100. package/src/corpus/goldens-lsp/publish-unparseable.json +54 -0
  101. package/src/corpus/goldens-lsp/publish-violation.json +22 -0
  102. package/src/corpus/goldens-lsp/register-watched-files.json +72 -0
  103. package/src/custom-rules/values.mjs +1 -4
  104. package/src/go-work.mjs +8 -23
  105. package/src/governance/evolution-event.mjs +15 -0
  106. package/src/governance/evolution-store.mjs +59 -31
  107. package/src/governance/fitness-registry.mjs +14 -1
  108. package/src/governance/provenance-record.mjs +0 -152
  109. package/src/governance/verdict.mjs +62 -7
  110. package/src/intent/intent-manifest.json +22 -16
  111. package/src/lsp/server.mjs +5 -8
  112. package/src/lsp/workspace-index.mjs +55 -220
  113. package/src/options.mjs +10 -0
  114. package/src/providers/native/discover.mjs +13 -12
  115. package/src/providers/native/model.mjs +8 -3
  116. package/src/providers/nx-static.mjs +231 -0
  117. package/src/report/json.mjs +7 -4
  118. package/src/report/sarif.mjs +8 -3
  119. package/src/rules/edge-constraints.mjs +9 -0
  120. package/src/rules/index.mjs +26 -10
  121. package/src/rules/messages.mjs +64 -14
  122. package/src/rules/specifiers.mjs +21 -20
  123. package/src/tsconfig-paths.mjs +8 -14
  124. package/src/values.mjs +11 -0
  125. package/src/verdict.mjs +127 -16
@@ -105,10 +105,15 @@ import {
105
105
  serializeEvidenceSnapshot,
106
106
  } from "./delta-snapshot.mjs";
107
107
  import { computeDiff } from "./diff.mjs";
108
+ import { isAbsolute, resolve } from "node:path";
108
109
  import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
110
+ import { eventSnapshotSide } from "./history.mjs";
109
111
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
110
112
  import { resolveProvenance } from "./provenance.mjs";
111
- import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
113
+ import { resolveCommandContext } from "./context.mjs";
114
+ import { compareSnapshotMetadata, dirtyBaselineNote, dirtyHeadNote } from "./snapshot-meta.mjs";
115
+ import { describe } from "../values.mjs";
116
+ import { resolvePolicy } from "./policy.mjs";
112
117
 
113
118
  const require = createRequire(import.meta.url);
114
119
  /** @type {{name: string, version: string}} */
@@ -381,11 +386,15 @@ export function sourceProjectAttributor(headGraph, baselineProjects) {
381
386
  * a custom-rule-only introduced finding never reaches the VIOLATION
382
387
  * predicate, so a classifications scan would read "accepted" on an exit-1
383
388
  * run — the silent direction;
384
- * - everything else — a clean comparable capture (`ok`, `[]` classifications),
385
- * an `ok` capture with a fact class (REPAIR, CHANGE, DRIFT,
386
- * DECISION_CHANGE — each accepted by the vocabulary `classification` earns),
387
- * or an `ok` capture holding a WAIVED violation (a waiver is a tracked
388
- * acceptance — which is exactly what kept the gate `ok`) ⇒ `accepted`.
389
+ * - `ok` status — a clean comparable capture (`[]` classifications), an `ok`
390
+ * capture with a fact class (REPAIR, CHANGE, DRIFT, DECISION_CHANGE — each
391
+ * accepted by the vocabulary `classification` earns), or an `ok` capture
392
+ * holding a WAIVED violation (a waiver is a tracked acceptance — which is
393
+ * exactly what kept the gate `ok`) ⇒ `accepted`;
394
+ * - any OTHER status — a typo, a case- or whitespace-mangled spelling, an
395
+ * absent field — THROWS naming the input, the input latch `verdictFor`
396
+ * holds. There is no fourth status to fold, and folding a stranger to a
397
+ * verdict would be the silent direction.
389
398
  *
390
399
  * The two refusals that can never reach this mapping — an unjudgeable head
391
400
  * and a provider mismatch — THROW before any event exists, so a delta that
@@ -394,12 +403,162 @@ export function sourceProjectAttributor(headGraph, baselineProjects) {
394
403
  *
395
404
  * @param {{status: "ok"|"findings"|"no-verdict"}} input
396
405
  * @returns {"accepted"|"rejected"|"no-verdict"}
406
+ * @throws {Error} On any `status` outside the three-verb contract, naming the
407
+ * input.
397
408
  */
398
409
  export function deltaDisposition({ status }) {
399
410
  // used by its own test
400
411
  if (status === "no-verdict") return "no-verdict";
401
412
  if (status === "findings") return "rejected";
402
- return "accepted";
413
+ if (status === "ok") return "accepted";
414
+ throw new Error(
415
+ `deltaDisposition: unknown delta status ${describe(status)} — expected "ok", "findings", ` +
416
+ `or "no-verdict". A status outside the delta verb's own contract has no disposition; ` +
417
+ `guessing one would be the silent direction. This is a bug in archkeep, not a fact about ` +
418
+ `the workspace.`,
419
+ );
420
+ }
421
+
422
+ /**
423
+ * The input latch for `deltaFold`: names the first counted bucket that is not
424
+ * an array, or `null` when every one is. A returned problem becomes the
425
+ * fold's refusal — never a throw, because the refusal is a fact about the run
426
+ * the command reports in-band, the same lane an `unknown` classification
427
+ * takes.
428
+ *
429
+ * Every bucket the COMMAND counts is validated, not only the ones the fold
430
+ * reads first: the custom-rule notes loop, the §1 mapping, `result.summary`
431
+ * and both report faces count the same buckets downstream of the fold, so a
432
+ * bucket that would crash or silently empty one of them is refused while
433
+ * there is still a verdict to withhold.
434
+ *
435
+ * @param {object} classification From `classifyDelta`.
436
+ * @param {{judged: object[], skipped: object[], removed: string[],
437
+ * findings: {introduced: object[], resolved: object[], unchanged: object[],
438
+ * unknown: object[]}}|null} custom
439
+ * @returns {string|null}
440
+ */
441
+ function deltaFoldInputProblem(classification, custom) {
442
+ const counted = [
443
+ ["classification.violations.introduced", classification?.violations?.introduced],
444
+ ["classification.violations.resolved", classification?.violations?.resolved],
445
+ ["classification.violations.unchanged", classification?.violations?.unchanged],
446
+ ["classification.violations.unknown", classification?.violations?.unknown],
447
+ ["classification.unresolvable.introduced", classification?.unresolvable?.introduced],
448
+ ["classification.unresolvable.resolved", classification?.unresolvable?.resolved],
449
+ ["classification.unresolvable.unchanged", classification?.unresolvable?.unchanged],
450
+ ["classification.unresolvable.unknown", classification?.unresolvable?.unknown],
451
+ ...(custom === null
452
+ ? []
453
+ : [
454
+ ["custom.judged", custom?.judged],
455
+ ["custom.skipped", custom?.skipped],
456
+ ["custom.removed", custom?.removed],
457
+ ["custom.findings.introduced", custom?.findings?.introduced],
458
+ ["custom.findings.resolved", custom?.findings?.resolved],
459
+ ["custom.findings.unchanged", custom?.findings?.unchanged],
460
+ ["custom.findings.unknown", custom?.findings?.unknown],
461
+ ]),
462
+ ];
463
+ for (const [name, value] of counted) {
464
+ if (!Array.isArray(value)) {
465
+ return `the delta fold counts "${name}", which is ${describe(value)} — a bucket that is not an array counts as empty in every lane, and an empty delta reads "no change". This is a bug in archkeep, not a fact about the workspace.`;
466
+ }
467
+ }
468
+ return null;
469
+ }
470
+
471
+ /**
472
+ * The `delta` exit fold, lifted out of `deltaCommand` as a pure function so
473
+ * the mapping from classification buckets to the verdict is a fact a test can
474
+ * pin (`./change.mjs`'s `reconcileDisposition` is the arrangement's
475
+ * precedent). The lane order is the one `deltaCommand`'s header states:
476
+ * introduced-and-not-waived (violations and custom findings alike) →
477
+ * `findings`/1; else any unknown item → `no-verdict`/3; else `ok`/0.
478
+ *
479
+ * The input latch runs first: a bucket the fold cannot read is refused as a
480
+ * no-verdict whose single reason names it (`refused`), never folded past as a
481
+ * silently-empty count. The literals stay hand-rolled per site by decision —
482
+ * the carrier folds are the pinned baseline INV-2 names, not a table to be
483
+ * converged (`docs/architecture/refactor/AUTHORITY-MAP.md`).
484
+ *
485
+ * Pure and exported for the fold's own tests; `deltaCommand` is its only
486
+ * production caller.
487
+ *
488
+ * @param {object} classification From `classifyDelta`.
489
+ * @param {{judged: object[], skipped: object[], removed: string[],
490
+ * findings: {introduced: object[], resolved: object[], unchanged: object[],
491
+ * unknown: object[]}}|null} custom
492
+ * @returns {{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3,
493
+ * decision: object, introducedWaived: number, refused?: undefined}|
494
+ * {status: "no-verdict", exitCode: 3, decision: object, introducedWaived?: undefined,
495
+ * refused: string}}
496
+ * `introducedWaived` rides every judged lane because `result.summary`
497
+ * reports it whichever lane fired; a refusal carries `refused` and no
498
+ * counts, and is the one return whose `decision.reason` names a malformed
499
+ * input rather than a fact about the trees.
500
+ */
501
+ export function deltaFold(classification, custom) {
502
+ const problem = deltaFoldInputProblem(classification, custom);
503
+ if (problem !== null) {
504
+ return {
505
+ status: "no-verdict",
506
+ exitCode: 3,
507
+ decision: buildDecision({
508
+ status: "no-verdict",
509
+ coverageComplete: true,
510
+ findings: 0,
511
+ reason: problem,
512
+ }),
513
+ refused: problem,
514
+ };
515
+ }
516
+ const { violations, unresolvable } = classification;
517
+ const introducedWaived = violations.introduced.filter((entry) => entry.waived === true).length;
518
+ const introducedNotWaived = violations.introduced.length - introducedWaived;
519
+ // Custom findings have no waiver lane (`./delta-classify.mjs`'s
520
+ // `classifyCustomFindings` argues the by-construction absence), so every
521
+ // introduced one gates.
522
+ const customIntroduced = custom === null ? 0 : custom.findings.introduced.length;
523
+ const customUnknown = custom === null ? 0 : custom.findings.unknown.length;
524
+ const unknownCount = violations.unknown.length + unresolvable.unknown.length + customUnknown;
525
+
526
+ if (introducedNotWaived + customIntroduced > 0) {
527
+ return {
528
+ status: "findings",
529
+ exitCode: 1,
530
+ decision: buildDecision({
531
+ status: "findings",
532
+ coverageComplete: true,
533
+ findings: introducedNotWaived + customIntroduced,
534
+ }),
535
+ introducedWaived,
536
+ };
537
+ }
538
+ if (unknownCount > 0) {
539
+ return {
540
+ status: "no-verdict",
541
+ exitCode: 3,
542
+ decision: buildDecision({
543
+ status: "no-verdict",
544
+ coverageComplete: true,
545
+ findings: 0,
546
+ reason:
547
+ `${unknownCount} delta item${unknownCount === 1 ? "" : "s"} could not be classified — ` +
548
+ (customUnknown > 0
549
+ ? `${customUnknown} of them custom-rule item${customUnknown === 1 ? "" : "s"} — `
550
+ : "") +
551
+ `an item whose identity cannot be stated is never guessed into a bucket`,
552
+ }),
553
+ introducedWaived,
554
+ };
555
+ }
556
+ return {
557
+ status: "ok",
558
+ exitCode: 0,
559
+ decision: buildDecision({ status: "ok", coverageComplete: true, findings: 0 }),
560
+ introducedWaived,
561
+ };
403
562
  }
404
563
 
405
564
  /** First eight hex characters of a fingerprint, for prose that names one. */
@@ -448,8 +607,8 @@ const short = (fingerprint) =>
448
607
  * `loadIntent`; absent intent ⇒ no ids, an in-band note says so), and the
449
608
  * custom-rule host's two injectable seams, passed through to
450
609
  * `customRulesForDelta`.
451
- * @returns {Promise<{status: "ok"|"findings"|"no-verdict", delta?: object,
452
- * coverage: object,
610
+ * @returns {Promise<{status: "ok"|"findings"|"no-verdict",
611
+ * exitCode: 0|1|3, delta?: object, coverage: object,
453
612
  * eventWrite?: {id: string, duplicate: boolean}|null,
454
613
  * report: {text: string, json: string, sarif?: string}}>} `delta` carries
455
614
  * the additive `classifications`/`affected` fields (design §1); `eventWrite`
@@ -569,16 +728,10 @@ export async function deltaCommand(
569
728
  );
570
729
  }
571
730
  if (meta.dirtyBaseline) {
572
- notes.push(
573
- "the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
574
- "claim about the commit it names",
575
- );
731
+ notes.push(dirtyBaselineNote(false));
576
732
  }
577
733
  if (meta.dirtyHead) {
578
- notes.push(
579
- "this run's working tree is dirty — the head side describes uncommitted state, not the " +
580
- "commit HEAD names",
581
- );
734
+ notes.push(dirtyHeadNote());
582
735
  }
583
736
 
584
737
  // The event's structural signal: what moved between the two graphs the
@@ -588,10 +741,11 @@ export async function deltaCommand(
588
741
  // a second spelling of "changed". The head side is rebuilt by the same
589
742
  // `buildProjects`/`buildDependencies` the snapshot stores
590
743
  // (`./delta-snapshot.mjs`), which is what makes the two sides comparable.
591
- const structuralDiff = computeDiff(baseline.graph, {
744
+ const headGraph = {
592
745
  projects: buildProjects(graph.nodes),
593
746
  dependencies: buildDependencies(graph.dependencies),
594
- });
747
+ };
748
+ const structuralDiff = computeDiff(baseline.graph, headGraph);
595
749
  const structural = {
596
750
  projects: {
597
751
  added: structuralDiff.addedProjects.map((project) => project.name),
@@ -618,6 +772,25 @@ export async function deltaCommand(
618
772
  // same", and a policy change is disclosed, not folded into drift.
619
773
  const baseCommit = baseline.provenance?.commit;
620
774
  const headCommit = headProvenance?.commit;
775
+ // The event's identity sides are frozen HERE — at the same instant the diff
776
+ // above was computed from these same graph objects — so the identity always
777
+ // describes the graph the diff actually compared. Everything below this line
778
+ // (custom rules, classification, intent, debt) reads the graphs too; the
779
+ // engine's purity is what keeps them honest today, but the WS-I adversarial
780
+ // audit named the seam: identity and diff agreeing must not rest on purity
781
+ // conventions alone when one structural line closes it.
782
+ const eventBase = eventSnapshotSide({
783
+ revision: baseCommit,
784
+ projects: baseline.graph.projects,
785
+ dependencies: baseline.graph.dependencies,
786
+ policyFingerprint: baseline.policyFingerprint,
787
+ });
788
+ const eventHead = eventSnapshotSide({
789
+ revision: headCommit,
790
+ projects: headGraph.projects,
791
+ dependencies: headGraph.dependencies,
792
+ policyFingerprint: headFingerprint,
793
+ });
621
794
  const provenanceAdvanced =
622
795
  typeof baseCommit === "string" &&
623
796
  typeof headCommit === "string" &&
@@ -676,6 +849,48 @@ export async function deltaCommand(
676
849
  findings: { introduced: [], resolved: [], unchanged: [], unknown: [] },
677
850
  };
678
851
  }
852
+ // The exit fold, taken the moment both of its inputs are final: everything
853
+ // below — the custom-rule notes loop, the §1 mapping, `result.summary`, both
854
+ // report faces — counts the same buckets, and a malformed one must be
855
+ // refused HERE, in-band (no-verdict, exit 3, the bucket named), never
856
+ // counted past as a silently-empty bucket. The refusal withholds `delta`
857
+ // and the event exactly the coverage refusal does (#608): a comparison
858
+ // whose counts could not be read has no result to report.
859
+ const fold = deltaFold(classification, custom);
860
+ if (fold.refused !== undefined) {
861
+ const refusalCoverage = {
862
+ complete: true,
863
+ projects: Object.keys(graph.nodes).length,
864
+ analyzedFiles: analysis.analyzed,
865
+ imports: analysis.imports.length,
866
+ notAnalyzed: [],
867
+ blindSpots: blindSpotRows(analysis.failures),
868
+ notes: [fold.refused],
869
+ };
870
+ return {
871
+ status: fold.status,
872
+ exitCode: fold.exitCode,
873
+ coverage: refusalCoverage,
874
+ report: {
875
+ text: `delta: no verdict — ${fold.refused}\n`,
876
+ json: renderJson(
877
+ jsonEnvelope({
878
+ command: "delta",
879
+ context: { root, provider, marker, provenance: headProvenance },
880
+ status: fold.status,
881
+ exitCode: fold.exitCode,
882
+ coverage: refusalCoverage,
883
+ // The refusal-withheld payload, stated the way `coverageRefusal`
884
+ // states it: `jsonEnvelope` requires the key, the refusal has no
885
+ // result to report.
886
+ result: undefined,
887
+ decision: fold.decision,
888
+ }),
889
+ ),
890
+ },
891
+ };
892
+ }
893
+
679
894
  if (custom !== null) {
680
895
  for (const skipped of custom.skipped) {
681
896
  notes.push(`custom rule "${skipped.name}" was not classified — ${skipped.reason}`);
@@ -722,48 +937,10 @@ export async function deltaCommand(
722
937
  codeDrift,
723
938
  });
724
939
 
940
+ // The verdict, from the one fold — `deltaFold` above owns the lane order
941
+ // and the input latch; this destructure is the command's only hand in it.
725
942
  const { violations, unresolvable } = classification;
726
- const introducedWaived = violations.introduced.filter((entry) => entry.waived === true).length;
727
- const introducedNotWaived = violations.introduced.length - introducedWaived;
728
- // Custom findings have no waiver lane (`./delta-classify.mjs`'s
729
- // `classifyCustomFindings` argues the by-construction absence), so every
730
- // introduced one gates.
731
- const customIntroduced = custom === null ? 0 : custom.findings.introduced.length;
732
- const customUnknown = custom === null ? 0 : custom.findings.unknown.length;
733
- const unknownCount = violations.unknown.length + unresolvable.unknown.length + customUnknown;
734
-
735
- /** @type {"ok"|"findings"|"no-verdict"} */
736
- let status;
737
- /** @type {0|1|3} */
738
- let exitCode;
739
- let decision;
740
- if (introducedNotWaived + customIntroduced > 0) {
741
- status = "findings";
742
- exitCode = 1;
743
- decision = buildDecision({
744
- status,
745
- coverageComplete: true,
746
- findings: introducedNotWaived + customIntroduced,
747
- });
748
- } else if (unknownCount > 0) {
749
- status = "no-verdict";
750
- exitCode = 3;
751
- decision = buildDecision({
752
- status,
753
- coverageComplete: true,
754
- findings: 0,
755
- reason:
756
- `${unknownCount} delta item${unknownCount === 1 ? "" : "s"} could not be classified — ` +
757
- (customUnknown > 0
758
- ? `${customUnknown} of them custom-rule item${customUnknown === 1 ? "" : "s"} — `
759
- : "") +
760
- `an item whose identity cannot be stated is never guessed into a bucket`,
761
- });
762
- } else {
763
- status = "ok";
764
- exitCode = 0;
765
- decision = buildDecision({ status, coverageComplete: true, findings: 0 });
766
- }
943
+ const { status, exitCode, decision, introducedWaived } = fold;
767
944
 
768
945
  const coverage = {
769
946
  complete: true,
@@ -878,13 +1055,18 @@ export async function deltaCommand(
878
1055
  schemaVersion: EVOLUTION_EVENT_SCHEMA_VERSION,
879
1056
  kind: "transition",
880
1057
  source: "delta",
881
- base: {
882
- ...(typeof baseCommit === "string" ? { revision: baseCommit } : {}),
883
- // The evidence ref is the baseline file this run actually compared
884
- // against a pointer into the evidence, never a graph.
885
- evidence: baselinePath,
886
- },
887
- head: typeof headCommit === "string" ? { revision: headCommit } : {},
1058
+ // Both sides were frozen through the ONE identity spelling
1059
+ // (`eventSnapshotSide`, ./history.mjs) at the diff site above: a
1060
+ // revision when one is known, plus the snapshot identity of the graph
1061
+ // the diff compared never the baseline's storage path, which is
1062
+ // machine-local and must not make the identity a per-machine property.
1063
+ base: eventBase,
1064
+ head: eventHead,
1065
+ // The evidence ref is the baseline file this run actually compared
1066
+ // against — a pointer into the evidence, never a graph, and disclosed
1067
+ // OUTSIDE the identity: the tuple above names the state itself, so a
1068
+ // relocated baseline is still the same event.
1069
+ evidence: baselinePath,
888
1070
  recordedAt: recordOrigin({
889
1071
  by: "cli",
890
1072
  tool: `archkeep:v${TOOL_VERSION}`,
@@ -928,6 +1110,7 @@ export async function deltaCommand(
928
1110
 
929
1111
  return {
930
1112
  status,
1113
+ exitCode,
931
1114
  eventWrite,
932
1115
  delta: result,
933
1116
  coverage,
@@ -941,3 +1124,54 @@ export async function deltaCommand(
941
1124
  },
942
1125
  };
943
1126
  }
1127
+
1128
+ /**
1129
+ * `delta --capture` as the CLI drives it: the shared preamble — command
1130
+ * context, then the boundary law — resolved here, so `../../cli.mjs`'s
1131
+ * driver only wires where the evidence text lands. The engine this returns
1132
+ * from is `captureDelta` above, unchanged; the driver owns the write door.
1133
+ *
1134
+ * @param {{config: string|null}} options This run's parsed flags.
1135
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
1136
+ * seams a test injects, the same ones `check` takes.
1137
+ * @returns {Promise<object>} `captureDelta`'s result (`snapshot`, `text`),
1138
+ * unmodified.
1139
+ */
1140
+ export async function captureBaseline(options, { cwd, readGraph, listFiles }) {
1141
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
1142
+ // The snapshot's policy fingerprint needs the boundary config — the
1143
+ // workspace's own `boundaryConfig` (profile-aware the same way `check` is,
1144
+ // `resolvePolicy`), so the stored fingerprint agrees with a standalone
1145
+ // `graph` fingerprint over the same workspace, kept in one place so a
1146
+ // capture and a `graph` never disagree about the current policy.
1147
+ const { config } = await resolvePolicy(options, commandContext, cwd);
1148
+ return captureDelta(commandContext, { config });
1149
+ }
1150
+
1151
+ /**
1152
+ * `delta` (compare) as the CLI drives it: the baseline path resolved from
1153
+ * the single positional argument, then the shared preamble — command
1154
+ * context, boundary law — so `../../cli.mjs`'s driver only wires options,
1155
+ * IO seams, and where output lands. The engine this returns from is
1156
+ * `deltaCommand` above, unchanged.
1157
+ *
1158
+ * @param {{config: string|null, eventOut: string|null, paths: string[]}} options
1159
+ * This run's parsed flags; `paths[0]` is the baseline file.
1160
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
1161
+ * seams a test injects, the same ones `check` takes.
1162
+ * @returns {Promise<object>} `deltaCommand`'s result, unmodified.
1163
+ */
1164
+ export async function delta(options, { cwd, readGraph, listFiles }) {
1165
+ const baselinePath = isAbsolute(options.paths[0])
1166
+ ? resolve(options.paths[0])
1167
+ : resolve(cwd, options.paths[0]);
1168
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
1169
+ // Same loading logic as `check` (`resolvePolicy`) — a `--config`
1170
+ // overrides the workspace's own `boundaryConfig`, profile-aware the same
1171
+ // way `check` is.
1172
+ const { config } = await resolvePolicy(options, commandContext, cwd);
1173
+ return deltaCommand(baselinePath, commandContext, {
1174
+ config,
1175
+ eventOut: options.eventOut,
1176
+ });
1177
+ }
@@ -42,12 +42,15 @@ import { readFileSync } from "node:fs";
42
42
 
43
43
  import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
44
44
  import { computeRuleImpact } from "../rules/edge-constraints.mjs";
45
+ import { isAbsolute, resolve } from "node:path";
45
46
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
46
47
  import { SCHEMA_VERSION } from "../report/json.mjs";
47
48
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
48
49
  import { formatDiffReport } from "../report/diff-text.mjs";
49
- import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
50
+ import { compareSnapshotMetadata, dirtyBaselineNote, dirtyHeadNote } from "./snapshot-meta.mjs";
50
51
  import { resolveProvenance } from "./provenance.mjs";
52
+ import { resolveCommandContext } from "./context.mjs";
53
+ import { resolvePolicy } from "./policy.mjs";
51
54
 
52
55
  /**
53
56
  * Reads and validates a baseline snapshot from `path`.
@@ -236,6 +239,20 @@ function buildHeadSnapshot(commandContext) {
236
239
  * consumer wants to see: it is a real architectural event, not an
237
240
  * implementation detail.
238
241
  *
242
+ * This key is in-memory arithmetic and nothing else: `Map`/`Set` keys inside
243
+ * one run, never persisted and never emitted — only counts derived from
244
+ * those sets reach an envelope. The STORED spelling of edge identity is
245
+ * `edgeEvolutionIdentity` (`../governance/evolution-event.mjs`), the escaped
246
+ * `source>target:type` string evolution events carry, and the two spellings
247
+ * are deliberately not unified — each medium keeps exactly one identity
248
+ * constructor (`../../../../docs/adr/0008-snapshot-identity-per-family.md`;
249
+ * INV-6 in `../../../../docs/architecture/refactor/INVARIANTS.md`). An edge
250
+ * crosses from a structural diff into an event as its raw
251
+ * `{source, target, type}` triple, mapped through that function at the
252
+ * command boundary; a `\0`-joined key written into an event record would
253
+ * give one edge two spellings inside the store, and anything comparing
254
+ * identity strings across events would read them as different boundaries.
255
+ *
239
256
  * @param {{source: string, target: string, type: string}} edge
240
257
  * @returns {string}
241
258
  */
@@ -475,16 +492,10 @@ export function diffCommand(
475
492
  }
476
493
 
477
494
  if (meta.dirtyBaseline) {
478
- coverage.notes.push(
479
- "the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
480
- "claim about the commit it names",
481
- );
495
+ coverage.notes.push(dirtyBaselineNote(false));
482
496
  }
483
497
  if (meta.dirtyHead) {
484
- coverage.notes.push(
485
- "this run's working tree is dirty — the head side describes uncommitted state, not the " +
486
- "commit HEAD names",
487
- );
498
+ coverage.notes.push(dirtyHeadNote());
488
499
  }
489
500
 
490
501
  if (meta.policyChanged === true) {
@@ -564,3 +575,31 @@ export function diffCommand(
564
575
  },
565
576
  };
566
577
  }
578
+
579
+ /**
580
+ * `diff` as the CLI drives it: the baseline path resolved from the single
581
+ * positional argument, then the shared preamble — command context, boundary
582
+ * law — so `../../cli.mjs`'s driver only wires options, IO seams, and where
583
+ * output lands (`./README.md`). The engine this returns from is
584
+ * `diffCommand` above, unchanged.
585
+ *
586
+ * @param {{format: string, output: string|null, config: string|null, paths: string[]}} options
587
+ * This run's parsed flags; `paths[0]` is the baseline file.
588
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
589
+ * seams a test injects, the same ones `check` takes.
590
+ * @returns {Promise<object>} `diffCommand`'s result, unmodified.
591
+ */
592
+ export async function diff(options, { cwd, readGraph, listFiles }) {
593
+ const baselinePath = isAbsolute(options.paths[0])
594
+ ? options.paths[0]
595
+ : resolve(cwd, options.paths[0]);
596
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
597
+ // Load the boundary config when --config is given or when the workspace
598
+ // declares one, so rule-impact analysis is computed. Without a config,
599
+ // the diff reports only structural changes — same as before. A
600
+ // profile-selected workspace resolves the same way `check` does
601
+ // (`resolvePolicy`), so a policy edit under an unchanged profile NAME is
602
+ // still visible as a fingerprint change here.
603
+ const { config } = await resolvePolicy(options, commandContext, cwd);
604
+ return diffCommand(baselinePath, commandContext, { config });
605
+ }
@@ -122,6 +122,43 @@ export function proposalToIntent(proposal) {
122
122
  };
123
123
  }
124
124
 
125
+ /**
126
+ * Serialize a proposal into the exact bytes `--write-intent` plants at the
127
+ * target path — `proposalToIntent` rendered as stable, two-space JSON with a
128
+ * trailing newline, so the file a human reviews as a diff is the file
129
+ * `check` would read. Owned here, beside the conversion it renders, so the
130
+ * CLI's write door can never re-derive a second spelling of it.
131
+ *
132
+ * @param {object} proposal The proposal from `discoverCommand`'s result.
133
+ * @returns {string} The serialized intent document.
134
+ */
135
+ export function intentJsonFromProposal(proposal) {
136
+ return JSON.stringify(proposalToIntent(proposal), null, 2) + "\n";
137
+ }
138
+
139
+ /**
140
+ * The `--write-intent` self-footgun refusal, decided beside the proposal it
141
+ * protects. A proposal is a suggestion; a file already at the target is a law
142
+ * (or a candidate someone holds), and silently overwriting it with a proposal
143
+ * is the adoption this command must never perform by itself — the same
144
+ * posture `--output`'s fixed-name table holds at the write door, specialized
145
+ * to the one target `discover` itself names. The CLI keeps the mechanics
146
+ * (the `wx` write, the stderr wording around it); the DECISION to refuse
147
+ * travels with the verb, the way `historyOutputRefusal` does for history.
148
+ *
149
+ * @param {string} target The `--write-intent` path as the user wrote it.
150
+ * @param {{exists: (path: string) => boolean}} io Injectable existence read —
151
+ * the same seam style `loadNativeModel`'s `{readFile}` keeps.
152
+ * @returns {string|null} The refusal message, or `null` when the write may
153
+ * proceed.
154
+ */
155
+ export function intentWriteRefusal(target, { exists }) {
156
+ return exists(target)
157
+ ? `archkeep: ${target} already exists, and a proposal must never ` +
158
+ `silently replace what is there. Move or delete the file first, then run this again.`
159
+ : null;
160
+ }
161
+
125
162
  /**
126
163
  * Runs the `discover` command: observes the workspace, optionally proposes the
127
164
  * candidate architecture over it, and returns the report.
@@ -77,6 +77,8 @@
77
77
  * intent produce byte-identical text and JSON.
78
78
  */
79
79
  import { blindSpotRows } from "../analysis/source-util.mjs";
80
+ import { resolveCommandContext } from "./context.mjs";
81
+ import { resolvePolicy } from "./policy.mjs";
80
82
  import { buildDependencies, buildProjects } from "./graph.mjs";
81
83
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
82
84
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
@@ -471,3 +473,46 @@ export async function driftCommand(commandContext, io = {}) {
471
473
  },
472
474
  };
473
475
  }
476
+
477
+ /**
478
+ * `drift` as the CLI drives it: the shared preamble — command context, then
479
+ * the boundary law — resolved here so `../../cli.mjs`'s driver only wires
480
+ * options, IO seams, and where output lands (`./README.md`). The engine this
481
+ * returns from is `driftCommand` above, unchanged.
482
+ *
483
+ * The loaded policy — profile-aware the same way `check` is
484
+ * (`resolvePolicy`), `null` when the workspace declares none. Drift reads
485
+ * the intent's rows, and the fitness half of a row's `decisionRef` resolves
486
+ * against the ids THIS policy declares (F04), so the same policy that made
487
+ * the boundary law answerable to the model must answer here. `drift` has no
488
+ * `--config` (`DRIFT_FLAG_HELP`), so `config` is always the workspace's own
489
+ * default — resolvePolicy reads `options.config` as the override, hence
490
+ * `null` here, which selects the workspace's configured boundary law (or a
491
+ * profile, when one is registered).
492
+ *
493
+ * The failure is DEFERRED rather than thrown here. `drift`'s only reader of
494
+ * this policy is the non-verdict decisionRef axis, and only for rows that
495
+ * carry one, so a workspace with an intent and no boundary config was
496
+ * exiting 3 over a law drift would never have opened — a fifth refusal
497
+ * neither `docs/usage/drift.md` nor `reconcile`, which makes the same four,
498
+ * ever had. `driftCommand` rethrows it, unchanged, at the one site that
499
+ * reads the policy, so every workspace whose intent cites anything keeps the
500
+ * exact exit-3 it had.
501
+ *
502
+ * @param {{format: string, output: string|null, paths: string[]}} options
503
+ * This run's parsed flags.
504
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} io The
505
+ * seams a test injects, the same ones `check` takes.
506
+ * @returns {Promise<object>} `driftCommand`'s result, unmodified.
507
+ */
508
+ export async function drift(options, { cwd, readGraph, listFiles }) {
509
+ const commandContext = resolveCommandContext({ cwd }, { readGraph, listFiles });
510
+ let config = null;
511
+ let configError = null;
512
+ try {
513
+ ({ config } = await resolvePolicy({ ...options, config: null }, commandContext, cwd));
514
+ } catch (error) {
515
+ configError = /** @type {Error} */ (error);
516
+ }
517
+ return driftCommand(commandContext, { config, configError });
518
+ }