@ecoma-io/archkeep 0.22.2 → 0.24.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 (63) hide show
  1. package/package.json +40 -13
  2. package/src/analysis/jvm/packages.mjs +0 -17
  3. package/src/analysis/manifest-util.mjs +14 -5
  4. package/src/analysis/markdown.mjs +340 -0
  5. package/src/analysis/source-util.mjs +5 -4
  6. package/src/analysis/typescript.mjs +146 -0
  7. package/src/architecture-intent/judge.mjs +1 -1
  8. package/src/architecture-intent/model.mjs +3 -12
  9. package/src/commands/README.md +16 -7
  10. package/src/commands/change-intent.mjs +2 -11
  11. package/src/commands/check.mjs +234 -12
  12. package/src/commands/completeness.mjs +0 -32
  13. package/src/commands/context-command.mjs +13 -21
  14. package/src/commands/context.mjs +46 -47
  15. package/src/commands/coverage-verdict.mjs +12 -2
  16. package/src/commands/delta-snapshot.mjs +1 -5
  17. package/src/commands/diff.mjs +1 -1
  18. package/src/commands/discover.mjs +7 -3
  19. package/src/commands/evaluation-primitives.mjs +6 -2
  20. package/src/commands/explain.mjs +17 -20
  21. package/src/commands/graph.mjs +7 -0
  22. package/src/commands/health.mjs +4 -0
  23. package/src/commands/impact-reachability.mjs +104 -0
  24. package/src/commands/impact.mjs +9 -71
  25. package/src/commands/plan-context-command.mjs +5 -1
  26. package/src/commands/policy.mjs +4 -4
  27. package/src/commands/provenance.mjs +8 -2
  28. package/src/commands/scenario-evaluation.mjs +1 -1
  29. package/src/config.mjs +171 -17
  30. package/src/custom-rules/evidence.mjs +1 -1
  31. package/src/custom-rules/host.mjs +2 -2
  32. package/src/custom-rules/values.mjs +8 -3
  33. package/src/errors.mjs +24 -2
  34. package/src/eslint-config.mjs +2 -5
  35. package/src/fixtures/evolution-lifecycle/workspace.mjs +0 -5
  36. package/src/governance/adr-registry.mjs +33 -17
  37. package/src/governance/decision-graph.mjs +1 -1
  38. package/src/governance/evolution-store.mjs +36 -18
  39. package/src/governance/fitness-registry.mjs +1 -14
  40. package/src/governance/profile-registry.mjs +20 -23
  41. package/src/governance/provenance-record.mjs +1 -11
  42. package/src/governance/reconcile-score.mjs +0 -3
  43. package/src/governance/row-schema.mjs +1 -14
  44. package/src/governance/verdict.mjs +168 -4
  45. package/src/intent/intent-manifest.json +16 -16
  46. package/src/lsp/diagnose.mjs +2 -2
  47. package/src/lsp/server.mjs +1 -1
  48. package/src/lsp/workspace-index.mjs +3 -3
  49. package/src/options.mjs +1 -1
  50. package/src/providers/model-gate.mjs +59 -0
  51. package/src/providers/moon.mjs +6 -6
  52. package/src/providers/native/model.mjs +2 -16
  53. package/src/report/README.md +13 -7
  54. package/src/report/evidence.mjs +11 -168
  55. package/src/report/json.mjs +10 -7
  56. package/src/report/sarif.mjs +29 -4
  57. package/src/report/text.mjs +39 -0
  58. package/src/rules/README.md +18 -9
  59. package/src/{commands → rules}/edge-constraints.mjs +18 -12
  60. package/src/rules/index.mjs +30 -0
  61. package/src/values.mjs +49 -0
  62. package/src/verdict.mjs +58 -7
  63. package/src/workspace.mjs +29 -0
@@ -377,10 +377,19 @@ the resolution order.
377
377
  `check` folds into the fitness exit lanes plus the finding catalogue SARIF's
378
378
  descriptors are built from; it prints nothing and decides no exit code.
379
379
 
