@ecoma-io/archkeep 0.23.0 → 0.24.1

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 (83) hide show
  1. package/package.json +1 -1
  2. package/src/analysis/csharp.mjs +3 -1
  3. package/src/analysis/dotnet/csproj.mjs +5 -1
  4. package/src/analysis/dotnet/namespaces.mjs +1 -0
  5. package/src/analysis/go.mjs +6 -0
  6. package/src/analysis/java.mjs +2 -0
  7. package/src/analysis/jvm/gradle.mjs +3 -1
  8. package/src/analysis/jvm/maven.mjs +6 -1
  9. package/src/analysis/jvm/packages.mjs +1 -0
  10. package/src/analysis/jvm/resolve.mjs +4 -2
  11. package/src/analysis/kotlin.mjs +2 -0
  12. package/src/analysis/markdown.mjs +341 -0
  13. package/src/analysis/python.mjs +8 -0
  14. package/src/analysis/rust.mjs +5 -1
  15. package/src/analysis/source-util.mjs +6 -5
  16. package/src/analysis/typescript.mjs +148 -0
  17. package/src/architecture-intent/model.mjs +13 -8
  18. package/src/architecture-intent/selectors.mjs +2 -1
  19. package/src/commands/change-intent.mjs +10 -9
  20. package/src/commands/change.mjs +17 -1
  21. package/src/commands/check.mjs +177 -17
  22. package/src/commands/completeness.mjs +7 -6
  23. package/src/commands/context-command.mjs +12 -20
  24. package/src/commands/coverage-acceptance.mjs +46 -0
  25. package/src/commands/coverage-verdict.mjs +12 -2
  26. package/src/commands/custom-rules.mjs +1 -0
  27. package/src/commands/delta-classify.mjs +3 -0
  28. package/src/commands/delta-snapshot.mjs +3 -6
  29. package/src/commands/delta.mjs +35 -21
  30. package/src/commands/drift.mjs +1 -1
  31. package/src/commands/evaluation-primitives.mjs +4 -4
  32. package/src/commands/evolution.mjs +2 -0
  33. package/src/commands/explain.mjs +19 -20
  34. package/src/commands/graph.mjs +44 -15
  35. package/src/commands/health.mjs +4 -0
  36. package/src/commands/history.mjs +2 -0
  37. package/src/commands/plan-context-command.mjs +9 -2
  38. package/src/commands/policy.mjs +8 -5
  39. package/src/commands/provenance.mjs +8 -2
  40. package/src/commands/scenario-evaluation.mjs +1 -1
  41. package/src/commands/trajectory.mjs +2 -1
  42. package/src/config.mjs +170 -2
  43. package/src/custom-rules/host.mjs +3 -3
  44. package/src/errors.mjs +23 -1
  45. package/src/eslint-config.mjs +3 -5
  46. package/src/fixtures/evolution-lifecycle/workspace.mjs +15 -4
  47. package/src/go-work.mjs +1 -1
  48. package/src/governance/adr-registry.mjs +6 -2
  49. package/src/governance/debt-ledger.mjs +1 -1
  50. package/src/governance/decision-fitness.mjs +2 -0
  51. package/src/governance/decision-graph.mjs +1 -0
  52. package/src/governance/discovery-proposal.mjs +8 -2
  53. package/src/governance/evolution-event.mjs +42 -0
  54. package/src/governance/evolution-store.mjs +3 -2
  55. package/src/governance/fitness-registry.mjs +2 -3
  56. package/src/governance/preset-fingerprints.json +14 -14
  57. package/src/governance/profile-registry.mjs +22 -3
  58. package/src/governance/provenance-record.mjs +4 -1
  59. package/src/governance/reconcile-score.mjs +4 -0
  60. package/src/governance/row-schema.mjs +1 -3
  61. package/src/governance/verdict.mjs +1 -0
  62. package/src/governance/waiver.mjs +1 -0
  63. package/src/intent/intent-manifest.json +6 -6
  64. package/src/intent/mask-non-code.mjs +1 -0
  65. package/src/lsp/diagnostics.mjs +3 -2
  66. package/src/lsp/protocol.mjs +2 -1
  67. package/src/lsp/server.mjs +3 -0
  68. package/src/lsp/workspace-index.mjs +3 -1
  69. package/src/providers/moon.mjs +5 -5
  70. package/src/providers/native/differential.fixtures.mjs +29 -11
  71. package/src/providers/native/index.mjs +2 -1
  72. package/src/providers/native/model.mjs +4 -0
  73. package/src/report/envelope-shape.mjs +2 -0
  74. package/src/report/sarif.mjs +21 -8
  75. package/src/report/snapshot-text.mjs +3 -3
  76. package/src/report/text.mjs +10 -2
  77. package/src/rules/index.mjs +30 -0
  78. package/src/rules/match.mjs +7 -5
  79. package/src/rules/specifiers.mjs +2 -0
  80. package/src/rules/tags.mjs +3 -2
  81. package/src/rules/topology.mjs +6 -1
  82. package/src/verdict.mjs +33 -2
  83. package/src/workspace.mjs +1 -0
