@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
@@ -61,6 +61,7 @@
61
61
  import { readFileSync } from "node:fs";
62
62
 
63
63
  import { policyFrom } from "../config.mjs";
64
+ import { describe, isPlainObject } from "../values.mjs";
64
65
 
65
66
  /**
66
67
  * The top-level keys a profiles file may carry. `version` is checked AFTER
@@ -109,16 +110,6 @@ const BLOCK_KEYS = ["depConstraints", "moduleBoundaryOptions", "boundarySuppress
109
110
  */
110
111
  const NAME_PATTERN = /^[a-zA-Z0-9_-]+$/u;
111
112
 
112
- /** @type {(value: unknown) => value is Record<string, unknown>} */
113
- const isPlainObject = (value) =>
114
- typeof value === "object" && value !== null && !Array.isArray(value);
115
-
116
- function describe(value) {
117
- if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
118
- if (value === null) return "null";
119
- return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
120
- }
121
-
122
113
  /** A profile's declared block, kept ONLY for this command's own data. */
123
114
  export function listNames(registry) {
124
115
  return registry.profiles.map((profile) => profile.name);
@@ -304,23 +295,13 @@ export function resolveProfile(profiles, name, seen = new Set()) {
304
295
  * @param {string} path Absolute path of the profiles file.
305
296
  * @param {{readFile?: (path: string) => string|null}} [io] Injectable read,
306
297
  * the same seam `../../options.mjs`'s readers take; answers `null` when the
307
- * file is not there.
298
+ * file is not there. Defaults to `defaultProfileIo.readFile` — the sync
299
+ * `node:fs` read this module makes, the only place it touches the disk.
308
300
  * @returns {{profiles: object[]}}
309
301
  * @throws {Error} on a missing/unreadable/unparseable file, or on any
310
302
  * profile-registry or reference-graph defect.
311
303
  */
312
- export function loadProfileRegistry(
313
- path,
314
- {
315
- readFile = (p) => {
316
- try {
317
- return readFileSync(p, "utf8");
318
- } catch {
319
- return null;
320
- }
321
- },
322
- } = {},
323
- ) {
304
+ export function loadProfileRegistry(path, { readFile = defaultProfileIo.readFile } = {}) {
324
305
  const text = readFile(path);
325
306
  if (text === null) {
326
307
  throw new Error(`archkeep: cannot read profiles file ${path}`);
@@ -363,3 +344,19 @@ export function profilePolicy(registryPath, profileName, sourceLabel, io = {}) {
363
344
  const effective = resolveProfile(registry.profiles, profileName);
364
345
  return policyFrom(effective, `${sourceLabel} (profile "${profileName}")`);
365
346
  }
347
+
348
+ /**
349
+ * The default io: the sync `node:fs` read `loadProfileRegistry` makes, wrapped
350
+ * in the null-on-missing contract `../../options.mjs`'s readers share. This is
351
+ * the only place in this module the filesystem is named directly — a test
352
+ * injects a `readFile` and the module body never touches the disk on its own.
353
+ */
354
+ const defaultProfileIo = Object.freeze({
355
+ readFile: (p) => {
356
+ try {
357
+ return readFileSync(p, "utf8");
358
+ } catch {
359
+ return null;
360
+ }
361
+ },
362
+ });
@@ -49,6 +49,7 @@
49
49
 
50
50
  import { ADR_STATUSES } from "./adr-registry.mjs";
51
51
  import { clockViolations } from "./clock.mjs";
52
+ import { describe, isPlainObject } from "../values.mjs";
52
53
 
53
54
  /** The only keys a validated `origin` may carry. */
54
55
  export const ORIGIN_KEYS = Object.freeze(["by", "tool", "on"]);
@@ -63,17 +64,6 @@ export const ORIGIN_KEYS = Object.freeze(["by", "tool", "on"]);
63
64
  * `recordOrigin` produced it through the shared clock.
64
65
  */
65
66
 
66
- /** @type {(value: unknown) => value is Record<string, unknown>} */
67
- const isPlainObject = (value) =>
68
- value !== null && typeof value === "object" && !Array.isArray(value);
69
-
70
- /** A value's type, for an error message that shows what was actually there. */
71
- function describe(value) {
72
- if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
73
- if (value === null) return "null";
74
- return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
75
- }
76
-
77
67
  /**
78
68
  * Everything wrong with a raw `origin` record at READ time, as messages; empty
79
69
  * when it is well-formed. Shape only — an `on` committed in a declaration file
@@ -76,9 +76,6 @@ export const SEVERITY_ORDER = Object.freeze({
76
76
  unknown: Infinity,
77
77
  });
78
78
 
79
- /** A scored element's state, in the vocabulary the text and JSON reports share. */
80
- export const ELEMENT_STATES = Object.freeze(["match", "absent", "unexpected", "unknown"]);
81
-
82
79
  /**
83
80
  * A scored element.
84
81
  *
@@ -57,9 +57,7 @@
57
57
  */
58
58
 
59
59
  import { originViolations } from "./provenance-record.mjs";
60
-
61
- /** The shape of any `origin.on` producer. Re-exported for a row owner's own docs. */
62
- export { clockViolations as clockValidation } from "./clock.mjs";
60
+ import { describe, isPlainObject } from "../values.mjs";
63
61
 
64
62
  /** The four governance keys a row may carry, in the order reports list them. */
65
63
  export const GOVERNANCE_ROW_KEYS = Object.freeze([
@@ -85,17 +83,6 @@ export const GOVERNANCE_ROW_KEYS = Object.freeze([
85
83
  * @property {string[]} [fitnessBindings] Fitness ids this row is bound to.
86
84
  */
87
85
 
88
- /** @type {(value: unknown) => value is Record<string, unknown>} */
89
- const isPlainObject = (value) =>
90
- value !== null && typeof value === "object" && !Array.isArray(value);
91
-
92
- /** A value's type, for an error message that shows what was actually there. */
93
- function describe(value) {
94
- if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
95
- if (value === null) return "null";
96
- return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
97
- }
98
-
99
86
  /**
100
87
  * Everything wrong with a row's `rationale`, `decisionRef`, or
101
88
  * `fitnessBindings` — the three string-shaped governance keys. `origin` has
@@ -34,10 +34,13 @@
34
34
  * claim the vocabulary makes and the hardest to disprove, so every other
35
35
  * state exists to refuse it.
36
36
  *
37
- * The enforcer that makes the invariants executable lives in
38
- * `../report/evidence.mjs` (`buildDecision`)this module owns the closed
39
- * vocabulary and its relation to the envelope's existing statuses, and the
40
- * report module owns the runtime check a command's counts go through.
37
+ * The enforcer that makes the invariants executable is `buildDecision`, in
38
+ * this file, beside the vocabulary it enforces a vocabulary and the check
39
+ * that a verdict's evidence agrees with it are one subject, and splitting
40
+ * them across the report boundary had made the core verdict module
41
+ * (`../verdict.mjs`) depend on the presentation layer. `../report/evidence.mjs`
42
+ * re-exports `buildDecision` so the render-side callers keep their import
43
+ * path — a path, never a second implementation.
41
44
  */
42
45
 
43
46
  /** The four canonical verdict values. */
@@ -125,3 +128,164 @@ export function fitnessVerdict({ verdict, name, evidence, message, rows, notAppl
125
128
  ...(notApplicableReason === undefined ? {} : { notApplicableReason }),
126
129
  };
127
130
  }
131
+
132
+ /**
133
+ * The decision builder: turns a command's verdict counts into the `decision`
134
+ * the envelope optionally carries, enforcing the five invariants the module
135
+ * header states in code rather than leaving them to a docs page a later
136
+ * command author might not read.
137
+ *
138
+ * This function decides nothing about whether a finding IS one — the command
139
+ * that built the envelope owns that. What it decides is whether the verdict
140
+ * and its evidence AGREE, and it throws when they do not, the same posture
141
+ * `../report/json.mjs`'s `jsonEnvelope` takes for the three consistency rules
142
+ * it enforces: a mismatch here is a bug in the command, not a fact about the
143
+ * workspace.
144
+ *
145
+ * The shape it produces:
146
+ *
147
+ * {
148
+ * verdict: "pass" | "fail" | "unknown" | "not_applicable",
149
+ * reason?: string, // always present for unknown
150
+ * notApplicableReason?: string, // always present for not_applicable
151
+ * sampleTime?: string // opt-in, never on a deterministic envelope
152
+ * }
153
+ *
154
+ * A caller may pass `reason` for `unknown` — it names WHICH could-not-look
155
+ * condition fired (coverage incomplete, an unresolved intent boundary, a
156
+ * thrown analysis). Without it, `buildDecision` states the generic one. The
157
+ * reason field itself is always present on an `unknown` decision (I3).
158
+ *
159
+ * ## Determinism is the default
160
+ *
161
+ * The envelope this decision rides on is byte-deterministic
162
+ * (`docs/reference/json-output.md`: no timestamp, no random identifier). So
163
+ * `sampleTime` is OPT-IN by construction: a command passes it explicitly when
164
+ * it is an age/count capability (waivers, debt, health — the features
165
+ * `./clock.mjs` serves), and a command whose verdict must stay reproducible
166
+ * over an unchanged tree emits a decision with no time at all. That is how
167
+ * the determinism↔time tension is resolved — the clock is injectable (a test
168
+ * drives the same code with a fixed time), never asserted from the wall
169
+ * clock.
170
+ *
171
+ * ## One refusal per invariant
172
+ *
173
+ * I1 refuses a `pass` over incomplete coverage — the same refusal
174
+ * `jsonEnvelope` makes for `status: "ok"` over incomplete coverage, at the
175
+ * verdict layer — and a `pass` carrying findings. I2 refuses a `fail` that
176
+ * names no finding. I3 keeps a reason on every `unknown`, defaulting the
177
+ * generic one when the caller supplies none. I4 refuses a `not_applicable`
178
+ * without its `notApplicableReason`. I5 is I1's first check plus every caller
179
+ * choosing `unknown` wherever the run did not reach a verdict.
180
+ *
181
+ * `not_applicable` has no envelope status, so `buildDecision` reaches it only
182
+ * through an explicit `verdict` — the route a Fitness or Waiver capability
183
+ * takes. Engine behavior today never passes it: `jsonEnvelope` refuses a
184
+ * `decision.verdict` that contradicts the envelope's `status`, and no status
185
+ * maps to `not_applicable`, so the state is locked out of every envelope this
186
+ * release builds.
187
+ *
188
+ * @param {{
189
+ * verdict?: "pass"|"fail"|"unknown"|"not_applicable",
190
+ * status?: "ok"|"findings"|"no-verdict",
191
+ * coverageComplete: boolean,
192
+ * findings: number,
193
+ * reason?: string|null,
194
+ * notApplicableReason?: string|null,
195
+ * sampleTime?: string
196
+ * }} run
197
+ * @returns {{verdict: string, reason?: string, notApplicableReason?: string,
198
+ * sampleTime?: string}}
199
+ * @throws {Error} on any invariant violation (I1–I4).
200
+ */
201
+ export function buildDecision(run) {
202
+ if (run.verdict === undefined && run.status === undefined) {
203
+ // No status, no explicit verdict — a builder called with neither is a
204
+ // programming error, not a fact about the workspace.
205
+ throw new Error("archkeep: buildDecision needs either a status or an explicit verdict");
206
+ }
207
+ const verdict = run.verdict ?? verdictForStatus(run.status);
208
+ if (
209
+ run.verdict !== undefined &&
210
+ run.status !== undefined &&
211
+ run.verdict !== verdictForStatus(run.status)
212
+ ) {
213
+ throw new Error(
214
+ `archkeep: refusing to build a decision where verdict "${run.verdict}" contradicts status ` +
215
+ `"${run.status}" — status implies ${verdictForStatus(run.status)}, and a decision that ` +
216
+ `disagrees with its own status would make one of the two a lie. ` +
217
+ `This is a bug in the command that built the decision.`,
218
+ );
219
+ }
220
+
221
+ // The `findings` count is the cardinal evidence number — a non-negative
222
+ // integer that the I1–I5 invariants all rely on. A missing, non-numeric, or
223
+ // negative value would silently falsify every comparison (`undefined > 0` is
224
+ // `false`), producing a clean verdict over a run whose counts were never set
225
+ // or are logically impossible — the exact silent direction this module exists
226
+ // to refuse.
227
+ if (typeof run.findings !== "number" || !Number.isFinite(run.findings) || run.findings < 0) {
228
+ throw new Error(
229
+ `archkeep: refusing to build a decision where findings is ${JSON.stringify(run.findings)} ` +
230
+ `— findings must be a non-negative number, or the verdict invariants cannot be enforced. ` +
231
+ `This is a bug in the command that built the decision.`,
232
+ );
233
+ }
234
+ if (verdict === "pass") {
235
+ if (run.coverageComplete !== true) {
236
+ throw new Error(
237
+ `archkeep: refusing to emit a "pass" decision over incomplete coverage ` +
238
+ `(coverage.complete: ${run.coverageComplete}) — a run that could not fully read the ` +
239
+ `tree can never pass. This is a bug in the command that built the decision.`,
240
+ );
241
+ }
242
+ if (run.findings > 0) {
243
+ throw new Error(
244
+ `archkeep: refusing to emit a "pass" decision with ${run.findings} finding(s) — ` +
245
+ `"pass" and "fail" cannot both be true of the same run. This is a bug in the command.`,
246
+ );
247
+ }
248
+ return withSampleTime({ verdict }, run.sampleTime);
249
+ }
250
+
251
+ if (verdict === "fail") {
252
+ if (run.findings < 1) {
253
+ throw new Error(
254
+ `archkeep: refusing to emit a "fail" decision with no findings — a failing verdict ` +
255
+ `must name what failed. This is a bug in the command that built the decision.`,
256
+ );
257
+ }
258
+ return withSampleTime({ verdict }, run.sampleTime);
259
+ }
260
+
261
+ if (verdict === "unknown") {
262
+ const reason =
263
+ run.reason ??
264
+ (run.coverageComplete === true ? "no verdict was reached" : "coverage was incomplete");
265
+ return withSampleTime({ verdict, reason }, run.sampleTime);
266
+ }
267
+
268
+ // verdict === "not_applicable" (I4).
269
+ if (!run.notApplicableReason) {
270
+ throw new Error(
271
+ `archkeep: refusing to emit a "not_applicable" decision without notApplicableReason — ` +
272
+ `"did not apply" and "did not run" must never be indistinguishable. ` +
273
+ `This is a bug in the command that built the decision.`,
274
+ );
275
+ }
276
+ return withSampleTime({ verdict, notApplicableReason: run.notApplicableReason }, run.sampleTime);
277
+ }
278
+
279
+ /**
280
+ * Adds `sampleTime` to the decision only when the caller opted into time —
281
+ * the determinism rule in `buildDecision`'s header. Absent `sampleTime`, the
282
+ * decision object carries exactly the invariant-bearing fields and nothing
283
+ * more.
284
+ *
285
+ * @param {object} decision
286
+ * @param {string|undefined} sampleTime
287
+ * @returns {object}
288
+ */
289
+ function withSampleTime(decision, sampleTime) {
290
+ return sampleTime === undefined ? decision : { ...decision, sampleTime };
291
+ }
@@ -20,13 +20,13 @@
20
20
  "type": "architecture-test",
21
21
  "path": "src/conformance/boundary.test.mjs",
22
22
  "assertion": "SHIPPED_PACKAGES allow-list prevents unapproved dependencies; specifiersIn() walk catches all imports including createRequire; no provider import found in core layers",
23
- "sha256": "12d4e179f88de2622a41e56f42b2bab1c88a677ec2232430752138c57ba5fb18"
23
+ "sha256": "e4e24ed9f36e31126249c7d079b3656d17ac78ee4f12033321467ffa6666f033"
24
24
  },
25
25
  {
26
26
  "type": "source-evidence",
27
27
  "path": "src/commands/context.mjs",
28
28
  "assertion": "Only commands/context.mjs imports providers — the designated orchestration layer",
29
- "sha256": "7f566c06afbb175337529bd9bdc42465f8849498b932abf6a77fe0290a0cd13c"
29
+ "sha256": "b505e3a7476c64af4b2daeb106e2f9cb05be82e314604229d508dda04b629a98"
30
30
  }
31
31
  ],
32
32
  "status": "proven"
@@ -46,7 +46,7 @@
46
46
  "type": "source-evidence",
47
47
  "path": "src/lsp/diagnose.mjs",
48
48
  "assertion": "analyzed:false on every non-verdict path; empty diagnostic list only from two named places — plus a document whose only positioned failure is an external disclosure, which was judged and so is not published (#603)",
49
- "sha256": "72196def14055475ccb23284a3c5d34167535a506985b26fb95af097545cc740"
49
+ "sha256": "0edeb309a9bba49f4e82d8bf82982363ae9a20bfc04421b208961854b84acd41"
50
50
  },
51
51
  {
52
52
  "type": "source-evidence",
@@ -66,13 +66,13 @@
66
66
  "type": "behavioral-test",
67
67
  "path": "src/intent/intent.test.mjs",
68
68
  "assertion": "analysis output conforms to the frozen contract schema (no extra verdict/policy fields); analysis output is invariant under project tag changes (three workspaces differing only in project tag fields compared byte-for-byte)",
69
- "sha256": "6ed86cb5e7af94b56fa2af3d2da5d45d58937fbbf316105da0224d0060e88582"
69
+ "sha256": "adb25388f17c97577e97e6b65bd3dacc5811710fe3bb60a409a51ff4abcd945e"
70
70
  },
71
71
  {
72
72
  "type": "architecture-test",
73
73
  "path": "src/intent/intent.test.mjs",
74
74
  "assertion": "the gate walks every production analysis module and fails when judging vocabulary (judge/forbid/permit/allow/ban) appears in code",
75
- "sha256": "6ed86cb5e7af94b56fa2af3d2da5d45d58937fbbf316105da0224d0060e88582"
75
+ "sha256": "adb25388f17c97577e97e6b65bd3dacc5811710fe3bb60a409a51ff4abcd945e"
76
76
  },
77
77
  {
78
78
  "type": "source-evidence",
@@ -104,13 +104,13 @@
104
104
  "type": "source-evidence",
105
105
  "path": "src/commands/graph.mjs",
106
106
  "assertion": "Plain string comparison, never localeCompare; INTERNAL_DATA_FIELDS stripped; SCHEMA_VERSION = 2",
107
- "sha256": "25ece527ff5ea847f5863314a12c50c95a80f995d36d3aeb756ca022dd72f283"
107
+ "sha256": "72a28c1edcbcb209f74bd10a1a3691e25aaa848bac4bd54aceeacf8d415c8f13"
108
108
  },
109
109
  {
110
110
  "type": "source-evidence",
111
111
  "path": "src/commands/graph.mjs",
112
112
  "assertion": "computePolicyFingerprint produces SHA-256 of canonicalized policy",
113
- "sha256": "25ece527ff5ea847f5863314a12c50c95a80f995d36d3aeb756ca022dd72f283"
113
+ "sha256": "72a28c1edcbcb209f74bd10a1a3691e25aaa848bac4bd54aceeacf8d415c8f13"
114
114
  }
115
115
  ],
116
116
  "status": "proven"
@@ -130,7 +130,7 @@
130
130
  "type": "source-evidence",
131
131
  "path": "src/commands/diff.mjs",
132
132
  "assertion": "parseBaseline validates schemaVersion; refuses unknown versions",
133
- "sha256": "6190013ec55eeb7f1a9e4cdd910c7be73bfb1fa87051bdeace7c9581c6a18426"
133
+ "sha256": "85e6400006a7e93c66e64547162c854320fa69d833a11cec42773703f522a163"
134
134
  }
135
135
  ],
136
136
  "status": "proven"
@@ -150,7 +150,7 @@
150
150
  "type": "source-evidence",
151
151
  "path": "src/commands/diff.mjs",
152
152
  "assertion": "computeDiff returns structural diff; policyMismatch detected via fingerprint; computeRuleImpact for depConstraints-only context",
153
- "sha256": "6190013ec55eeb7f1a9e4cdd910c7be73bfb1fa87051bdeace7c9581c6a18426"
153
+ "sha256": "85e6400006a7e93c66e64547162c854320fa69d833a11cec42773703f522a163"
154
154
  }
155
155
  ],
156
156
  "status": "proven"
@@ -230,7 +230,7 @@
230
230
  "type": "behavioral-test",
231
231
  "path": "src/intent/intent.test.mjs",
232
232
  "assertion": "depConstraints verdicts from judgeEdge agree with evaluate in both directions (violating edge found by both, legal edge reported by neither); explain includes the same violations as evaluate at a given site",
233
- "sha256": "6ed86cb5e7af94b56fa2af3d2da5d45d58937fbbf316105da0224d0060e88582"
233
+ "sha256": "adb25388f17c97577e97e6b65bd3dacc5811710fe3bb60a409a51ff4abcd945e"
234
234
  },