380
- - **`edge-constraints.mjs`** — edge-constraint analysis shared by `diff` and
381
- `impact`. Judges a single graph edge against the `depConstraints` table,
382
- producing violations with their constraint rows. Checks only tag-based rules
383
- (`onlyDependOnLibsWithTags`, `notDependOnLibsWithTags`,
384
- `projectWithoutTagsCannotHaveDependencies`); npm/circular/lazy-load rules
385
- need import-site details that graph edges do not carry. A consumer who needs
386
- the complete verdict should run `check`.
380
+ - **`impact-reachability.mjs`** — reverse reachability (`computeImpact`: a
381
+ project's direct and transitive dependents) shared by the `impact`,
382
+ `scenario` and `context --plan` commands and the canonical evaluator
383
+ (`./evaluation-primitives.mjs`). It lives apart from `./impact.mjs` rather
384
+ than inside it because that module drives `./impact-statement.mjs`, which
385
+ drives the evaluator holding the walk in the command module is what made
386
+ `evaluation-primitives impact impact-statement → evaluation-primitives`
387
+ the engine's only import cycle (#644). `./impact.mjs` re-exports
388
+ `computeImpact`, so its existing importers keep resolving; new code imports
389
+ this module.
390
+
391
+ The edge-constraint analysis `diff`, `impact` and `check` share is not one of
392
+ these modules: it judges graph edges, so it lives in the rules layer whose
393
+ constraint table and tag matching it reads (`../rules/edge-constraints.mjs`) —
394
+ which is also what lets `../lsp/diagnose.mjs` share it without importing from
395
+ this application layer (#649).
@@ -41,6 +41,8 @@
41
41
 
42
42
  import { readFile as readFileFromDisk } from "node:fs/promises";
43
43
 
44
+ import { describe, isPlainObject } from "../values.mjs";
45
+
44
46
  /** The only `version` this module accepts. A different value is a load error. */
45
47
  export const CHANGE_INTENT_VERSION = "1";
46
48
 
@@ -108,17 +110,6 @@ export function edgePairKey({ from, to }) {
108
110
  return `${from}\u0000${to}`;
109
111
  }
110
112
 
111
- /** A value's type, for an error message that shows what was actually there. */
112
- function describe(value) {
113
- if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
114
- if (value === null) return "null";
115
- return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
116
- }
117
-
118
- /** @type {(value: unknown) => value is Record<string, unknown>} */
119
- const isPlainObject = (value) =>
120
- value !== null && typeof value === "object" && !Array.isArray(value);
121
-
122
113
  /** `key` on `obj` that is not one of `allowed` — the reject-by-name rule. */
123
114
  function unknownKeys(obj, allowed) {
124
115
  return Object.keys(obj).filter((key) => !allowed.includes(key));
@@ -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,
@@ -23,11 +24,15 @@ import { stripTrailingSlashes } from "../path-util.mjs";
23
24
  import { suppressionCovers } from "../config.mjs";
24
25
  import { referenceTime } from "../governance/clock.mjs";
25
26
  import { suppressionFate } from "../governance/waiver.mjs";
26
- import { resolveCommandContext, unownedGapWithoutRunConfiguration } from "./context.mjs";
27
+ import {
28
+ resolveCommandContext,
29
+ unownedGapWithoutRunConfiguration,
30
+ untrackedOwnedFiles,
31
+ } from "./context.mjs";
27
32
  import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
28
33
  import { readAdrContext } from "./adr.mjs";
29
34
  import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
30
- import { declaredEdgeViolationsForCheck } from "./edge-constraints.mjs";
35
+ import { declaredEdgeViolationsForCheck, judgeEdge } from "../rules/edge-constraints.mjs";
31
36
  import { customRulesForCheck, declaresCustomRules } from "./custom-rules.mjs";
32
37
  import { driftForCheck } from "./drift.mjs";
33
38
  import { fitnessForCheck } from "./fitness.mjs";
@@ -36,15 +41,17 @@ import { resolvePolicy } from "./policy.mjs";
36
41
  import { resolveProvenance } from "./provenance.mjs";
37
42
  import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
38
43
  import { compareGoWork, parseGoWorkUse } from "../go-work.mjs";
44
+ import { mergeDeclaredEdges } from "../providers/native/graph.mjs";
39
45
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
40
46
  import { formatSarif } from "../report/sarif.mjs";
41
47
  import { formatReport } from "../report/text.mjs";
42
48
  import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
43
- import { evaluateRun, exemptResolvedFile } from "../rules/index.mjs";
49
+ import { applySuppressionTable, evaluateRun, exemptResolvedFile } from "../rules/index.mjs";
50
+ import { buildReachability } from "../rules/reachability.mjs";
44
51
  import { orphanedNotDependOnTags, unmatchedConstraintRows } from "../rules/tags.mjs";
45
52
  import { judgeTsconfigPaths } from "../tsconfig-paths.mjs";
46
- import { verdictFor } from "../verdict.mjs";
47
- import { listTrackedFiles } from "../workspace.mjs";
53
+ import { coverageComplete, verdictFor } from "../verdict.mjs";
54
+ import { listTrackedFiles, listUntrackedFiles } from "../workspace.mjs";
48
55
 
49
56
  /**
50
57
  * A total order over violations, so a report's byte sequence is an invariant
@@ -184,10 +191,21 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
184
191
  * readers: a test drives the real analysis, the real rules and the real
185
192
  * report over a fixture tree, and pins the exact `file:line:column` a
186
193
  * developer would act on, without an Nx installation or a git repository.
194
+ * `listUntracked` is the third git seam, the one the tracked universe is
195
+ * audited against (#675): it answers with the worktree files the universe
196
+ * left out (`../workspace.mjs`'s `listUntrackedFiles`). It is injected
197
+ * separately rather than derived from `listFiles` because the two are only
198
+ * paired when BOTH are git's — a caller that injected a tracked universe has
199
+ * already decided what the whole tree is, and asking git what else exists
200
+ * would answer a different tree than the one `listFiles` named. That is why
201
+ * the default below resolves the real listing only when `listFiles` is also
202
+ * the real one, and treats an injected universe as the whole story: its
203
+ * untracked complement is empty by construction, not by claim.
187
204
  *
188
205
  * @param {{format: string, config: string|null, paths: string[],
189
206
  * evidenceOut?: string|null}} options
190
- * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} context
207
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function,
208
+ * listUntracked?: Function}} context
191
209
  * @returns {Promise<{report: string, violations: number, declaredEdgeFindings: number,
192
210
  * goWorkDrift: number, tsconfigPathsDead: number, intentFindings: number,
193
211
  * intentUnresolved: number, intentUnresolvedDecisionRefs: number, fitnessFail: number,
@@ -195,7 +213,10 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
195
213
  * customRuleEvidence: {rule: string, bytes: Uint8Array}[], customRulesDeclared: boolean,
196
214
  * analyzed: number, unchecked: number, blindSpots: number, waived?: number}>}
197
215
  */
198
- export async function check(options, { cwd, readGraph, listFiles = listTrackedFiles }) {
216
+ export async function check(
217
+ options,
218
+ { cwd, readGraph, listFiles = listTrackedFiles, listUntracked },
219
+ ) {
199
220
  const commandContext = resolveCommandContext(
200
221
  { cwd, paths: options.paths },
201
222
  { readGraph, listFiles },
@@ -435,6 +456,41 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
435
456
  }
436
457
  }
437
458
 
459
+ // The markdown document track, keyed by presence like every fold above it:
460
+ // a policy that declares no `markdown` block reaches nothing here and hears
461
+ // nothing anywhere — no edges, no failures, no envelope key — so a
462
+ // config-absent run is byte-identical to one this fold never existed for
463
+ // (`../../../../AGENTS.md`, "a change to what is reported on an unchanged
464
+ // workspace is a breaking change"). A policy that DOES declare one has its
465
+ // documents read (`../analysis/markdown.mjs`'s `foldMarkdownTrack`), the
466
+ // resolved pairings folded into the graph the way the declared manifest
467
+ // track's edges already are (`mergeDeclaredEdges`, the same dedup key), and
468
+ // every whole-file failure the read earned pushed into the same `failures`
469
+ // funnel the analysis uses — an unresolvable marker is an `unchecked` file,
470
+ // never a clean one.
471
+ //
472
+ // The fold runs BEFORE the walk below on purpose: a document pairing is a
473
+ // project-to-project dependency claim, and from here on this run's graph
474
+ // carries it exactly as it carries a csproj's `ProjectReference` — the tag
475
+ // rows, the circularity walk and the reachability behind them all read one
476
+ // graph, so a pairing cannot be legal here and violating in `context`.
477
+ // What it does not join is the twelve non-tag import-site rules: those need
478
+ // a specifier, a file and a resolution, and a document marker has none of
479
+ // them — it is judged as the EDGE it drew, the same 3-of-15 limit
480
+ // `declaredEdgeViolationsForCheck` documents for `implicit` edges.
481
+ let markdownTrack = null;
482
+ if (config !== null && config.markdown !== undefined) {
483
+ markdownTrack = foldMarkdownTrack({
484
+ tracked,
485
+ owned: commandContext.owned,
486
+ readFile: (file) => workspace.readFile(file),
487
+ workspace,
488
+ markdown: config.markdown,
489
+ });
490
+ mergeDeclaredEdges(graph, markdownTrack.edges);
491
+ failures.push(...markdownTrack.failures);
492
+ }
493
+
438
494
  // Both faces of one walk: the run's verdict, and the raw superset it was
439
495
  // picked from — every candidate up to each site's surviving group, including
440
496
  // the verdicts the suppression table removed to get there. `evaluate` alone
@@ -444,8 +500,66 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
444
500
  // the gate's waiver-expiry judgement below and the engine's are the same
445
501
  // judgement, not two reads of the clock a boundary instant could split.
446
502
  const now = referenceTime();
447
- const { violations: judged, rawViolations } = evaluateRun(imports, graph, { ...config, now });
448
- const violations = sortViolations(judged);
503
+ const { violations: judged, rawViolations: judgedRaw } = evaluateRun(imports, graph, {
504
+ ...config,
505
+ now,
506
+ });
507
+
508
+ // The document pairings' own verdicts, judged by the machinery that already
509
+ // existed: `judgeEdge` — the same function `declaredEdgeViolationsForCheck`
510
+ // runs `implicit` edges through, so a doc pairing and a declared manifest
511
+ // edge can never disagree about the same boundary — over the graph the fold
512
+ // above already joined, with reachability built once for the whole claim
513
+ // list. The same empty-table exit `declaredEdgeViolationsForCheck` takes: a
514
+ // workspace declaring no `depConstraints` has opted out of tag enforcement
515
+ // entirely, and folding `projectWithoutTagsCannotHaveDependencies` onto
516
+ // every marker anyway would flag an opted-out workspace for a reason its
517
+ // import sites are never flagged for. Each verdict is then reshaped into the
518
+ // exact `Violation` record `violationOf` builds — position and specifier
519
+ // from the MARKER (the line a reader edits is the document's), project and
520
+ // constraint from the edge — so suppression, waiver annotation, sorting and
521
+ // the SARIF face all treat it as the ordinary violation it is.
522
+ /** @type {object[]} */
523
+ const markdownRaw = [];
524
+ if (config !== null && markdownTrack !== null && config.depConstraints.length > 0) {
525
+ const reachability = buildReachability(graph);
526
+ for (const claim of markdownTrack.claims) {
527
+ for (const verdict of judgeEdge(
528
+ { source: claim.source, target: claim.target },
529
+ graph.nodes,
530
+ graph.dependencies,
531
+ config.depConstraints,
532
+ reachability,
533
+ )) {
534
+ markdownRaw.push({
535
+ sourceFile: claim.file,
536
+ line: claim.line,
537
+ column: claim.column,
538
+ specifier: claim.name,
539
+ kind: claim.type,
540
+ messageId: verdict.messageId,
541
+ message: verdict.message,
542
+ sourceProject: verdict.source,
543
+ targetProject: verdict.target,
544
+ constraint: verdict.constraint,
545
+ data: verdict.data,
546
+ });
547
+ }
548
+ }
549
+ }
550
+ // The same table, the same behaviour: a suppression row whose glob covers a
551
+ // document removes that document's verdict (or waives it, with the same
552
+ // expiry evidence an import-site waiver carries), and the RAW records join
553
+ // `rawViolations` below so a row covering only document verdicts still
554
+ // counts as alive — a table that is doing its job must not read as dead
555
+ // because the violations it removes live in a different fold.
556
+ const markdownViolations =
557
+ config !== null && markdownTrack !== null
558
+ ? applySuppressionTable(config.suppressions, markdownRaw, now)
559
+ : [];
560
+
561
+ const rawViolations = [...judgedRaw, ...markdownRaw];
562
+ const violations = sortViolations([...judged, ...markdownViolations]);
449
563
  // An ACTIVE waiver keeps the violation it accepts in the findings list,
450
564
  // marked `waivedBy` — the run is still non-zero (waiving does not flip
451
565
  // exit 1 → 0), and this count is the additive "accepted violations" number
@@ -686,7 +800,10 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
686
800
  config !== null &&
687
801
  options.paths.length === 0 &&
688
802
  failures.length === 0 &&
689
- (config.suppressions.length > 0 || config.depConstraints.length > 0 || coverageRows.length > 0)
803
+ (config.suppressions.length > 0 ||
804
+ config.depConstraints.length > 0 ||
805
+ coverageRows.length > 0 ||
806
+ config.markdown !== undefined)
690
807
  ) {
691
808
  const deadRows = [];
692
809
  // The third dead table: a `coverage.unowned` row matching no unowned file
@@ -737,6 +854,34 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
737
854
  );
738
855
  }
739
856
  }
857
+ // The document track's two dead tables, measured against what the fold
858
+ // actually selected (`markdownTrack.includeCounts`/`rowMatches` — the
859
+ // same counts the fold's own selection is built from, not a re-derivation
860
+ // that could disagree): an include glob matching no tracked document, and
861
+ // a marker row matching no line in any included document, both govern
862
+ // nothing while reading as enforced — every pairing the workspace meant
863
+ // to declare goes unjudged and the run stays green. A whole-file failure
864
+ // above already disabled this gate, so reaching here with a row that
865
+ // matched nothing means the tree genuinely has no such line.
866
+ if (markdownTrack !== null && config.markdown !== undefined) {
867
+ markdownTrack.includeCounts.forEach((count, index) => {
868
+ if (count > 0) return;
869
+ deadRows.push(
870
+ `markdown.include[${index}]: '${config.markdown.include[index]}' matches no tracked ` +
871
+ `document — the document track reads nothing, so every marker row below it governs ` +
872
+ `nothing while reading as enforced. Either the path was never right, or the ` +
873
+ `documents are not tracked`,
874
+ );
875
+ });
876
+ markdownTrack.rowMatches.forEach((count, index) => {
877
+ if (count > 0) return;
878
+ deadRows.push(
879
+ `markdown.markers[${index}]: the pattern matches no line in any included document — ` +
880
+ `this row extracts no pairing, so it enforces nothing while reading as enforced. ` +
881
+ `Either the documents do not carry the marker, or the pattern was never right`,
882
+ );
883
+ });
884
+ }
740
885
  if (deadRows.length > 0) {
741
886
  throw new Error(
742
887
  `archkeep: ${policySource ?? "the boundary config"} describes a workspace that does not ` +
@@ -791,6 +936,45 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
791
936
  ...(unconstrainedImportNote === null ? [] : [unconstrainedImportNote]),
792
937
  ];
793
938
 
939
+ // The tracked-universe boundary, audited (#675): every file a project owns,
940
+ // that an analyzer could judge, and that this run never read because git
941
+ // does not track it yet. The universe above is `listFiles(root)` —
942
+ // `git ls-files` verbatim — so a file that was never `git add`-ed never
943
+ // entered it, and before this audit a run over such a tree printed the same
944
+ // clean verdict as a run over the whole tree, byte for byte, with nothing
945
+ // anywhere naming what the universe had left out. `untrackedOwnedFiles`
946
+ // (`./context.mjs`) narrows the complement to the files whose absence can
947
+ // change what the verdict claims; the ownership and language tests are the
948
+ // two this module already runs over the tracked list, reused rather than
949
+ // re-derived.
950
+ //
951
+ // The verdict posture is the `unowned-files` bargain, and the completeness
952
+ // law decides it: the three no-verdict axes (`verdictFor`'s `unchecked`,
953
+ // `blindSpots`, `analyzed`) are all about files the universe CONTAINED —
954
+ // a whole-file failure, an unjudged site, a run that judged nothing —
955
+ // and the precedent for withholding (zero analysis, #619/#620/#634) is a
956
+ // verdict that was VACUOUS, not one that was scoped. This run judged every
957
+ // file its universe held, so the verdict over that universe stands — exit
958
+ // code and `coverage.complete` unchanged, exactly as the `coverageGaps`
959
+ // channel's contract states (`../../../../docs/reference/json-output.md`:
960
+ // "no kind changes `complete`, `status`, or the exit code") — but the clean
961
+ // verdict over a partial universe is now distinguishable from a clean
962
+ // verdict over the whole one, in both faces, with the full list a parser
963
+ // can act on. What is forbidden here is not exit 0; it is silence.
964
+ //
965
+ // Workspace-wide on purpose, the same posture `unownedGap` holds: a
966
+ // `check <path>` run must not be able to hide an unread file elsewhere in
967
+ // the tree by naming a path that excludes it. And resolved through the
968
+ // `listUntracked` seam's conditional default — an injected universe is the
969
+ // whole story (see this function's doc), so the audit asks git only when
970
+ // git also built the universe.
971
+ const untrackedListing =
972
+ listUntracked ?? (listFiles === listTrackedFiles ? listUntrackedFiles : null);
973
+ const untrackedOwned = untrackedOwnedFiles({
974
+ untracked: untrackedListing === null ? [] : untrackedListing(root),
975
+ projects: commandContext.workspace.projects,
976
+ });
977
+
794
978
  // A polyglot coverage gap: the Nx graph carries no polyglot edges because
795
979
  // the plugin is not registered, but polyglot manifests exist under project
796
980
  // roots. The checker still judged every import it found — this is not a
@@ -864,6 +1048,13 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
864
1048
  ...(unsupportedLanguageFiles.length > 0
865
1049
  ? [{ kind: "unsupported-language", files: [...unsupportedLanguageFiles].sort() }]
866
1050
  : []),
1051
+ // The universe audit above (#675): project-owned, analyzable files the
1052
+ // worktree holds that this run never read, because the universe is the
1053
+ // tracked set. Contributed only when the list is non-empty, so a tree
1054
+ // with nothing beyond its index reports exactly the bytes it reported
1055
+ // before; sorted already, by the one function that built it, so the row's
1056
+ // bytes cannot vary with git's worktree-traversal order (E-F10).
1057
+ ...(untrackedOwned.length > 0 ? [{ kind: "untracked-files", files: untrackedOwned }] : []),
867
1058
  ];
868
1059
 
869
1060
  // One verdict computation for both faces: the JSON envelope spreads it and
@@ -913,8 +1104,14 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
913
1104
  // Complete means the run judged everything in scope: no
914
1105
  // whole-file failure (unchecked), no unresolvable site (#595),
915
1106
  // and at least one file analyzed (#599 — a run that judged
916
- // nothing has no verdict to claim).
917
- complete: unchecked === 0 && blindSpotCount === 0 && analyzed > 0,
1107
+ // nothing has no verdict to claim). Read from the one predicate
1108
+ // `verdictFor`'s decision face reads, so this field and the
1109
+ // envelope's `decision.coverageComplete` are one derivation —
1110
+ // the counts are this command's (the go.work/tsconfig failures
1111
+ // pushed above and the accepted files withdrawn above make the
1112
+ // universe wider than `commandContext.analysis`), the law is
1113
+ // not.
1114
+ complete: coverageComplete({ unchecked, blindSpotCount, analyzed }),
918
1115
  projects: Object.keys(graph.nodes).length,
919
1116
  analyzedFiles: analyzed,
920
1117
  imports: imports.length,
@@ -965,6 +1162,31 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
965
1162
  judged: declaredEdges.judged,
966
1163
  findings: declaredEdges.findings,
967
1164
  },
