@ecoma-io/archkeep 0.22.2 → 0.23.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 (50) 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/architecture-intent/judge.mjs +1 -1
  5. package/src/architecture-intent/model.mjs +1 -11
  6. package/src/commands/README.md +16 -7
  7. package/src/commands/change-intent.mjs +2 -11
  8. package/src/commands/check.mjs +69 -5
  9. package/src/commands/completeness.mjs +0 -32
  10. package/src/commands/context-command.mjs +1 -1
  11. package/src/commands/context.mjs +46 -47
  12. package/src/commands/diff.mjs +1 -1
  13. package/src/commands/discover.mjs +7 -3
  14. package/src/commands/evaluation-primitives.mjs +6 -2
  15. package/src/commands/impact-reachability.mjs +104 -0
  16. package/src/commands/impact.mjs +9 -71
  17. package/src/commands/scenario-evaluation.mjs +1 -1
  18. package/src/config.mjs +1 -15
  19. package/src/custom-rules/evidence.mjs +1 -1
  20. package/src/custom-rules/host.mjs +2 -2
  21. package/src/custom-rules/values.mjs +8 -3
  22. package/src/errors.mjs +1 -1
  23. package/src/fixtures/evolution-lifecycle/workspace.mjs +0 -5
  24. package/src/governance/adr-registry.mjs +31 -16
  25. package/src/governance/decision-graph.mjs +1 -1
  26. package/src/governance/evolution-store.mjs +33 -16
  27. package/src/governance/fitness-registry.mjs +1 -11
  28. package/src/governance/profile-registry.mjs +20 -23
  29. package/src/governance/provenance-record.mjs +1 -11
  30. package/src/governance/reconcile-score.mjs +0 -3
  31. package/src/governance/row-schema.mjs +1 -11
  32. package/src/governance/verdict.mjs +168 -4
  33. package/src/intent/intent-manifest.json +12 -12
  34. package/src/lsp/diagnose.mjs +2 -2
  35. package/src/lsp/server.mjs +1 -1
  36. package/src/lsp/workspace-index.mjs +3 -3
  37. package/src/options.mjs +1 -1
  38. package/src/providers/model-gate.mjs +59 -0
  39. package/src/providers/moon.mjs +1 -1
  40. package/src/providers/native/model.mjs +2 -16
  41. package/src/report/README.md +13 -7
  42. package/src/report/evidence.mjs +11 -168
  43. package/src/report/json.mjs +10 -7
  44. package/src/report/sarif.mjs +29 -4
  45. package/src/report/text.mjs +39 -0
  46. package/src/rules/README.md +18 -9
  47. package/src/{commands → rules}/edge-constraints.mjs +18 -12
  48. package/src/values.mjs +49 -0
  49. package/src/verdict.mjs +25 -5
  50. package/src/workspace.mjs +29 -0
package/package.json CHANGED
@@ -1,22 +1,49 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.22.2",
4
- "description": "Architecture enforcement for polyglot repositories — dependency graphs and module boundaries for Go, Rust, Python, TypeScript, JavaScript, Vue, Java and Kotlin",
3
+ "version": "0.23.0",
4
+ "description": "Architecture authority for human and agentic software development deterministic, evidence-backed enforcement of declared architecture.",
5
5
  "keywords": [
6
6
  "architecture",
7
- "enforcement",
7
+ "software-architecture",
8
+ "architecture-governance",
9
+ "architecture-enforcement",
10
+ "architecture-as-code",
11
+ "architecture-drift",
12
+ "architecture-compliance",
13
+ "architecture-rules",
14
+ "dependency-analysis",
15
+ "dependency-governance",
8
16
  "dependency-graph",
9
- "monorepo",
10
- "module-boundaries",
11
- "polyglot",
12
17
  "static-analysis",
13
- "language-server",
14
- "nx",
15
- "nx-plugin",
16
- "golang",
17
- "rust",
18
- "python",
19
- "typescript"
18
+ "code-analysis",
19
+ "code-governance",
20
+ "architecture-fitness",
21
+ "fitness-functions",
22
+ "architecture-decision",
23
+ "architecture-decision-record",
24
+ "adr",
25
+ "architecture-evidence",
26
+ "evidence-based",
27
+ "deterministic-analysis",
28
+ "deterministic-verdicts",
29
+ "drift-detection",
30
+ "change-impact-analysis",
31
+ "architecture-debt",
32
+ "architecture-health",
33
+ "architecture-discovery",
34
+ "architecture-reconciliation",
35
+ "polyglot",
36
+ "monorepo",
37
+ "multi-repo",
38
+ "cross-repo",
39
+ "coding-agents",
40
+ "ai-agents",
41
+ "agentic-development",
42
+ "developer-tools",
43
+ "developer-experience",
44
+ "software-governance",
45
+ "engineering-governance",
46
+ "continuous-compliance"
20
47
  ],