235
235
  {
236
236
  "type": "behavioral-test",
@@ -248,19 +248,19 @@
248
248
  "type": "source-evidence",
249
249
  "path": "src/commands/context-command.mjs",
250
250
  "assertion": "coverage.notes warns that per-edge violations cover only depConstraints (3 of 15 violation types)",
251
- "sha256": "72526cdaf038da4d9a50b33ab3cb30828713e62bbb891f4e2e51e008a403338e"
251
+ "sha256": "f906671ff4a5720d80ea5a3dab2779f1cfd3648fbc1e6a1f3a6d0e6efef21feb"
252
252
  },
253
253
  {
254
254
  "type": "source-evidence",
255
255
  "path": "src/commands/impact.mjs",
256
256
  "assertion": "coverage.notes warns that per-edge violations cover only depConstraints (3 of 15 violation types)",
257
- "sha256": "38cff166a8944860c51a73316447ce789898751489d783e75fd6befbfd30be3c"
257
+ "sha256": "152a53a0010d8cbd51d11e3ce6268fa50d1e2bcacab3d7bcb38dbd53fc120bbf"
258
258
  },
259
259
  {
260
260
  "type": "source-evidence",
261
261
  "path": "src/commands/diff.mjs",
262
262
  "assertion": "coverage.notes warns when ruleImpact is computed (depConstraints only, 3 of 15)",
263
- "sha256": "6190013ec55eeb7f1a9e4cdd910c7be73bfb1fa87051bdeace7c9581c6a18426"
263
+ "sha256": "85e6400006a7e93c66e64547162c854320fa69d833a11cec42773703f522a163"
264
264
  },