1165
+ // The document track is a policy DECLARATION, so it takes the
1166
+ // same omitted-key-not-null discipline the intent/fitness/
1167
+ // customRules blocks below state: a workspace whose policy
1168
+ // declares no `markdown` block gets no key at all, and its
1169
+ // envelope is byte-identical to the one it got before this
1170
+ // section existed. The findings themselves are NOT restated
1171
+ // here — they are members of `violations` above (sorted with,
1172
+ // suppressible by, and counted into the verdict exactly like
1173
+ // every import-site violation), so an array here would be one
1174
+ // fact counted in two places; the block states what the track
1175
+ // DID: how many documents the globs selected, how many markers
1176
+ // they carried, where each resolution went.
1177
+ ...(markdownTrack === null
1178
+ ? {}
1179
+ : {
1180
+ markdown: {
1181
+ checked: true,
1182
+ documents: markdownTrack.documents,
1183
+ judged: markdownTrack.judged,
1184
+ resolved: markdownTrack.resolved,
1185
+ ...(markdownTrack.selfPaired > 0
1186
+ ? { selfPaired: markdownTrack.selfPaired }
1187
+ : {}),
1188
+ },
1189
+ }),
968
1190
  // Intent is a governance DECLARATION, absent when the workspace
969
1191
  // chose not to make one: the key is omitted, never written as