@@ -12,6 +12,7 @@
12
12
  import { statSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
 
15
+ import { foldMarkdownTrack } from "../analysis/markdown.mjs";
15
16
  import {
16
17
  blindSpotRows,
17
18
  fileFailure,
@@ -28,10 +29,13 @@ import {
28
29
  unownedGapWithoutRunConfiguration,
29
30
  untrackedOwnedFiles,
30
31
  } from "./context.mjs";
31
- import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
32
+ import {
33
+ partitionUnownedCoverage,
34
+ withdrawAcceptedUnclaimedFailures,
35
+ } from "./coverage-acceptance.mjs";
32
36
  import { readAdrContext } from "./adr.mjs";
33
37
  import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
34
- import { declaredEdgeViolationsForCheck } from "../rules/edge-constraints.mjs";
38
+ import { declaredEdgeViolationsForCheck, judgeEdge } from "../rules/edge-constraints.mjs";
35
39
  import { customRulesForCheck, declaresCustomRules } from "./custom-rules.mjs";
36
40
  import { driftForCheck } from "./drift.mjs";
37
41
  import { fitnessForCheck } from "./fitness.mjs";
@@ -40,14 +44,16 @@ import { resolvePolicy } from "./policy.mjs";
40
44
  import { resolveProvenance } from "./provenance.mjs";
41
45
  import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
42
46
  import { compareGoWork, parseGoWorkUse } from "../go-work.mjs";
47
+ import { mergeDeclaredEdges } from "../providers/native/graph.mjs";
43
48
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
44
49
  import { formatSarif } from "../report/sarif.mjs";
45
50
  import { formatReport } from "../report/text.mjs";
46
51
  import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
47
- import { evaluateRun, exemptResolvedFile } from "../rules/index.mjs";
52
+ import { applySuppressionTable, evaluateRun, exemptResolvedFile } from "../rules/index.mjs";
53
+ import { buildReachability } from "../rules/reachability.mjs";
48
54
  import { orphanedNotDependOnTags, unmatchedConstraintRows } from "../rules/tags.mjs";
49
55
  import { judgeTsconfigPaths } from "../tsconfig-paths.mjs";
50
- import { verdictFor } from "../verdict.mjs";
56
+ import { coverageComplete, verdictFor } from "../verdict.mjs";
51
57
  import { listTrackedFiles, listUntrackedFiles } from "../workspace.mjs";
52
58
 
53
59
  /**
@@ -220,7 +226,7 @@ export async function check(
220
226
  );
221
227
  const { root, graph, workspace, tracked } = commandContext;
222
228
  const { imports, exemptedFiles, unsupportedLanguageFiles } = commandContext.analysis;
223
- const failures = [...commandContext.analysis.failures];
229
+ let failures = [...commandContext.analysis.failures];
224
230
  const analyzed = commandContext.analysis.analyzed;
225
231
 
226
232
  // The config's location is a separate fact from the workspace root, which is
@@ -305,17 +311,16 @@ export async function check(
305
311
  // acceptance now — stated below as the `"accepted-unowned-files"` coverage
306
312
  // gap, never silently — rather than the exit-3 refusal an unanswered
307
313
  // orphan earns. Uncovered unclaimed files keep their failures, and with
308
- // them the exit code, byte-identical to before the channel existed. An
309
- // unclaimed file carries exactly one failure (it is unowned, so no
310
- // analyzer ever read it), so filtering by file cannot drop an unrelated
311
- // read failure.
314
+ // them the exit code, byte-identical to before the channel existed. The
315
+ // one-failure-per-file assumption the withdrawal rests on is enforced where
316
+ // it is spent (`./coverage-acceptance.mjs`), not stated here: an accepted
317
+ // file carrying a second failure refuses the run instead of losing both
318
+ // rows to a by-file splice.
312
319
  const acceptedUnclaimed = new Set(
313
320
  commandContext.unclaimedGap.files.filter((file) => unownedCoverage.acceptedFiles.has(file)),
314
321
  );
315
322
  if (acceptedUnclaimed.size > 0) {
316
- for (let at = failures.length - 1; at >= 0; at -= 1) {
317
- if (acceptedUnclaimed.has(failures[at].sourceFile)) failures.splice(at, 1);
318
- }
323
+ failures = withdrawAcceptedUnclaimedFailures(failures, acceptedUnclaimed);
319
324
  }
320
325
 
321
326
  // The go.work drift check, keyed off the manifest's presence the way every
@@ -453,6 +458,41 @@ export async function check(
453
458
  }
454
459
  }
455
460
 
461
+ // The markdown document track, keyed by presence like every fold above it:
462
+ // a policy that declares no `markdown` block reaches nothing here and hears
463
+ // nothing anywhere — no edges, no failures, no envelope key — so a
464
+ // config-absent run is byte-identical to one this fold never existed for
465
+ // (`../../../../AGENTS.md`, "a change to what is reported on an unchanged
466
+ // workspace is a breaking change"). A policy that DOES declare one has its
467
+ // documents read (`../analysis/markdown.mjs`'s `foldMarkdownTrack`), the
468
+ // resolved pairings folded into the graph the way the declared manifest
469
+ // track's edges already are (`mergeDeclaredEdges`, the same dedup key), and
470
+ // every whole-file failure the read earned pushed into the same `failures`
471
+ // funnel the analysis uses — an unresolvable marker is an `unchecked` file,
472
+ // never a clean one.
473
+ //
474
+ // The fold runs BEFORE the walk below on purpose: a document pairing is a
475
+ // project-to-project dependency claim, and from here on this run's graph
476
+ // carries it exactly as it carries a csproj's `ProjectReference` — the tag
477
+ // rows, the circularity walk and the reachability behind them all read one
478
+ // graph, so a pairing cannot be legal here and violating in `context`.
479
+ // What it does not join is the twelve non-tag import-site rules: those need
480
+ // a specifier, a file and a resolution, and a document marker has none of
481
+ // them — it is judged as the EDGE it drew, the same 3-of-15 limit
482
+ // `declaredEdgeViolationsForCheck` documents for `implicit` edges.
483
+ let markdownTrack = null;
484
+ if (config !== null && config.markdown !== undefined) {
485
+ markdownTrack = foldMarkdownTrack({
486
+ tracked,
487
+ owned: commandContext.owned,
488
+ readFile: (file) => workspace.readFile(file),
489
+ workspace,
490
+ markdown: config.markdown,
491
+ });
492
+ mergeDeclaredEdges(graph, markdownTrack.edges);
493
+ failures.push(...markdownTrack.failures);
494
+ }
495
+
456
496
  // Both faces of one walk: the run's verdict, and the raw superset it was
457
497
  // picked from — every candidate up to each site's surviving group, including
458
498
  // the verdicts the suppression table removed to get there. `evaluate` alone
@@ -462,8 +502,66 @@ export async function check(
462
502
  // the gate's waiver-expiry judgement below and the engine's are the same
463
503
  // judgement, not two reads of the clock a boundary instant could split.
464
504
  const now = referenceTime();
465
- const { violations: judged, rawViolations } = evaluateRun(imports, graph, { ...config, now });
466
- const violations = sortViolations(judged);
505
+ const { violations: judged, rawViolations: judgedRaw } = evaluateRun(imports, graph, {
506
+ ...config,
507
+ now,
508
+ });
509
+
510
+ // The document pairings' own verdicts, judged by the machinery that already
511
+ // existed: `judgeEdge` — the same function `declaredEdgeViolationsForCheck`
512
+ // runs `implicit` edges through, so a doc pairing and a declared manifest
513
+ // edge can never disagree about the same boundary — over the graph the fold
514
+ // above already joined, with reachability built once for the whole claim
515
+ // list. The same empty-table exit `declaredEdgeViolationsForCheck` takes: a
516
+ // workspace declaring no `depConstraints` has opted out of tag enforcement
517
+ // entirely, and folding `projectWithoutTagsCannotHaveDependencies` onto
518
+ // every marker anyway would flag an opted-out workspace for a reason its
519
+ // import sites are never flagged for. Each verdict is then reshaped into the
520
+ // exact `Violation` record `violationOf` builds — position and specifier
521
+ // from the MARKER (the line a reader edits is the document's), project and
522
+ // constraint from the edge — so suppression, waiver annotation, sorting and
523
+ // the SARIF face all treat it as the ordinary violation it is.
524
+ /** @type {object[]} */
525
+ const markdownRaw = [];
526
+ if (config !== null && markdownTrack !== null && config.depConstraints.length > 0) {
527
+ const reachability = buildReachability(graph);
528
+ for (const claim of markdownTrack.claims) {
529
+ for (const verdict of judgeEdge(
530
+ { source: claim.source, target: claim.target },
531
+ graph.nodes,
532
+ graph.dependencies,
533
+ config.depConstraints,
534
+ reachability,
535
+ )) {
536
+ markdownRaw.push({
537
+ sourceFile: claim.file,
538
+ line: claim.line,
539
+ column: claim.column,
540
+ specifier: claim.name,
541
+ kind: claim.type,
542
+ messageId: verdict.messageId,
543
+ message: verdict.message,
544
+ sourceProject: verdict.source,
545
+ targetProject: verdict.target,
546
+ constraint: verdict.constraint,
547
+ data: verdict.data,
548
+ });
549
+ }
550
+ }
551
+ }
552
+ // The same table, the same behaviour: a suppression row whose glob covers a
553
+ // document removes that document's verdict (or waives it, with the same
554
+ // expiry evidence an import-site waiver carries), and the RAW records join
555
+ // `rawViolations` below so a row covering only document verdicts still
556
+ // counts as alive — a table that is doing its job must not read as dead
557
+ // because the violations it removes live in a different fold.
558
+ const markdownViolations =
559
+ config !== null && markdownTrack !== null
560
+ ? applySuppressionTable(config.suppressions, markdownRaw, now)
561
+ : [];
562
+
563
+ const rawViolations = [...judgedRaw, ...markdownRaw];
564
+ const violations = sortViolations([...judged, ...markdownViolations]);
467
565
  // An ACTIVE waiver keeps the violation it accepts in the findings list,
468
566
  // marked `waivedBy` — the run is still non-zero (waiving does not flip
469
567
  // exit 1 → 0), and this count is the additive "accepted violations" number
@@ -704,7 +802,10 @@ export async function check(
704
802
  config !== null &&
705
803
  options.paths.length === 0 &&
706
804
  failures.length === 0 &&
707
- (config.suppressions.length > 0 || config.depConstraints.length > 0 || coverageRows.length > 0)
805
+ (config.suppressions.length > 0 ||
806
+ config.depConstraints.length > 0 ||
807
+ coverageRows.length > 0 ||
808
+ config.markdown !== undefined)
708
809
  ) {
709
810
  const deadRows = [];
710
811
  // The third dead table: a `coverage.unowned` row matching no unowned file
@@ -755,6 +856,34 @@ export async function check(
755
856
  );
756
857
  }
757
858
  }
859
+ // The document track's two dead tables, measured against what the fold
860
+ // actually selected (`markdownTrack.includeCounts`/`rowMatches` — the
861
+ // same counts the fold's own selection is built from, not a re-derivation
862
+ // that could disagree): an include glob matching no tracked document, and
863
+ // a marker row matching no line in any included document, both govern
864
+ // nothing while reading as enforced — every pairing the workspace meant
865
+ // to declare goes unjudged and the run stays green. A whole-file failure
866
+ // above already disabled this gate, so reaching here with a row that
867
+ // matched nothing means the tree genuinely has no such line.
868
+ if (markdownTrack !== null && config.markdown !== undefined) {
869
+ markdownTrack.includeCounts.forEach((count, index) => {
870
+ if (count > 0) return;
871
+ deadRows.push(
872
+ `markdown.include[${index}]: '${config.markdown.include[index]}' matches no tracked ` +
873
+ `document — the document track reads nothing, so every marker row below it governs ` +
874
+ `nothing while reading as enforced. Either the path was never right, or the ` +
875
+ `documents are not tracked`,
876
+ );
877
+ });
878
+ markdownTrack.rowMatches.forEach((count, index) => {
879
+ if (count > 0) return;
880
+ deadRows.push(
881
+ `markdown.markers[${index}]: the pattern matches no line in any included document — ` +
882
+ `this row extracts no pairing, so it enforces nothing while reading as enforced. ` +
883
+ `Either the documents do not carry the marker, or the pattern was never right`,
884
+ );
885
+ });
886
+ }
758
887
  if (deadRows.length > 0) {
759
888
  throw new Error(
760
889
  `archkeep: ${policySource ?? "the boundary config"} describes a workspace that does not ` +
@@ -977,8 +1106,14 @@ export async function check(
977
1106
  // Complete means the run judged everything in scope: no
978
1107
  // whole-file failure (unchecked), no unresolvable site (#595),
979
1108
  // and at least one file analyzed (#599 — a run that judged
980
- // nothing has no verdict to claim).
981
- complete: unchecked === 0 && blindSpotCount === 0 && analyzed > 0,
1109
+ // nothing has no verdict to claim). Read from the one predicate
1110
+ // `verdictFor`'s decision face reads, so this field and the
1111
+ // envelope's `decision.coverageComplete` are one derivation —
1112
+ // the counts are this command's (the go.work/tsconfig failures
1113
+ // pushed above and the accepted files withdrawn above make the
1114
+ // universe wider than `commandContext.analysis`), the law is
1115
+ // not.
1116
+ complete: coverageComplete({ unchecked, blindSpotCount, analyzed }),
982
1117
  projects: Object.keys(graph.nodes).length,
983
1118
  analyzedFiles: analyzed,
984
1119
  imports: imports.length,
@@ -1029,6 +1164,31 @@ export async function check(
1029
1164
  judged: declaredEdges.judged,
1030
1165
  findings: declaredEdges.findings,
1031
1166
  },
1167
+ // The document track is a policy DECLARATION, so it takes the
1168
+ // same omitted-key-not-null discipline the intent/fitness/
1169
+ // customRules blocks below state: a workspace whose policy
1170
+ // declares no `markdown` block gets no key at all, and its
1171
+ // envelope is byte-identical to the one it got before this
1172
+ // section existed. The findings themselves are NOT restated
1173
+ // here — they are members of `violations` above (sorted with,
1174
+ // suppressible by, and counted into the verdict exactly like
1175
+ // every import-site violation), so an array here would be one
1176
+ // fact counted in two places; the block states what the track
1177
+ // DID: how many documents the globs selected, how many markers
1178
+ // they carried, where each resolution went.
1179
+ ...(markdownTrack === null
1180
+ ? {}
1181
+ : {
1182
+ markdown: {
1183
+ checked: true,
1184
+ documents: markdownTrack.documents,
1185
+ judged: markdownTrack.judged,
1186
+ resolved: markdownTrack.resolved,
1187
+ ...(markdownTrack.selfPaired > 0
1188
+ ? { selfPaired: markdownTrack.selfPaired }
1189
+ : {}),
1190
+ },
1191
+ }),
1032
1192
  // Intent is a governance DECLARATION, absent when the workspace
1033
1193
  // chose not to make one: the key is omitted, never written as
1034
1194
  // null — the design contract `docs/reference/json-output.md` will
@@ -40,10 +40,10 @@ export const EVALUATION_STATUS = Object.freeze({
40
40
  REFUSED: "refused",
41
41
  });
42
42
  export const EVALUATED = EVALUATION_STATUS.EVALUATED;
43
- export const PARTIAL = EVALUATION_STATUS.PARTIAL;
43
+ export const PARTIAL = EVALUATION_STATUS.PARTIAL; // used by its own test
44
44
  export const NOT_EVALUATED = EVALUATION_STATUS.NOT_EVALUATED;
45
- export const UNSUPPORTED = EVALUATION_STATUS.UNSUPPORTED;
46
- export const REFUSED = EVALUATION_STATUS.REFUSED;
45
+ export const UNSUPPORTED = EVALUATION_STATUS.UNSUPPORTED; // used by its own test
46
+ export const REFUSED = EVALUATION_STATUS.REFUSED; // used by its own test
47
47
 
48
48
  // ---------------------------------------------------------------------------
49
49
  // Evaluation contract types — which gates are required per evaluation type
@@ -71,7 +71,7 @@ export const EVALUATION_CONTRACT_TYPES = Object.freeze({
71
71
  *
72
72
  * @type {Readonly<Object<string, ReadonlySet<string>>>}
73
73
  */
74
- export const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
74
+ const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
75
75
  [EVALUATION_CONTRACT_TYPES.CANONICAL]: Object.freeze(
76
76
  new Set([
77
77
  "domainCoverage",
@@ -107,7 +107,7 @@ export const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
107
107
  * Defaults to SCENARIO (most restrictive).
108
108
  * @returns {boolean}
109
109
  */
110
- export function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES.SCENARIO) {
110
+ function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES.SCENARIO) {
111
111
  const required = REQUIRED_GATES_FOR_CONTRACT[contractType];
112
112
  return required ? required.has(gateKey) : true;
113
113
  }
@@ -121,6 +121,7 @@ export function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES
121
121
  * @type {Readonly<{key: string, label: string, type: string}[]>}
122
122
  */
123
123
  export const EVIDENCE_COMPLETE_GATES = Object.freeze([
124
+ // used by its own test
124
125
  { key: "domainCoverage", label: "Domain coverage", type: "ratio" },
125
126
  { key: "claimEvidenceCoverage", label: "Claim evidence coverage", type: "ratio" },
126
127
  { key: "causalCoverage", label: "Causal coverage", type: "ratio" },
@@ -265,7 +266,7 @@ export const REQUIRED_DOMAINS = Object.freeze([
265
266
  * @param {string} status EVALUATION_STATUS value
266
267
  * @returns {boolean}
267
268
  */
268
- export function isDomainEvaluated(status) {
269
+ function isDomainEvaluated(status) {
269
270
  return status === EVALUATION_STATUS.EVALUATED;
270
271
  }
271
272
 
@@ -32,16 +32,12 @@
32
32
  * than explaining constraints from a graph whose edges silently under-represent
33
33
  * the real architecture.
34
34
  */
35
- import {
36
- blindSpotRows,
37
- isWholeFileFailure,
38
- unresolvableLiteralCount,
39
- } from "../analysis/source-util.mjs";
40
35
  import { UsageError } from "../errors.mjs";
41
36
  import { judgeEdge } from "../rules/edge-constraints.mjs";
42
37
  import { findConstraintsFor } from "../rules/tags.mjs";
43
38
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
44
39
  import { formatContextReport } from "../report/context-text.mjs";
40
+ import { coverageVerdict } from "./coverage-verdict.mjs";
45
41
  import { resolveProvenance } from "./provenance.mjs";
46
42
  import { readAdrContext } from "./adr.mjs";
47
43
  import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
@@ -150,27 +146,23 @@ export function contextCommand(projectName, commandContext, config) {
150
146
  );
151
147
  }
152
148
 
153
- const notAnalyzed = commandContext.analysis.failures
154
- .filter(isWholeFileFailure)
155
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
156
-
157
- // The same completeness `check` claims (#595, #599): unjudged sites and
158
- // a zero-analyzed run defeat it here exactly as they do there, so a
159
- // context report cannot look complete over a tree the run could not
160
- // fully read.
161
- const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
162
- const complete =
163
- notAnalyzed.length === 0 && blindSpotCount === 0 && commandContext.analysis.analyzed > 0;
164
- const status = complete ? "ok" : "no-verdict";
165
- const exitCode = complete ? 0 : 3;
149
+ // The completeness verdict is the shared constructor's, not this file's —
150
+ // the same law `graph`/`discover` run (#595, #599): unjudged sites and a
151
+ // zero-analyzed run defeat it here exactly as they do there, so a context
152
+ // report cannot look complete over a tree the run could not fully read
153
+ // while the faces beside it refuse the same tree. The restatement this
154
+ // replaces carried all three axes, so composing it moves no byte on any
155
+ // input.
156
+ const verdict = coverageVerdict(commandContext);
157
+ const { complete, status, exitCode } = verdict;
166
158
 
167
159
  const coverage = {
168
160
  complete,
169
161
  projects: Object.keys(graph.nodes).length,
170
162
  analyzedFiles: commandContext.analysis.analyzed,
171
163
  imports: commandContext.analysis.imports.length,
172
- notAnalyzed,
173
- blindSpots: blindSpotRows(commandContext.analysis.failures),
164
+ notAnalyzed: verdict.notAnalyzed,
165
+ blindSpots: verdict.blindSpots,
174
166
  notes: [
175
167
  "per-edge violations cover only depConstraints (3 of 15 violation types). " +
176
168
  "A dependency with no violations here may still violate npm-ban, circular-dependency, " +
@@ -111,3 +111,49 @@ export function partitionUnownedCoverage({ rows, unownedGap, unclaimedFiles, tra
111
111
  },
112
112
  };
113
113
  }
114
+
115
+ /**
116
+ * Withdraws each accepted unclaimed file's failure from the run's failure
117
+ * list — the acceptance channel's effect on it, kept beside the matching so
118
+ * the channel's contract (accept the file ⇒ withdraw its refusal) is one
119
+ * module's. `./check.mjs` holds the list and narrows `acceptedFiles` to the
120
+ * unclaimed half; `./waivers.mjs` has no failure list and only reports rows.
121
+ *
122
+ * An accepted unclaimed file carries exactly one failure — its unowned-file
123
+ * refusal, the only row any producer appends for a file no analyzer ever
124
+ * read. The withdrawal does not TRUST that assumption, it ENFORCES it: an
125
+ * accepted file carrying more than one failure means some producer appended a
126
+ * second row for it, and a splice by `sourceFile` would take the second one
127
+ * down with the first — the failure count falls, `coverage.notAnalyzed` loses
128
+ * a row, and a could-not-look run tips toward a clean one, the silent
129
+ * direction (`../../../../AGENTS.md`). The run refuses instead, naming the
130
+ * file and the count it found.
131
+ *
132
+ * @param {{sourceFile: string}[]} failures The run's failure list, in order.
133
+ * @param {Set<string>} acceptedFiles The accepted subset of the unclaimed
134
+ * list — `partitionUnownedCoverage`'s `acceptedFiles` narrowed to that list
135
+ * by the caller.
136
+ * @returns {{sourceFile: string}[]} A new array without the accepted files'
137
+ * single failures; the input is not mutated.
138
+ * @throws {Error} when an accepted file carries more than one failure.
139
+ */
140
+ export function withdrawAcceptedUnclaimedFailures(failures, acceptedFiles) {
141
+ const carried = new Map();
142
+ for (const failure of failures) {
143
+ if (!acceptedFiles.has(failure.sourceFile)) continue;
144
+ carried.set(failure.sourceFile, (carried.get(failure.sourceFile) ?? 0) + 1);
145
+ }
146
+ const overloaded = [...carried.entries()].filter(([, count]) => count > 1);
147
+ if (overloaded.length > 0) {
148
+ const named = overloaded
149
+ .map(([file, count]) => `'${file}' (carries ${count} failures)`)
150
+ .join(", ");
151
+ throw new Error(
152
+ `archkeep: the coverage.unowned acceptance channel cannot withdraw ${named} — ` +
153
+ `acceptance withdraws exactly one failure per file (the unowned-file refusal), and ` +
154
+ `withdrawing by file would drop the second one with it, understating what this run ` +
155
+ `could not read`,
156
+ );
157
+ }
158
+ return failures.filter((failure) => !acceptedFiles.has(failure.sourceFile));
159
+ }
@@ -49,7 +49,7 @@ import {
49
49
  isWholeFileFailure,
50
50
  unresolvableLiteralCount,
51
51
  } from "../analysis/source-util.mjs";
52
- import { EXIT, coverageIncompleteReasons } from "../verdict.mjs";
52
+ import { EXIT, coverageComplete, coverageIncompleteReasons } from "../verdict.mjs";
53
53
  import { buildDecision } from "../report/evidence.mjs";
54
54
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
55
55
  import { formatCoverageIncomplete } from "../report/text.mjs";
@@ -96,7 +96,17 @@ export function coverageVerdict(commandContext, { acceptedFiles } = {}) {
96
96
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
97
97
  const blindSpots = blindSpotRows(analysis.failures);
98
98
  const blindSpotCount = unresolvableLiteralCount(analysis.failures);
99
- const complete = notAnalyzed.length === 0 && blindSpotCount === 0 && analysis.analyzed > 0;
99
+ // The conjunction is `../verdict.mjs`'s `coverageComplete` the same
100
+ // predicate `verdictFor`'s decision face reads, so `check`'s envelope
101
+ // cannot carry a `coverage.complete` and a `decision.coverageComplete`
102
+ // that disagree about one run. The predicate is defined there, not here,
103
+ // because `verdictFor` needs it and this module already imports from that
104
+ // one: the reverse import would be a cycle.
105
+ const complete = coverageComplete({
106
+ unchecked: notAnalyzed.length,
107
+ blindSpotCount,
108
+ analyzed: analysis.analyzed,
109
+ });
100
110
  return {
101
111
  notAnalyzed,
102
112
  blindSpots,
@@ -140,6 +140,7 @@ export function declaresCustomRules(config) {
140
140
  * @returns {(artifact: string) => Uint8Array|null}
141
141
  */
142
142
  export function readArtifactBytes(root) {
143
+ // used by its own test
143
144
  return (artifact) => {
144
145
  const abs = join(root, artifact);
145
146
  if (containmentViolation(root, abs) !== null) return null;
@@ -79,6 +79,7 @@ import { namespacedId } from "./custom-rules.mjs";
79
79
  * constraint: object|null}}|{ok: false, reason: string}}
80
80
  */
81
81
  export function violationIdentity(violation) {
82
+ // used by its own test
82
83
  if (violation === null || typeof violation !== "object" || Array.isArray(violation)) {
83
84
  return { ok: false, reason: `violation is ${describe(violation)}, not an object` };
84
85
  }
@@ -148,6 +149,7 @@ export function violationIdentity(violation) {
148
149
  * original violation plus the `reason` its identity could not be stated.
149
150
  */
150
151
  export function classifyViolations({ base, head, suppressions = [], now = referenceTime() }) {
152
+ // used by its own test
151
153
  const baseIdentified = base.map(identityOf);
152
154
  const headIdentified = head.map(identityOf);
153
155
 
@@ -237,6 +239,7 @@ export function classifyViolations({ base, head, suppressions = [], now = refere
237
239
  * unknown: object[]}}
238
240
  */
239
241
  export function classifyUnresolvableRecords({ base, head, sourceProjectOf }) {
242
+ // used by its own test
240
243
  const attribute = sourceProjectOf ?? (() => null);
241
244
  const baseIdentified = base
242
245
  .filter(isUnresolvable)
@@ -48,10 +48,11 @@
48
48
  import { readFileSync } from "node:fs";
49
49
 
50
50
  import { canonicalJsonReplacer } from "../canonical.mjs";
51
+ import { isPlainObject } from "../values.mjs";
51
52
  import { buildDependencies, buildProjects } from "./graph.mjs";
52
53
 
53
54
  /** The only snapshot schemaVersion this module writes and reads. */
54
- export const EVIDENCE_SNAPSHOT_SCHEMA_VERSION = 1;
55
+ export const EVIDENCE_SNAPSHOT_SCHEMA_VERSION = 1; // used by its own test
55
56
 
56
57
  /**
57
58
  * Builds the snapshot object from already-captured evidence.
@@ -337,6 +338,7 @@ export function readEvidenceSnapshot(path, io = {}) {
337
338
  * @throws {Error} on every condition above.
338
339
  */
339
340
  export function parseEvidenceSnapshot(text, path) {
341
+ // used by its own test
340
342
  let parsed;
341
343
  try {
342
344
  parsed = JSON.parse(text);
@@ -518,11 +520,6 @@ export function providerMismatch(baselineProvider, currentProvider) {
518
520
  );
519
521
  }
520
522
 
521
- /** Non-empty plain-object guard used across validation. */
522
- function isPlainObject(value) {
523
- return value !== null && typeof value === "object" && !Array.isArray(value);
524
- }
525
-
526
523
  /** Describes a value for error messages without dumping it. */
527
524
  function describe(value) {
528
525
  if (value === null) return "null";
@@ -74,6 +74,7 @@ import {
74
74
  import { stripTrailingSlashes } from "../path-util.mjs";
75
75
  import { referenceTime } from "../governance/clock.mjs";
76
76
  import {
77
+ assertReproducibleEventIdentity,
77
78
  eventDedupeKey,
78
79
  eventId,
79
80
  EVOLUTION_EVENT_SCHEMA_VERSION,
@@ -161,6 +162,7 @@ export function refusePluginGapHead(commandContext, activity) {
161
162
  * @throws {Error} on either condition.
162
163
  */
163
164
  export function refuseUnjudgeableHead(commandContext, activity) {
165
+ // used by its own test
164
166
  refusePluginGapHead(commandContext, activity);
165
167
  const notAnalyzed = commandContext.analysis.failures.filter(isWholeFileFailure);
166
168
 
@@ -271,6 +273,8 @@ export function captureDelta(commandContext, { config }) {
271
273
  * type: string}[], workspaceLayout?: object, exemptedFiles?: string[]}} storedGraph
272
274
  * A validated snapshot's `graph` section (`parseEvidenceSnapshot`).
273
275
  * @returns {object} A graph `evaluate()` consumes.
276
+ * @throws {Error} when an `exemptedFiles` entry is not a string — the engine's
277
+ * own filter would drop it in silence (the refusal beside the copy below).
274
278
  */
275
279
  export function evidenceGraphToProjectGraph(storedGraph) {
276
280
  /** @type {Record<string, object>} */
@@ -297,7 +301,26 @@ export function evidenceGraphToProjectGraph(storedGraph) {
297
301
  const graph = { nodes, dependencies };
298
302
  if (storedGraph.workspaceLayout !== undefined)
299
303
  graph.workspaceLayout = storedGraph.workspaceLayout;
300
- if (Array.isArray(storedGraph.exemptedFiles)) graph.exemptedFiles = storedGraph.exemptedFiles;
304
+ if (Array.isArray(storedGraph.exemptedFiles)) {
305
+ // An entry that is not a string is refused here rather than carried:
306
+ // `../rules/index.mjs`'s `createContext` filters `graph.exemptedFiles`
307
+ // with `typeof file === "string"`, so a corrupted snapshot's entry would
308
+ // ride through this conversion and vanish there — the exemption set the
309
+ // snapshot recorded silently shrinking by one file, the under-count
310
+ // disclosed nowhere.
311
+ const malformed = storedGraph.exemptedFiles
312
+ .map((entry, at) => ({ entry, at }))
313
+ .filter(({ entry }) => typeof entry !== "string");
314
+ if (malformed.length > 0) {
315
+ const { entry, at } = malformed[0];
316
+ throw new Error(
317
+ `archkeep: the snapshot's graph.exemptedFiles[${at}] is ${JSON.stringify(entry)}, ` +
318
+ `not a string — the rule engine drops such entries in silence, which would shrink ` +
319
+ `the exemption set the snapshot recorded; re-capture the baseline or correct the file`,
320
+ );
321
+ }
322
+ graph.exemptedFiles = storedGraph.exemptedFiles;
323
+ }
301
324
  return graph;
302
325
  }
303
326
 
@@ -373,6 +396,7 @@ export function sourceProjectAttributor(headGraph, baselineProjects) {
373
396
  * @returns {"accepted"|"rejected"|"no-verdict"}
374
397
  */
375
398
  export function deltaDisposition({ status }) {
399
+ // used by its own test
376
400
  if (status === "no-verdict") return "no-verdict";
377
401
  if (status === "findings") return "rejected";
378
402
  return "accepted";
@@ -810,26 +834,16 @@ export async function deltaCommand(
810
834
  let eventWrite = null;
811
835
  if (eventOut !== null && eventOut !== undefined) {
812
836
  // F-delta-event-id: an evolution event is only written from a reproducible
813
- // identity — a committed, clean head and a clean base. A commitless head
814
- // has no revision to name, and a dirty tree names a commit its evidence
815
- // does not back; either way TWO distinct evidence states collapse onto ONE
816
- // event id, so a later transition is silently lost or aliased (the silent
817
- // direction). Refuse loudly instead. The same run without `--event-out`
818
- // stays a byte-identical in-memory delta.
819
- if (typeof headCommit !== "string") {
820
- throw new Error(
821
- "archkeep: refusing to write a delta event without a committed head — a commitless " +
822
- "head has no reproducible event identity, and every distinct head state would " +
823
- "collide on one event id. Commit the head, or capture without --event-out.",
824
- );
825
- }
826
- if (baseline.provenance?.dirty === true || headProvenance?.dirty === true) {
827
- throw new Error(
828
- "archkeep: refusing to write a delta event from a dirty working tree — the event " +
829
- "would name a commit whose evidence is uncommitted, and distinct uncommitted " +
830
- "states would collide on one event id. Commit both sides first.",
831
- );
832
- }
837
+ // identity — a committed, clean head and a clean base, by the shared law
838
+ // `assertReproducibleEventIdentity` owns. The wording is frozen there; the
839
+ // messages consumers match on are byte-identical to the inline refusals
840
+ // this call replaces.
841
+ assertReproducibleEventIdentity({
842
+ label: "delta",
843
+ headCommit,
844
+ baseDirty: baseline.provenance?.dirty === true,
845
+ headDirty: headProvenance?.dirty === true,
846
+ });
833
847
  // The architecture-debt sub-ledger (design §8): judged by re-running the
834
848
  // current intent over this run's base and head graphs — a drift finding
835
849
  // present at head but not base is introduced; one gone is resolved. Both
@@ -250,7 +250,7 @@ export async function driftForCheck(commandContext, io = {}) {
250
250
  * @param {object} intent The normalized intent model.
251
251
  * @returns {{kind: string, row: object}[]}
252
252
  */
253
- export function intentDecisionRefRows(intent) {
253
+ function intentDecisionRefRows(intent) {
254
254
  return governanceIntentRows(intent).filter(
255
255
  ({ row }) => typeof row?.decisionRef === "string" && row.decisionRef.trim() !== "",
256
256
  );