265
265
  {
266
266
  "type": "documentation",
@@ -286,7 +286,7 @@
286
286
  "type": "source-evidence",
287
287
  "path": "src/commands/graph.mjs",
288
288
  "assertion": "Plain string comparison throughout; never localeCompare",
289
- "sha256": "25ece527ff5ea847f5863314a12c50c95a80f995d36d3aeb756ca022dd72f283"
289
+ "sha256": "72a28c1edcbcb209f74bd10a1a3691e25aaa848bac4bd54aceeacf8d415c8f13"
290
290
  }
291
291
  ],
292
292
  "status": "proven"
@@ -312,13 +312,13 @@
312
312
  "type": "source-evidence",
313
313
  "path": "src/providers/moon.mjs",
314
314
  "assertion": "inferWorkspaceLayout returns null for partial layouts — same all-or-nothing contract as Nx and Native",
315
- "sha256": "232dd8e2cbe70c9149ba3a75a03dcebe0db355894c280964ebbf6a5a536b5567"
315
+ "sha256": "2b874da56bda5bc6cfcfb4a984556ace0eafe3a6a98d964c78204bd96cc30e48"
316
316
  },
317
317
  {
318
318
  "type": "behavioral-test",
319
319
  "path": "src/providers/moon.test.mjs",
320
320
  "assertion": "Partial layout (apps-only, libs-only) returns undefined workspaceLayout",
321
- "sha256": "084a0061b3236e680274d837fb2f12d27dd4b5a379040157f7afe2be5088e8ac"
321
+ "sha256": "906fcd38b82af875aecca2910231ec5d5bb551e2a6e7d452f35a0670326c8756"
322
322
  }