970
1192
  // null — the design contract `docs/reference/json-output.md` will
@@ -491,38 +491,6 @@ export function buildEvidenceComplete({
491
491
  };
492
492
  }
493
493
 
494
- /**
495
- * Asserts that the Evidence-Complete contract is satisfied.
496
- * Throws with a detailed message listing every failing gate.
497
- *
498
- * @param {EvidenceCompleteContract} ec The Evidence-Complete contract to verify.
499
- * @returns {void}
500
- * @throws {Error} When any gate fails.
501
- */
502
- export function assertEvidenceComplete(ec) {
503
- if (ec.overallComplete) return;
504
-
505
- const contractType = ec.contractType || EVALUATION_CONTRACT_TYPES.SCENARIO;
506
- const failures = [];
507
- for (const gate of EVIDENCE_COMPLETE_GATES) {
508
- const g = ec.gates[gate.key];
509
- // Skip non-required gates for this contract type
510
- if (g.required === false) continue;
511
- if (!g.pass) {
512
- failures.push(`${gate.label}: ${JSON.stringify(g.value)} (expected pass)`);
513
- }
514
- }
515
-
516
- if (failures.length === 0) return;
517
-
518
- throw new Error(
519
- `Evidence-Complete contract not satisfied.\n` +
520
- ` Contract type: ${contractType}\n` +
521
- ` Overall: ${ec.overallStatus}\n` +
522
- ` Failed gates:\n ${failures.join("\n ")}`,
523
- );
524
- }
525
-
526
494
  // ---------------------------------------------------------------------------