21
48
  "type": "module",
22
49
  "license": "Apache-2.0",
@@ -190,20 +190,3 @@ export function resolveJvmPackagePrefix(specifier, index) {
190
190
  }
191
191
  return null;
192
192
  }
193
-
194
- /**
195
- * The owning project of a resolved prefix, when exactly one project claims
196
- * the matched name.
197
- *
198
- * @param {{ owners: { project: string }[], prefix: string }} resolution
199
- * @returns {{ target: string, ambiguous?: undefined } |
200
- * { target: null, ambiguous: true, projects: string[] }} A single
201
- * target when the owners agree; otherwise every distinct claimant, for the
202
- * caller's failure record.
203
- */
204
- export function projectOfResolution(resolution) {
205
- const projects = [...new Set(resolution.owners.map((owner) => owner.project))];
206
- return projects.length === 1
207
- ? { target: projects[0] }
208
- : { target: null, ambiguous: true, projects };
209
- }
@@ -67,8 +67,12 @@ export function resolveWithinWorkspace(baseDir, relative) {
67
67
  return segments.join("/");
68
68
  }
69
69
 
70
- /** A pattern carrying any of these is a glob; anything else is a literal. */
71
- const GLOB_METACHARACTERS = /[*?[{\\]/;
70
+ /**
71
+ * A pattern carrying any of these is routed to the glob matcher; everything
72
+ * else compares equal. `(` rides for extglob — `+(x).txt` matches `x.txt`
73
+ * (measured on Node v24) — the character #671 proved the table was missing.
74
+ */
75
+ const GLOB_METACHARACTERS = /[*?[{(\\]/;
72
76
 
73
77
  /**
74
78
  * Does a file's basename match any of a manifest-name pattern list — the
@@ -82,9 +86,14 @@ const GLOB_METACHARACTERS = /[*?[{\\]/;
82
86
  * the equality scan it replaced was nanoseconds, because each call compiles
83
87
  * its pattern again. The matcher is injected so `../../providers/native/
84
88
  * model.mjs`'s validated one and raw `path.posix.matchesGlob` ride the same
85
- * fast path without this module reaching for either; semantics are
86
- * unchanged, because a metacharacter-free pattern answers identically
87
- * either way and every other pattern still reaches the glob.
89
+ * fast path without this module reaching for either. The rule the table has
90
+ * to hold: every pattern carrying a character that can alter the glob's
91
+ * answer is routed to it, and everything else compares equal a pattern
92
+ * the table does not carry is literal outside constructs the table already
93
+ * routes, so the two answers agree, and routing a literal anyway (an
94
+ * unbalanced `a(b`) costs only the matcher call. #671: `(` was missing
95
+ * from the table, so an extglob manifest pattern like `+(x).csproj` was
96
+ * compared by equality and missed every file the pattern named.
88
97
  *
89
98
  * @param {string} base The basename under test.
90
99
  * @param {readonly string[]} patterns
@@ -129,7 +129,7 @@ function directEdges(graph) {
129
129
  * three copies of the same filter is how one of them drifts.
130
130
  *
131
131
  * `../../src/rules/reachability.mjs`'s `buildReachability` itself stays
132
- * type-agnostic on purpose: `../../src/commands/edge-constraints.mjs`'s
132
+ * type-agnostic on purpose: `../rules/edge-constraints.mjs`'s
133
133
  * `declaredEdgeViolationsForCheck` and `../../src/rules/index.mjs`'s
134
134
  * `evaluate()` both deliberately hand it the UNFILTERED graph, because
135
135
  * `depConstraints`' `notDependOnLibsWithTags` is a different, tag-based
@@ -43,6 +43,7 @@ import { resolve } from "node:path";
43
43
  import { containmentViolation } from "../containment.mjs";
44
44
 
45
45
  import { isValidSelector, splitSelector } from "./selectors.mjs";
46
+ import { describe, isPlainObject } from "../values.mjs";
46
47
  import { GOVERNANCE_ROW_KEYS, rowSchemaViolations } from "../governance/row-schema.mjs";
47
48
 
48
49
  /** The base name of the root file this module reads. */
@@ -121,17 +122,6 @@ const ROW_KEYS = Object.freeze(["from", "to", "reason", "optional", "decisionRef
121
122
  /** A boundary `name`, matched exactly by the loaders — names can never contain `:`, so a name can never collide with a `name:`-prefixed selector. */
122
123
  const NAME_PATTERN = /^[a-zA-Z0-9_-]+$/u;
123
124
 
124
- /** A value's type, for an error message that shows what was actually there. */
125
- function describe(value) {
126
- if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
127
- if (value === null) return "null";
128
- return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
129
- }
130
-
131
- /** @type {(value: unknown) => value is Record<string, unknown>} */
132
- const isPlainObject = (value) =>
133
- value !== null && typeof value === "object" && !Array.isArray(value);
134
-
135
125
  /** `key` on `obj` that is not one of `allowed` — for the reject-by-name rule. */
136
126
  function unknownKeys(obj, allowed) {
137
127
  return Object.keys(obj).filter((key) => !allowed.includes(key));
@@ -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));
@@ -23,11 +23,15 @@ import { stripTrailingSlashes } from "../path-util.mjs";
23
23
  import { suppressionCovers } from "../config.mjs";
24
24
  import { referenceTime } from "../governance/clock.mjs";
25
25
  import { suppressionFate } from "../governance/waiver.mjs";
26
- import { resolveCommandContext, unownedGapWithoutRunConfiguration } from "./context.mjs";
26
+ import {
27
+ resolveCommandContext,
28
+ unownedGapWithoutRunConfiguration,
29
+ untrackedOwnedFiles,
30
+ } from "./context.mjs";
27
31
  import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
28
32
  import { readAdrContext } from "./adr.mjs";
29
33
  import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
30
- import { declaredEdgeViolationsForCheck } from "./edge-constraints.mjs";
34
+ import { declaredEdgeViolationsForCheck } from "../rules/edge-constraints.mjs";
31
35
  import { customRulesForCheck, declaresCustomRules } from "./custom-rules.mjs";
32
36
  import { driftForCheck } from "./drift.mjs";
33
37
  import { fitnessForCheck } from "./fitness.mjs";
@@ -44,7 +48,7 @@ import { evaluateRun, exemptResolvedFile } from "../rules/index.mjs";
44
48
  import { orphanedNotDependOnTags, unmatchedConstraintRows } from "../rules/tags.mjs";
45
49
  import { judgeTsconfigPaths } from "../tsconfig-paths.mjs";
46
50
  import { verdictFor } from "../verdict.mjs";
47
- import { listTrackedFiles } from "../workspace.mjs";
51
+ import { listTrackedFiles, listUntrackedFiles } from "../workspace.mjs";
48
52
 
49
53
  /**
50
54
  * A total order over violations, so a report's byte sequence is an invariant
@@ -184,10 +188,21 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
184
188
  * readers: a test drives the real analysis, the real rules and the real
185
189
  * report over a fixture tree, and pins the exact `file:line:column` a
186
190
  * developer would act on, without an Nx installation or a git repository.
191
+ * `listUntracked` is the third git seam, the one the tracked universe is
192
+ * audited against (#675): it answers with the worktree files the universe
193
+ * left out (`../workspace.mjs`'s `listUntrackedFiles`). It is injected
194
+ * separately rather than derived from `listFiles` because the two are only
195
+ * paired when BOTH are git's — a caller that injected a tracked universe has
196
+ * already decided what the whole tree is, and asking git what else exists
197
+ * would answer a different tree than the one `listFiles` named. That is why
198
+ * the default below resolves the real listing only when `listFiles` is also
199
+ * the real one, and treats an injected universe as the whole story: its
200
+ * untracked complement is empty by construction, not by claim.
187
201
  *
188
202
  * @param {{format: string, config: string|null, paths: string[],
189
203
  * evidenceOut?: string|null}} options
190
- * @param {{cwd: string, readGraph?: Function, listFiles?: Function}} context
204
+ * @param {{cwd: string, readGraph?: Function, listFiles?: Function,
205
+ * listUntracked?: Function}} context
191
206
  * @returns {Promise<{report: string, violations: number, declaredEdgeFindings: number,
192
207
  * goWorkDrift: number, tsconfigPathsDead: number, intentFindings: number,
193
208
  * intentUnresolved: number, intentUnresolvedDecisionRefs: number, fitnessFail: number,
@@ -195,7 +210,10 @@ function declaredEdgeManifest({ provider, graph }, sourceProject) {
195
210
  * customRuleEvidence: {rule: string, bytes: Uint8Array}[], customRulesDeclared: boolean,
196
211
  * analyzed: number, unchecked: number, blindSpots: number, waived?: number}>}
197
212
  */
198
- export async function check(options, { cwd, readGraph, listFiles = listTrackedFiles }) {
213
+ export async function check(
214
+ options,
215
+ { cwd, readGraph, listFiles = listTrackedFiles, listUntracked },
216
+ ) {
199
217
  const commandContext = resolveCommandContext(
200
218
  { cwd, paths: options.paths },
201
219
  { readGraph, listFiles },
@@ -791,6 +809,45 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
791
809
  ...(unconstrainedImportNote === null ? [] : [unconstrainedImportNote]),
792
810
  ];
793
811
 
812
+ // The tracked-universe boundary, audited (#675): every file a project owns,
813
+ // that an analyzer could judge, and that this run never read because git
814
+ // does not track it yet. The universe above is `listFiles(root)` —
815
+ // `git ls-files` verbatim — so a file that was never `git add`-ed never
816
+ // entered it, and before this audit a run over such a tree printed the same
817
+ // clean verdict as a run over the whole tree, byte for byte, with nothing
818
+ // anywhere naming what the universe had left out. `untrackedOwnedFiles`
819
+ // (`./context.mjs`) narrows the complement to the files whose absence can
820
+ // change what the verdict claims; the ownership and language tests are the
821
+ // two this module already runs over the tracked list, reused rather than
822
+ // re-derived.
823
+ //
824
+ // The verdict posture is the `unowned-files` bargain, and the completeness
825
+ // law decides it: the three no-verdict axes (`verdictFor`'s `unchecked`,
826
+ // `blindSpots`, `analyzed`) are all about files the universe CONTAINED —
827
+ // a whole-file failure, an unjudged site, a run that judged nothing —
828
+ // and the precedent for withholding (zero analysis, #619/#620/#634) is a
829
+ // verdict that was VACUOUS, not one that was scoped. This run judged every
830
+ // file its universe held, so the verdict over that universe stands — exit
831
+ // code and `coverage.complete` unchanged, exactly as the `coverageGaps`
832
+ // channel's contract states (`../../../../docs/reference/json-output.md`:
833
+ // "no kind changes `complete`, `status`, or the exit code") — but the clean
834
+ // verdict over a partial universe is now distinguishable from a clean
835
+ // verdict over the whole one, in both faces, with the full list a parser
836
+ // can act on. What is forbidden here is not exit 0; it is silence.
837
+ //
838
+ // Workspace-wide on purpose, the same posture `unownedGap` holds: a
839
+ // `check <path>` run must not be able to hide an unread file elsewhere in
840
+ // the tree by naming a path that excludes it. And resolved through the
841
+ // `listUntracked` seam's conditional default — an injected universe is the
842
+ // whole story (see this function's doc), so the audit asks git only when
843
+ // git also built the universe.
844
+ const untrackedListing =
845
+ listUntracked ?? (listFiles === listTrackedFiles ? listUntrackedFiles : null);
846
+ const untrackedOwned = untrackedOwnedFiles({
847
+ untracked: untrackedListing === null ? [] : untrackedListing(root),
848
+ projects: commandContext.workspace.projects,
849
+ });
850
+
794
851
  // A polyglot coverage gap: the Nx graph carries no polyglot edges because
795
852
  // the plugin is not registered, but polyglot manifests exist under project
796
853
  // roots. The checker still judged every import it found — this is not a
@@ -864,6 +921,13 @@ export async function check(options, { cwd, readGraph, listFiles = listTrackedFi
864
921
  ...(unsupportedLanguageFiles.length > 0
865
922
  ? [{ kind: "unsupported-language", files: [...unsupportedLanguageFiles].sort() }]
866
923
  : []),
924
+ // The universe audit above (#675): project-owned, analyzable files the
925
+ // worktree holds that this run never read, because the universe is the
926
+ // tracked set. Contributed only when the list is non-empty, so a tree
927
+ // with nothing beyond its index reports exactly the bytes it reported
928
+ // before; sorted already, by the one function that built it, so the row's
929
+ // bytes cannot vary with git's worktree-traversal order (E-F10).
930
+ ...(untrackedOwned.length > 0 ? [{ kind: "untracked-files", files: untrackedOwned }] : []),
867
931
  ];
868
932
 
869
933
  // One verdict computation for both faces: the JSON envelope spreads it and
@@ -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
  // ---------------------------------------------------------------------------
@@ -38,7 +38,7 @@ import {
38
38
  unresolvableLiteralCount,
39
39
  } from "../analysis/source-util.mjs";
40
40
  import { UsageError } from "../errors.mjs";
41
- import { judgeEdge } from "./edge-constraints.mjs";
41
+ import { judgeEdge } from "../rules/edge-constraints.mjs";
42
42
  import { findConstraintsFor } from "../rules/tags.mjs";
43
43
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
44
44
  import { formatContextReport } from "../report/context-text.mjs";
@@ -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
 
@@ -41,7 +41,7 @@
41
41
  import { readFileSync } from "node:fs";
42
42
 
43
43
  import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
44
- import { computeRuleImpact } from "./edge-constraints.mjs";
44
+ import { computeRuleImpact } from "../rules/edge-constraints.mjs";
45
45
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
46
46
  import { SCHEMA_VERSION } from "../report/json.mjs";
47
47
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
@@ -14,9 +14,13 @@
14
14
  * - **`--propose`** — computes the candidate architecture over those same
15
15
  * observations (`src/governance/discovery-proposal.mjs`'s
16
16
  * `evaluateDiscovery`) and emits it with `proposed: true` and
17
- * `notAuthoritative: true` on every candidate. It never writes
18
- * `architecture-intent.json`, never mutates the workspace, and never hands
19
- * a candidate the authority of a decision.
17
+ * `notAuthoritative: true` on every candidate. This layer performs no
18
+ * write: the evaluator is pure, and the one route from a proposal to
19
+ * `architecture-intent.json` is the CLI's own `--write-intent <file>` flag
20
+ * (`cli.mjs`'s `runDiscover`, serialized through `proposalToIntent`
21
+ * below) — explicit, named by the operator, and refused when a file
22
+ * already stands at the target. The command never hands a candidate the
23
+ * authority of a decision.
20
24
  *
21
25
  * ## The empty-result invariant
22
26
  *
@@ -25,8 +25,12 @@ import {
25
25
  computeDomainCoverage,
26
26
  REQUIRED_DOMAINS,
27
27
  } from "./completeness.mjs";
28
- import { computeImpact } from "./impact.mjs";
29
- import { computeImpactConstraints } from "./edge-constraints.mjs";
28
+ // The reachability walk comes from `./impact-reachability.mjs`, not from
29
+ // `./impact.mjs`: that module drives `./impact-statement.mjs`, which drives
30
+ // this one, so importing the walk from there closed the engine's only import
31
+ // cycle (#644).
32
+ import { computeImpact } from "./impact-reachability.mjs";
33
+ import { computeImpactConstraints } from "../rules/edge-constraints.mjs";
30
34
  // ---------------------------------------------------------------------------
31
35
  // Decision resolution
32
36
  // ---------------------------------------------------------------------------