323
323
  ],
324
324
  "status": "proven"
@@ -43,7 +43,7 @@
43
43
  */
44
44
  import { analyzeFile } from "../analysis/analyze.mjs";
45
45
  import { isExternalSiteFailure, projectOwning } from "../analysis/source-util.mjs";
46
- import { declaredEdgeViolationsForCheck } from "../commands/edge-constraints.mjs";
46
+ import { declaredEdgeViolationsForCheck } from "../rules/edge-constraints.mjs";
47
47
  import { evaluate } from "../rules/index.mjs";
48
48
 
49
49
  import {
@@ -170,7 +170,7 @@ export function diagnoseDocument({ sourceFile, text, index, config }) {
170
170
  // Nx/`archkeep.json` `implicitDependencies` declaration — has no import site
171
171
  // behind it, so it never becomes an `importSites` record for the rule engine
172
172
  // to iterate. The CLI judges exactly those edges itself
173
- // (`../commands/edge-constraints.mjs`'s `declaredEdgeViolationsForCheck`,
173
+ // (`../rules/edge-constraints.mjs`'s `declaredEdgeViolationsForCheck`,
174
174
  // `cli.mjs check`), and without the same fold here the editor would paint a
175
175
  // file clean while `check` exits 1 over the same declared edge — the
176
176
  // boundary rule that never runs, dressed as a clean tree. Only the violations
@@ -393,7 +393,7 @@ export function readWorkspaceOptions(root) {
393
393
  // A Moon root, checked only once neither marker file is there: a `.moon`
394
394
  // beside `nx.json` keeps falling to `readPluginOptions` below exactly as it
395
395
  // did, and `./workspace-index.mjs`'s `buildWorkspaceIndex` refuses the pair
396
- // loudly through the one shared gate (`../commands/context.mjs`'s
396
+ // loudly through the one shared gate (`../providers/model-gate.mjs`'s
397
397
  // `requireSingleProjectModel`) rather than this function growing a second
398
398
  // copy of that refusal. `moonMarkerAt` is the same dispatcher the index and
399
399
  // the CLI read, so all three agree about which directory marks the tree —
@@ -134,7 +134,7 @@ import {
134
134
  import { nodeTypeOf, PROJECT_CONFIG_FILE } from "../providers/native/discover.mjs";
135
135
  import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
136
136
  import { nativeProvider } from "../providers/native/index.mjs";
137
- import { requireSingleProjectModel } from "../commands/context.mjs";
137
+ import { requireSingleProjectModel } from "../providers/model-gate.mjs";
138
138
  import { mergeImportEdges, moonProvider } from "../providers/moon.mjs";
139
139
 
140
140
  export { PROJECT_CONFIG_FILE, nodeTypeOf, buildDependencies };
@@ -337,7 +337,7 @@ export function buildNodes(projects) {
337
337
  * no project has no boundary to cross — a clean report, produced by not
338
338
  * looking. Also when the root carries more than one project-model marker —
339
339
  * a Moon directory beside `nx.json`/`archkeep.json`, both Moon spellings at
340
- * once, or `nx.json` beside `archkeep.json` (`../commands/context.mjs`'s
340
+ * once, or `nx.json` beside `archkeep.json` (`../providers/model-gate.mjs`'s
341
341
  * `requireSingleProjectModel`, the same refusal `check` makes) — which
342
342
  * config governs at all is a decision nobody made, refused the same way an
343
343
  * unreadable `nx.json` is, through this function's caller in `./server.mjs`.
@@ -354,7 +354,7 @@ export function buildWorkspaceIndex({
354
354
  const files = listFiles(root);
355
355
  const readFile = (path) => readFileAt(root, path);
356
356
  // Which provider may judge this root at all — the SAME gate
357
- // (`../commands/context.mjs`'s `requireSingleProjectModel`) the CLI reads
357
+ // (`../providers/model-gate.mjs`'s `requireSingleProjectModel`) the CLI reads
358
358
  // before any command runs, so a tree carrying a Moon directory beside
359
359
  // `nx.json`/`archkeep.json` is refused here exactly as `check` refuses it,
360
360
  // in the same words, from the one copy of the rule. Before the shared
package/src/options.mjs CHANGED
@@ -322,7 +322,7 @@ export function readPluginOptions(workspaceRoot, { readFile = readFileOrNull } =
322
322
  * `nx.json` → `plugins[].options.tsConfig` and a native root states it on
323
323
  * `archkeep.json`'s own `tsConfig` field; Moon's own configuration carries no
324
324
  * plugin-options table, and a `archkeep.json` beside `.moon/` is refused
325
- * outright (`./commands/context.mjs`'s `requireSingleProjectModel`), so every
325
+ * outright (`./providers/model-gate.mjs`'s `requireSingleProjectModel`), so every
326
326
  * door to a stated name is shut. What is left is convention, and one name is
327
327
  * not enough of it: measured on a 94-project Vue Moon workspace whose `paths`
328
328
  * table lives in `tsconfig.json` with no `tsconfig.base.json` beside it, a
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The one gate deciding whether a workspace root may be judged at all: which
3
+ * project model it declares, and a loud refusal when it declares more than
4
+ * one.
5
+ *
6
+ * Lives in the providers layer because the decision it makes is a
7
+ * provider-selection decision — `../commands/context.mjs` composes a provider
8
+ * behind it, and `../lsp/workspace-index.mjs` branches on the same facts
9
+ * before choosing one — and neither face may hold a second copy of the rule.
10
+ * A second copy was exactly how the faces drifted apart once: the CLI refused
11
+ * a tree carrying a Moon directory beside `nx.json`/`archkeep.json` while the
12
+ * editor indexed it anyway — a clean diagnostic list over a tree nobody agreed
13
+ * could be judged (#223's silent shape, one level up).
14
+ */
15
+
16
+ import { existsSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ import { NX_CONFIG_FILE } from "../options.mjs";
20
+ import { ARCHKEEP_MODEL_FILE } from "./native/model.mjs";
21
+ import { moonMarkerAt } from "./moon.mjs";
22
+
23
+ /**
24
+ * The one gate deciding whether `root` may be judged at all: more than ONE
25
+ * project-model marker present is refused, naming what conflicts.
26
+ *
27
+ * Every entry point that picks a provider must answer this identically —
28
+ * `../commands/context.mjs`'s `resolveCommandContext` reads it before any
29
+ * command runs, and `../lsp/workspace-index.mjs`'s index build reads it before
30
+ * choosing a branch. Moon-versus-Moon coexistence (`.moon/` AND
31
+ * `.config/moon/`) is refused inside `./moon.mjs`'s `moonMarkerAt`, which this
32
+ * gate calls first; the cross-family pairs are refused here, all in the same
33
+ * terms: which model to judge against is a decision nobody made, not one this
34
+ * tool can make for them.
35
+ *
36
+ * @param {string} root
37
+ * @param {{exists?: (path: string) => boolean}} [io] Injectable existence
38
+ * test (absolute paths), so a test drives this without a filesystem.
39
+ * @returns {{hasNx: boolean, hasNative: boolean, moonMarker: string|null}}
40
+ * The facts a provider choice needs; `moonMarker` names whichever Moon
41
+ * directory is present, `null` when neither spelling is.
42
+ * @throws {Error} when more than one marker is present.
43
+ */
44
+ export function requireSingleProjectModel(root, { exists = existsSync } = {}) {
45
+ const moonMarker = moonMarkerAt(root, { exists });
46
+ const hasNx = exists(join(root, NX_CONFIG_FILE));
47
+ const hasNative = exists(join(root, ARCHKEEP_MODEL_FILE));
48
+ const refusal = (a, b) =>
49
+ new Error(
50
+ `archkeep: ${root} declares both ${a} and ${b} — this tool judges a workspace ` +
51
+ `against exactly one project model, and a tree carrying both is a decision nobody made ` +
52
+ `rather than one this tool can make for them. Remove whichever one is not the ` +
53
+ `workspace's real source of truth for projects and tags.`,
54
+ );
55
+ if (moonMarker !== null && hasNx) throw refusal(moonMarker, NX_CONFIG_FILE);
56
+ if (moonMarker !== null && hasNative) throw refusal(moonMarker, ARCHKEEP_MODEL_FILE);
57
+ if (hasNx && hasNative) throw refusal(NX_CONFIG_FILE, ARCHKEEP_MODEL_FILE);
58
+ return { hasNx, hasNative, moonMarker };
59
+ }
@@ -31,6 +31,7 @@ import { existsSync } from "node:fs";
31
31
  import { delimiter, join, posix } from "node:path";
32
32
 
33
33
  import { environmentForTree, runProcess } from "../process.mjs";
34
+ import { isEnoent } from "../errors.mjs";
34
35
  import { buildDependencies } from "./native/graph.mjs";
35
36
 
36
37
  /**
@@ -249,7 +250,7 @@ function resolveMoonEnv(workspaceRoot, { env = process.env, platform = process.p
249
250
  * - **Archkeep** (from Nx's `implicitDependencies`, and `archkeep.json`'s own
250
251
  * row of that name): `type: "implicit"` means *a human declared this edge
251
252
  * and there is no import behind it*. That is precisely why
252
- * `../commands/edge-constraints.mjs`'s `declaredEdgeViolationsForCheck`
253
+ * `../rules/edge-constraints.mjs`'s `declaredEdgeViolationsForCheck`
253
254
  * exists — such an edge never becomes an `importSites` record, so
254
255
  * `evaluate()` structurally cannot reach it and `check` judges it as an edge
255
256
  * instead. `../commands/drift.mjs` and `../commands/discover.mjs` exclude
@@ -905,16 +906,15 @@ function resolveMoonCli(workspaceRoot, { resolveMoon = () => "moon" } = {}) {
905
906
  * `nxCli` guards against from the other direction, where only a
906
907
  * `MODULE_NOT_FOUND` earns the "not installed" story.
907
908
  *
908
- * `../process.mjs`'s `runProcess` wraps the child's failure and carries the
909
- * original on `cause`, so the code is read from there; the direct `code` is
910
- * read too, for a `run` seam that surfaces a spawn error unwrapped.
909
+ * The shape test itself is `../errors.mjs`'s `isEnoent` the name stays
910
+ * because what this file asks is not "is this ENOENT" but "does this failure
911
+ * carry an install action".
911
912
  *
912
913
  * @param {unknown} error
913
914
  * @returns {boolean}
914
915
  */
915
916
  function isMoonBinaryMissing(error) {
916
- const thrown = /** @type {{code?: unknown, cause?: {code?: unknown}}|null|undefined} */ (error);
917
- return thrown?.code === "ENOENT" || thrown?.cause?.code === "ENOENT";
917
+ return isEnoent(error);
918
918
  }
919
919
 
920
920
  /**