527
495
  // Governance completeness
528
496
  // ---------------------------------------------------------------------------
@@ -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
- import { judgeEdge } from "./edge-constraints.mjs";
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, " +
@@ -35,7 +35,7 @@ import { jvmIndexFailures } from "../analysis/jvm/packages.mjs";
35
35
  import { languageOf } from "../analysis/registry.mjs";
36
36
  import { pythonUnmodelledFailures } from "../analysis/python.mjs";
37
37
  import { rustManifestFailures } from "../analysis/rust.mjs";
38
- import { dedupeWholeFileFailures, fileFailure } from "../analysis/source-util.mjs";
38
+ import { dedupeWholeFileFailures, fileFailure, projectOwning } from "../analysis/source-util.mjs";
39
39
  import {
40
40
  DEFAULT_OPTIONS,
41
41
  NX_CONFIG_FILE,
@@ -54,6 +54,7 @@ import {
54
54
  } from "../providers/moon.mjs";
55
55
  import { nativeProvider } from "../providers/native/index.mjs";
56
56
  import { mergeDeclaredEdges } from "../providers/native/graph.mjs";
57
+ import { requireSingleProjectModel } from "../providers/model-gate.mjs";
57
58
  import {
58
59
  resolveDeclaredManifestEdges,
59
60
  resolveDeclaredManifestFailures,
@@ -119,7 +120,8 @@ function readFileAbsolute(path) {
119
120
 
120
121
  /**
121
122
  * Which Moon directory marks `root` — or none. Presence facts only; see
122
- * `requireSingleProjectModel` below for the one-decision gate.
123
+ * `requireSingleProjectModel` (`../providers/model-gate.mjs`) for the
124
+ * one-decision gate.
123
125
  *
124
126
  * @param {string} root
125
127
  * @returns {{hasNx: boolean, hasNative: boolean, hasMoon: boolean}}
@@ -132,48 +134,6 @@ export function markersAt(root) {
132
134
  };
133
135
  }
134
136
 
135
- /**
136
- * The one gate deciding whether `root` may be judged at all: more than ONE
137
- * project-model marker present is refused, naming what conflicts.
138
- *
139
- * Every entry point that picks a provider must answer this identically —
140
- * `resolveCommandContext` below reads it before any command runs, and
141
- * `../lsp/workspace-index.mjs`'s index build reads it before choosing a
142
- * branch. A second copy of the condition was exactly how the faces drifted
143
- * apart once: the CLI refused a tree carrying a Moon directory beside
144
- * `nx.json`/`archkeep.json` while the editor indexed it anyway — a clean
145
- * diagnostic list over a tree nobody agreed could be judged (#223's silent
146
- * shape, one level up). Moon-versus-Moon coexistence (`.moon/` AND
147
- * `.config/moon/`) is refused inside `../providers/moon.mjs`'s
148
- * `moonMarkerAt`, which this gate calls first; the cross-family pairs are
149
- * refused here, all in the same terms: which model to judge against is a
150
- * decision nobody made, not one this tool can make for them.
151
- *
152
- * @param {string} root
153
- * @param {{exists?: (path: string) => boolean}} [io] Injectable existence
154
- * test (absolute paths), so a test drives this without a filesystem.
155
- * @returns {{hasNx: boolean, hasNative: boolean, moonMarker: string|null}}
156
- * The facts a provider choice needs; `moonMarker` names whichever Moon
157
- * directory is present, `null` when neither spelling is.
158
- * @throws {Error} when more than one marker is present.
159
- */
160
- export function requireSingleProjectModel(root, { exists = existsSync } = {}) {
161
- const moonMarker = moonMarkerAt(root, { exists });
162
- const hasNx = exists(join(root, NX_CONFIG_FILE));
163
- const hasNative = exists(join(root, ARCHKEEP_MODEL_FILE));
164
- const refusal = (a, b) =>
165
- new Error(
166
- `archkeep: ${root} declares both ${a} and ${b} — this tool judges a workspace ` +
167
- `against exactly one project model, and a tree carrying both is a decision nobody made ` +
168
- `rather than one this tool can make for them. Remove whichever one is not the ` +
169
- `workspace's real source of truth for projects and tags.`,
170
- );
171
- if (moonMarker !== null && hasNx) throw refusal(moonMarker, NX_CONFIG_FILE);
172
- if (moonMarker !== null && hasNative) throw refusal(moonMarker, ARCHKEEP_MODEL_FILE);
173
- if (hasNx && hasNative) throw refusal(NX_CONFIG_FILE, ARCHKEEP_MODEL_FILE);
174
- return { hasNx, hasNative, moonMarker };
175
- }
176
-
177
137
  /**
178
138
  * Every marker a workspace root may be recognised by, as one list.
179
139
  *
@@ -191,7 +151,8 @@ export function requireSingleProjectModel(root, { exists = existsSync } = {}) {
191
151
  * selected the user's home directory as a workspace through `~/.moon`,
192
152
  * moonrepo's user-level state directory (#339). A bare `.moon` beside an
193
153
  * `nx.json` at a root already chosen is a different question, and stays with
194
- * `requireSingleProjectModel`'s directory-presence gate below.
154
+ * `requireSingleProjectModel`'s directory-presence gate
155
+ * (`../providers/model-gate.mjs`).
195
156
  *
196
157
  * @type {string[]}
197
158
  */
@@ -485,6 +446,43 @@ export function unownedGapWithoutRunConfiguration(gap, configNames) {
485
446
  };
486
447
  }
487
448
 
449
+ /**
450
+ * The population a tracked-file universe silently leaves out (#675): files
451
+ * present in the worktree that git does not track, that a project owns, and
452
+ * that an analyzer could have judged — the exact complement of the universe
453
+ * `listTrackedFiles` cuts, narrowed to the files whose absence changes what a
454
+ * verdict can claim.
455
+ *
456
+ * Ownership is decided by `projectOwning` — the same predicate
457
+ * `createWorkspace` attributed the tracked list with, so a file cannot be
458
+ * "owned" here and unowned there — and the language test is the same one
459
+ * `unownedAnalyzableFiles` above applies: a file no analyzer claims (a
460
+ * README, a manifest, an image) could never have entered a verdict, so naming
461
+ * it would be a gap that fires on every work-in-progress tree and teaches a
462
+ * reader to skip the line it is written on. Both filters reuse existing
463
+ * answers; neither re-derives a judgment this module already owns.
464
+ *
465
+ * Sorted by plain string comparison, because the caller renders the list as
466
+ * report bytes: `git ls-files --others` answers in worktree-traversal order,
467
+ * and a row whose order varied with the order files happened to be created
468
+ * would break the byte-identity contract (`../../../../docs/reference/json-output.md`)
469
+ * for a tree that never changed.
470
+ *
471
+ * @param {{untracked: string[], projects: {name: string, root: string}[]}} args
472
+ * `untracked` is `listUntrackedFiles`' answer (`../workspace.mjs`),
473
+ * `projects` the workspace's `{name, root}` list — the same array
474
+ * `createWorkspace` attributed the tracked files with.
475
+ * @returns {string[]} Sorted, deduplicated by construction (git lists a path
476
+ * once), and empty exactly when the universe omitted nothing a verdict
477
+ * could have reached.
478
+ */
479
+ export function untrackedOwnedFiles({ untracked, projects }) {
480
+ const files = untracked.filter(
481
+ (file) => projectOwning(projects, file) !== null && languageOf(file) !== null,
482
+ );
483
+ return files.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
484
+ }
485
+
488
486
  /**
489
487
  * Resolves everything a command needs before it can ask its own question:
490
488
  * which workspace, which provider, which files, and what analyzing them
@@ -531,8 +529,9 @@ export function resolveCommandContext(
531
529
  );
532
530
  }
533
531
  // Which provider may judge at all — the one gate
534
- // (`requireSingleProjectModel` above) every entry point shares, CLI and
535
- // language server alike. Moon-versus-Moon rides it through `moonMarkerAt`.
532
+ // (`requireSingleProjectModel`, `../providers/model-gate.mjs`) every entry
533
+ // point shares, CLI and language server alike. Moon-versus-Moon rides it
534
+ // through `moonMarkerAt`.
536
535
  const { hasNative, moonMarker } = requireSingleProjectModel(root);
537
536
  const hasMoon = moonMarker !== null;
538
537