@ecoma-io/archkeep 0.13.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 (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,496 @@
1
+ /**
2
+ * The agent architecture planning context: the deterministic facts an agent
3
+ * needs before it reasons about, plans, and executes a change in a
4
+ * Archkeep-governed workspace.
5
+ *
6
+ * ## The boundary this module holds
7
+ *
8
+ * Archkeep produces deterministic architecture facts and constraints; an agent
9
+ * produces reasoning, planning and code. This module never generates a plan,
10
+ * never decides an implementation strategy, never modifies source code, and
11
+ * never weakens architecture policy. Every field it emits is a fact the tree
12
+ * or the boundary law states; the sections that cannot state a fact say so
13
+ * (`null`, `[]`, or an explicit `no-verdict`), which is the empty-result
14
+ * invariant (`../../../../AGENTS.md`) applied to a planning document.
15
+ *
16
+ * ## What it reports
17
+ *
18
+ * Scoped to a requested change (the target project plus optional paths), each
19
+ * section is deterministic and independently verifiable:
20
+ *
21
+ * - **Current architecture** — the graph snapshot (`buildProjects`/
22
+ * `buildDependencies`) and which projects the change touches.
23
+ * - **Applicable policy / Intent** — the constraint rows that govern the
24
+ * target project, each carrying its authored description and remediation
25
+ * (the workspace's "Intent"), plus the policy fingerprint for future diffs.
26
+ * - **Impact** — every project that depends on the target project, separated
27
+ * direct/transitive, capped with an explicit overflow note.
28
+ * - **Current violations** — the real rule engine verdict (`evaluate`) over
29
+ * the WHOLE analyzeable tree, scoped for reporting. This is what makes
30
+ * circular-dependency and lazy-load rules correct on every provider.
31
+ * - **Drift** — go.work and tsconfig-path boundary drift, sourced from the
32
+ * same `compareGoWork`/`judgeTsconfigPaths` functions `check` uses.
33
+ * - **Intent** — the canonical Architecture Intent verdict (`driftForCheck`,
34
+ * the object `drift` and `check` share), absent when the workspace declares
35
+ * no `architecture-intent.json`, no-verdict when one cannot be established.
36
+ * - **Coverage / limitations** — complete vs no-verdict, with the exact files
37
+ * that could not be analyzed.
38
+ * - **Verification commands** — the deterministic commands an agent runs after
39
+ * making the change.
40
+ *
41
+ * ## Why whole-tree for the rule verdict
42
+ *
43
+ * `evaluate(importSites, graph, config)` judges only the sites it is given,
44
+ * and circular-dependency/lazy-load rules need the whole file index. Native
45
+ * providers already analyze the whole tree (`src/commands/context.mjs`); Nx
46
+ * and Moon scope before analyzing. For the planning context the rule verdict
47
+ * must be over the whole analyzeable tree so that a plan scoped to one
48
+ * project still reports a cycle that closes through another — the same fact a
49
+ * full `check` would state. Violations are then filtered to the scoped
50
+ * reporting set. This makes the plan's verdict correct on every provider.
51
+ */
52
+ import { statSync } from "node:fs";
53
+ import { join } from "node:path";
54
+
55
+ import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
56
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
57
+ import { tsconfigPathsFacts } from "../analysis/typescript.mjs";
58
+ import { compareGoWork, parseGoWorkUse } from "../go-work.mjs";
59
+ import { judgeTsconfigPaths } from "../tsconfig-paths.mjs";
60
+ import { jsonEnvelope, renderJson } from "../report/json.mjs";
61
+ import { evaluate } from "../rules/index.mjs";
62
+ import { analyzeWorkspace } from "../workspace.mjs";
63
+ import { driftForCheck } from "./drift.mjs";
64
+ import { computeImpact } from "./impact.mjs";
65
+ import { collectProjectContext } from "./context-command.mjs";
66
+ import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
67
+ import { resolveProvenance } from "./provenance.mjs";
68
+ import { readAdrContext } from "./adr.mjs";
69
+ import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
70
+ import { formatPlanContextReport } from "../report/plan-context-text.mjs";
71
+
72
+ /** How many dependents are listed before an explicit overflow note. */
73
+ export const DEPENDENT_CAP = 10;
74
+
75
+ /**
76
+ * The projects a change touches: the target project, plus every project whose
77
+ * files lie inside one of the given paths. A path pointing at a project root
78
+ * resolves to that project; a path into a shared directory resolves to every
79
+ * project owning a file there.
80
+ *
81
+ * @param {object} commandContext The `CommandContext` from `resolveCommandContext`.
82
+ * @param {string[]} paths Optional workspace-relative paths the change touches.
83
+ * @returns {string[]} Distinct affected project names, sorted.
84
+ */
85
+ export function collectAffectedProjects(commandContext, paths) {
86
+ const affected = new Set();
87
+ for (const { file, project } of commandContext.owned ?? []) {
88
+ for (const p of paths) {
89
+ if (file === p || file.startsWith(`${p}/`)) {
90
+ affected.add(project);
91
+ break;
92
+ }
93
+ }
94
+ }
95
+ return [...affected].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
96
+ }
97
+
98
+ /**
99
+ * The union of `computeImpact` over every project the change touches — the
100
+ * complete blast radius, not just the single named project's. Dependents are
101
+ * capped so a change at the bottom of a large graph does not produce an
102
+ * unbounded document.
103
+ *
104
+ * @param {string} projectName The target project being changed.
105
+ * @param {string[]} affected Every project the change touches.
106
+ * @param {object} graph The project graph.
107
+ * @returns {object[]}
108
+ */
109
+ export function collectImpact(projectName, affected, graph) {
110
+ const targets = [...new Set([projectName, ...affected])].sort((a, b) =>
111
+ a < b ? -1 : a > b ? 1 : 0,
112
+ );
113
+ return targets.map((target) => {
114
+ const impact = computeImpact(target, graph);
115
+ const capped = impact.dependents.slice(0, DEPENDENT_CAP);
116
+ return {
117
+ project: target,
118
+ direct: impact.direct.slice(0, DEPENDENT_CAP),
119
+ transitive: impact.transitive.slice(0, DEPENDENT_CAP),
120
+ dependents: capped,
121
+ dependentsTotal: impact.dependents.length,
122
+ hasMore: impact.dependents.length > capped.length,
123
+ };
124
+ });
125
+ }
126
+
127
+ /**
128
+ * The scope a path-scoped change is judged against: the set of project-owned
129
+ * files whose project the change touches, or `null` for "no scope given" (the
130
+ * whole workspace). `null` differs from `[]` on purpose — an empty scope is
131
+ * indistinguishable from a path that matched no project, which is itself a
132
+ * claim about the workspace the agent should see.
133
+ *
134
+ * @param {object} commandContext From `resolveCommandContext`.
135
+ * @param {string[]} affected Every project the change touches.
136
+ * @returns {Set<string>|null}
137
+ */
138
+ function scopedFiles(commandContext, affected) {
139
+ if (affected.length === 0) return null;
140
+ const files = new Set();
141
+ for (const { file, project } of commandContext.owned ?? []) {
142
+ if (affected.includes(project)) files.add(file);
143
+ }
144
+ return files;
145
+ }
146
+
147
+ /**
148
+ * Whole-workspace drift: the same two checks `check` runs, sourced from the
149
+ * same functions, keyed off each manifest's presence the same way. An absent
150
+ * `go.work` or absent `paths` table is `null` — not judged, never "no drift" —
151
+ * and a manifest that cannot be read is a whole-file failure that puts the run
152
+ * in the 3-class (`no-verdict`), exactly as it does for `check`.
153
+ *
154
+ * @param {object} commandContext From `resolveCommandContext`.
155
+ * @returns {{goWork: object|null, tsconfigPaths: object|null, failures: object[]}}
156
+ */
157
+ export function collectDrift(commandContext) {
158
+ const { root, tracked, workspace } = commandContext;
159
+ const failures = [];
160
+
161
+ let goWork = null;
162
+ if (tracked.includes("go.work")) {
163
+ try {
164
+ const goWorkText = workspace.readFile("go.work");
165
+ if (goWorkText === null) throw new Error("go.work could not be read");
166
+ goWork = compareGoWork({
167
+ uses: parseGoWorkUse(goWorkText),
168
+ workspaceRoot: root,
169
+ projects: workspace.projects,
170
+ files: tracked,
171
+ });
172
+ } catch (cause) {
173
+ failures.push({
174
+ sourceFile: "go.work",
175
+ line: null,
176
+ column: null,
177
+ reason:
178
+ `${cause?.message ?? cause} — a go.work this tool cannot read is a coverage hole, ` +
179
+ `not an empty use list, so the drift check reached no verdict`,
180
+ });
181
+ }
182
+ }
183
+
184
+ let tsconfigPaths = null;
185
+ const facts = tsconfigPathsFacts(workspace);
186
+ if (facts.configFailure !== null) {
187
+ failures.push({
188
+ sourceFile: facts.tsConfig,
189
+ line: null,
190
+ column: null,
191
+ reason:
192
+ `${facts.configFailure} — and the paths hygiene check reached no verdict, because a ` +
193
+ `tsconfig this tool cannot load is a coverage hole, not an empty alias table`,
194
+ });
195
+ } else if (facts.paths !== undefined) {
196
+ tsconfigPaths = judgeTsconfigPaths({
197
+ paths: facts.paths,
198
+ base: facts.base,
199
+ workspaceRoot: root,
200
+ tsConfig: facts.tsConfig,
201
+ directoryExists: (dir) => {
202
+ try {
203
+ return statSync(join(root, dir)).isDirectory();
204
+ } catch {
205
+ return false;
206
+ }
207
+ },
208
+ });
209
+ for (const { reason } of tsconfigPaths.malformed) {
210
+ failures.push({ sourceFile: facts.tsConfig, line: null, column: null, reason });
211
+ }
212
+ }
213
+
214
+ return { goWork, tsconfigPaths, failures };
215
+ }
216
+
217
+ /**
218
+ * Compiles the planning context for a change to `projectName`, scoped to
219
+ * `paths`, returning a `{status, coverage, result, report}` contract ready for
220
+ * `cli.mjs` to render — the same shape `contextCommand` returns.
221
+ *
222
+ * The rule verdict is over the WHOLE analyzeable tree so whole-graph rules
223
+ * (circular, lazy-load, transitive tag rules) are correct on every provider;
224
+ * only reporting is scoped. Coverage is therefore computed over that whole
225
+ * tree, so `complete` is honest: it is true exactly when no analyzeable file
226
+ * in the workspace failed.
227
+ *
228
+ * @param {string} projectName The target project being changed.
229
+ * @param {string[]} paths Optional workspace-relative paths the change touches.
230
+ * @param {object} commandContext From `resolveCommandContext`.
231
+ * @param {object} config The loaded boundary config.
232
+ * @returns {Promise<{status: "ok"|"no-verdict", exitCode: number, coverage: object,
233
+ * result: object, report: {text: string, json: string}}>}
234
+ */
235
+ export async function planContextCommand(projectName, paths, commandContext, config) {
236
+ const { root, provider, marker, graph, workspace, pluginGap, tracked } = commandContext;
237
+
238
+ // The same refusal every descriptive command carries: on an Nx workspace
239
+ // whose plugin is unregistered but whose tracked files include polyglot
240
+ // manifests under project roots, the graph carries no polyglot edges, so the
241
+ // architecture facts and the rule verdict would silently under-represent the
242
+ // real architecture.
243
+ if (provider === "nx" && !pluginGap.registered && pluginGap.manifests.length > 0) {
244
+ throw new Error(
245
+ `archkeep: refusing to build a planning context for an Nx workspace where this plugin is ` +
246
+ `not registered but polyglot manifests exist under project roots ` +
247
+ `(${pluginGap.manifests.join(", ")}). The graph would carry no polyglot edges, ` +
248
+ `so the architecture facts shown would be against an incomplete graph. ` +
249
+ `Register the plugin in nx.json: ` +
250
+ `"plugins": [{ "plugin": "@ecoma-io/archkeep/nx" }], or remove the polyglot manifests ` +
251
+ `if they are not in use.`,
252
+ );
253
+ }
254
+
255
+ // The target project's tags, constraints and per-edge verdicts — the same
256
+ // architecture context the ordinary `context` command reports, carried here
257
+ // so the plan and the plain command cannot disagree about what the policy
258
+ // says. `collectProjectContext` throws for a project absent from the graph,
259
+ // which is the caller-error path `cli.mjs` maps to usage.
260
+ const projectContext = collectProjectContext(projectName, graph, config);
261
+
262
+ // A matched constraint row's `decisionRef` names the ADR (or rule/fitness
263
+ // id) that supposedly authorizes it — the same gap the plain `context`
264
+ // command closes for the identical rows, through the identical
265
+ // `readAdrContext`/`unresolvedDecisionRefRows`
266
+ // (`../governance/adr-registry.mjs`). The plan and the plain command must
267
+ // not disagree about which citations resolve any more than they disagree
268
+ // about the constraint rows themselves.
269
+ const planDecisionRefRows = projectContext.constraints
270
+ .map((row, index) => ({ kind: `constraints[${index}]`, row }))
271
+ .filter(({ row }) => typeof row?.decisionRef === "string" && row.decisionRef.trim() !== "");
272
+ let unresolvedDecisionRefs = new Set();
273
+ if (planDecisionRefRows.length > 0) {
274
+ const adrContext = readAdrContext(root, { tracked });
275
+ unresolvedDecisionRefs = new Set(
276
+ // F04: the fitness half resolves against the ids THIS policy declares
277
+ // (`declaredFitnessNames(config)`), never the ADRs' own `bindings`.
278
+ unresolvedDecisionRefRows(
279
+ planDecisionRefRows,
280
+ adrContext.byId,
281
+ declaredFitnessNames(config),
282
+ ).map((row) => row.decisionRef),
283
+ );
284
+ }
285
+
286
+ // Whole-tree analysis: the same import set `check` would judge, so the rule
287
+ // verdict below is the full-workspace verdict regardless of provider.
288
+ const wholeTree = analyzeWorkspace(
289
+ workspace,
290
+ (commandContext.owned ?? []).map(({ file }) => file),
291
+ );
292
+ const wholeVerdict = evaluate(wholeTree.imports, graph, config);
293
+
294
+ // The change's own scoped failures plus the drift check's failures are what
295
+ // decide coverage: every whole-file failure puts the run in the 3-class.
296
+ const drift = collectDrift(commandContext);
297
+ // The canonical Intent verdict, folded the way `check`'s does —
298
+ // `driftForCheck` is what `drift` and `check` share, and the plan's Intent
299
+ // section must not disagree with either. Absent when the workspace chose not
300
+ // to make an intent (a workspace decision, not a finding); a deviant or
301
+ // unreadable intent is a no-verdict exactly as it is for `check`, and rides
302
+ // `intent`'s own no-verdict lane, never the file-coverage hole list.
303
+ let intent = null;
304
+ if (tracked.includes(INTENT_FILE)) {
305
+ try {
306
+ const intentDrift = await driftForCheck(commandContext, {
307
+ loadIntentOverride: (root) => loadIntent(root, { tracked }),
308
+ });
309
+ intent = {
310
+ verdict:
311
+ intentDrift.findings.length > 0
312
+ ? "findings"
313
+ : intentDrift.unresolved.length > 0
314
+ ? "no-verdict"
315
+ : "ok",
316
+ rows: intentDrift.intent.rows,
317
+ boundaries: intentDrift.boundaries,
318
+ findings: intentDrift.findings,
319
+ unresolved: intentDrift.unresolved,
320
+ notes: intentDrift.notes,
321
+ };
322
+ } catch (cause) {
323
+ const reason =
324
+ `${cause?.message ?? cause} — architecture-intent.json could not be ` +
325
+ `established, so the intent check reached no verdict`;
326
+ intent = {
327
+ verdict: "no-verdict",
328
+ rows: 0,
329
+ findings: [],
330
+ unresolved: [{ boundary: INTENT_FILE, issue: reason }],
331
+ boundaries: [],
332
+ notes: [],
333
+ };
334
+ }
335
+ }
336
+ const failures = [...wholeTree.failures, ...drift.failures];
337
+ const notAnalyzed = failures
338
+ .filter(isWholeFileFailure)
339
+ .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
340
+
341
+ const affected = collectAffectedProjects(commandContext, paths);
342
+ const scope = scopedFiles(commandContext, affected);
343
+ const violations = wholeVerdict
344
+ .filter((violation) => scope === null || scope.has(violation.sourceFile))
345
+ // Deterministic order — `evaluate` returns sites in analysis order, which is
346
+ // stable for an unchanged tree, but the plan contract runs across providers
347
+ // and providers scope analysis differently; sort by the fields a reader acts
348
+ // on so two runs over unchanged bytes match byte-for-byte (`AGENTS.md`).
349
+ .sort(
350
+ (a, b) =>
351
+ (a.sourceFile < b.sourceFile ? -1 : a.sourceFile > b.sourceFile ? 1 : 0) ||
352
+ a.line - b.line ||
353
+ a.column - b.column ||
354
+ (a.messageId < b.messageId ? -1 : a.messageId > b.messageId ? 1 : 0),
355
+ );
356
+
357
+ const complete = notAnalyzed.length === 0;
358
+ const status = complete ? "ok" : "no-verdict";
359
+ const exitCode = complete ? 0 : 3;
360
+
361
+ const notes = [
362
+ "violations are the full-workspace rule-engine verdict (`evaluate` over the whole " +
363
+ "analyzeable tree), scoped for reporting to the projects this change touches — " +
364
+ "identical to the verdict a full `check` would state.",
365
+ "drift is keyed off manifest presence: an absent go.work or absent paths table is " +
366
+ "null (not judged), never 'no drift'.",
367
+ "dependents are capped at 10 per project with dependentsTotal/hasMore for the rest.",
368
+ ];
369
+ // A path that matched no project's files is distinct from "no scope given":
370
+ // the agent asked to narrow and got nothing, which either way means the whole
371
+ // workspace is the verdict's scope. Say so rather than silently broadening.
372
+ if (paths.length > 0 && affected.length === 0) {
373
+ notes.push(
374
+ `no path matched a project-owned file (given: ${paths.join(", ")}), so the scope fell ` +
375
+ `back to the whole workspace — re-check the path spellings against 'archkeep graph'.`,
376
+ );
377
+ }
378
+
379
+ const coverage = {
380
+ complete,
381
+ projects: Object.keys(graph.nodes).length,
382
+ analyzedFiles: wholeTree.analyzed,
383
+ imports: wholeTree.imports.length,
384
+ notAnalyzed,
385
+ blindSpots: failures
386
+ .filter((failure) => !isWholeFileFailure(failure))
387
+ .map(({ sourceFile, line, column, reason }) => ({ file: sourceFile, line, column, reason })),
388
+ // Intent's own coverage notes ride the same seam `check` threads them on
389
+ // (today only an `"optional": true` allowed row whose statement is absent),
390
+ // so the plan's text and JSON reports read the same notes `check` does.
391
+ notes: [...notes, ...(intent === null ? [] : intent.notes)],
392
+ };
393
+
394
+ const result = {
395
+ // The plain `context` command's fields, held at the same top-level paths so
396
+ // the plan's envelope is additive — a consumer that already parses `context`
397
+ // reads exactly what it read before, plus the plan's own sections under
398
+ // `result.plan`. No field is removed or moved.
399
+ project: projectContext.project,
400
+ tags: projectContext.tags,
401
+ constraints: projectContext.constraints,
402
+ dependencies: projectContext.dependencies,
403
+ // Additive and optional, the same bargain the plain `context` command's
404
+ // field of the same name states: absent when every matched row's
405
+ // decisionRef resolves (or none carries one).
406
+ ...(unresolvedDecisionRefs.size > 0
407
+ ? {
408
+ unresolvedDecisionRefs: [...unresolvedDecisionRefs].sort((a, b) =>
409
+ a < b ? -1 : a > b ? 1 : 0,
410
+ ),
411
+ }
412
+ : {}),
413
+ // The planning context's own sections, nested so the plain `context` fields
414
+ // above stay intact — a consumer that already parses `context` reads exactly
415
+ // what it read before, plus the plan.
416
+ plan: {
417
+ // What this payload is, so a consumer reading the `context` command's JSON
418
+ // envelope can distinguish the planning context from the plain context
419
+ // without guessing from field presence.
420
+ variant: "plan",
421
+ // A fingerprint of the boundary policy, so a later run over the same tree
422
+ // can tell whether the rule table changed between runs.
423
+ policyFingerprint: computePolicyFingerprint(config),
424
+ // Current architecture.
425
+ architecture: {
426
+ projects: buildProjects(graph.nodes),
427
+ dependencies: buildDependencies(graph.dependencies),
428
+ targets: affected,
429
+ },
430
+ // Impact (capped).
431
+ impact: collectImpact(projectName, affected, graph),
432
+ // Current violations (scoped reporting, whole-tree verdict).
433
+ violations,
434
+ // Drift.
435
+ drift: {
436
+ goWork: goWorkResult(drift.goWork),
437
+ tsconfigPaths: tsconfigPathsResult(drift.tsconfigPaths),
438
+ },
439
+ // The canonical Architecture Intent verdict — the same fold `check` and
440
+ // `drift` report. Absent (key omitted) when no intent file is tracked,
441
+ // matching `check`: intent absence is a workspace decision about
442
+ // governance, never a claim of zero findings.
443
+ ...(intent === null
444
+ ? {}
445
+ : {
446
+ intent: {
447
+ verified: true,
448
+ file: INTENT_FILE,
449
+ verdict: intent.verdict,
450
+ rows: intent.rows,
451
+ findings: intent.findings,
452
+ unresolved: intent.unresolved,
453
+ boundaries: intent.boundaries,
454
+ notes: intent.notes,
455
+ },
456
+ }),
457
+ // The deterministic commands an agent runs after making the change.
458
+ verify: [
459
+ `archkeep check --format json`,
460
+ `archkeep impact ${projectName} --format json`,
461
+ `archkeep graph --format json`,
462
+ ],
463
+ provenance: resolveProvenance(root),
464
+ },
465
+ };
466
+
467
+ const envelope = jsonEnvelope({
468
+ command: "context",
469
+ context: { root, provider, marker, provenance: resolveProvenance(root) },
470
+ status,
471
+ exitCode,
472
+ coverage,
473
+ result,
474
+ });
475
+
476
+ return {
477
+ status,
478
+ exitCode,
479
+ coverage,
480
+ result,
481
+ report: {
482
+ text: formatPlanContextReport({ project: result, coverage, unresolvedDecisionRefs }),
483
+ json: renderJson(envelope),
484
+ },
485
+ };
486
+ }
487
+
488
+ /** The drift section, spelled the way `check` spells it, plus `null` for absent. */
489
+ function goWorkResult(goWork) {
490
+ return goWork === null ? null : { checked: true, findings: goWork.findings };
491
+ }
492
+
493
+ /** The drift section, spelled the way `check` spells it, plus `null` for absent. */
494
+ function tsconfigPathsResult(tsconfigPaths) {
495
+ return tsconfigPaths === null ? null : { checked: true, findings: tsconfigPaths.findings };
496
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The boundary-policy ladder every command that reads a boundary law shares.
3
+ *
4
+ * A preamble rather than a command — the same kind of exception `./context.mjs`
5
+ * is, and for the same reason: the `run*` functions in `../../cli.mjs` and
6
+ * `./check.mjs` all need the identical resolution order, and a hand-copied
7
+ * ladder is what let two defects land in it independently. `resolvePolicy`
8
+ * below argues the order, each arm, and what `profile`/`source` name.
9
+ */
10
+
11
+ import { isAbsolute, relative, resolve } from "node:path";
12
+
13
+ import { containmentViolation } from "../containment.mjs";
14
+ import { loadBoundaryConfig, loadBoundaryConfigFile, policyFrom } from "../config.mjs";
15
+ import { profilePolicy } from "../governance/profile-registry.mjs";
16
+ import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
17
+
18
+ /**
19
+ * Whether the workspace's resolved options name a `profiles` registry — the
20
+ * check command's signal to enforce by profile NAME rather than by file. A
21
+ * workspace that never registered the plugin carries the default `profiles:
22
+ * undefined`, which is the same "no registry, no named law" state as a
23
+ * workspace that declared nothing.
24
+ *
25
+ * @param {object} options The resolved options from `resolveCommandContext`.
26
+ * @returns {boolean}
27
+ */
28
+ export function hasProfiles(options) {
29
+ return typeof options?.profiles === "string" && options.profiles !== "";
30
+ }
31
+
32
+ /**
33
+ * The boundary-policy "ladder" every command that reads a boundary law
34
+ * shares, in the one place all of them now call it from. Hand-copied 11
35
+ * times before this — `check`, `graph`, `diff`, `waivers`, `fitness`,
36
+ * `impact`, `explain`, `context`, `history`'s `--capture` branch, `debt`,
37
+ * `health` — the duplication is what let two defects land in it independently:
38
+ * P1-25 found `graph`'s copy alone missing the inline-object arm, and P1-26
39
+ * found only `check`'s copy aware of a `profiles` registry at all — the other
40
+ * ten tried to resolve a profile NAME as a file, and named the wrong problem
41
+ * when it could not: `loadBoundaryConfigFile` refuses a bare name like
42
+ * `"strict"` as "names an unsupported boundaryConfig extension '(none)'",
43
+ * which blames a typo that was never made rather than naming the real gap —
44
+ * that command never knew profiles existed. One function, called from all
45
+ * eleven sites, is what makes that defect class structurally impossible to
46
+ * reintroduce one copy at a time.
47
+ *
48
+ * Checked in order, and the first match wins:
49
+ *
50
+ * 1. A `profiles` registry, when the workspace names one
51
+ * (`commandContext.options.profiles`) — `--config`, or absent that
52
+ * `boundaryConfig`, is then a profile NAME resolved from that registry,
53
+ * never a filename or an inline object at the same time
54
+ * (`docs/concepts/profiles.md`, "Selecting by name": the two never mix at
55
+ * one field).
56
+ * 2. `--config`, a FILE path, resolved against `cwd` rather than the
57
+ * workspace root — the tool and the law it enforces may be in different
58
+ * trees, and the tree being judged is still the consumer's.
59
+ * 3. The workspace's own `boundaryConfig`: a filename
60
+ * (`loadBoundaryConfig`), or — native workspaces only — the policy
61
+ * inline, as an object rather than a filename (`policyFrom`).
62
+ * 4. `null`, when the workspace declares no law at all. No current provider
63
+ * ever reaches this arm — `boundaryConfig` always resolves to a non-empty
64
+ * string or a truthy inline object (`../options.mjs`'s
65
+ * `DEFAULT_OPTIONS`, `../providers/native/model.mjs`'s
66
+ * `normalizeNativeModel`) — but the guard stays rather than calling
67
+ * `policyFrom(undefined, ...)` unconditionally, so a future provider that
68
+ * leaves the option unset degrades to "no policy" instead of a crash on a
69
+ * value that was never validated.
70
+ *
71
+ * `profile`/`source` name WHICH of the four arms fired and where its bytes
72
+ * came from — `profile` is the resolved profile name (`null` on every arm but
73
+ * the first), `source` is always workspace-relative, the convention every
74
+ * other file reference in a report already keeps (`sourceFile`, `tsConfig`,
75
+ * intent's `file`). Only `check` reads either field today (P1-01, naming the
76
+ * law that governed a run in its own report), but they are returned
77
+ * unconditionally rather than as a second, `check`-only code path, so the
78
+ * eleven callers keep sharing the one ladder this function exists to be.
79
+ *
80
+ * @param {{config: string|null}} options The command's own parsed flags —
81
+ * only `config` is read here, so a command with no `--config` flag at all
82
+ * (`graph` takes none) simply never sets it and this arm is skipped.
83
+ * @param {object} commandContext From `resolveCommandContext` — `root` and
84
+ * `options` (the workspace's resolved `boundaryConfig`/`profiles`) are read.
85
+ * @param {string} cwd The process's working directory a relative `--config`
86
+ * resolves against — kept separate from the workspace root for the reason
87
+ * above.
88
+ * @returns {Promise<{config: {depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], notes?: string[]}|null, profile: string|null, source: string|null}>}
89
+ * `fitness` and `customRules` are present only when the resolved policy
90
+ * declares them — an absent key is the workspace's decision not to declare
91
+ * that law, never an empty one (`../config.mjs`'s `policyFrom`).
92
+ * @throws {Error} when a named profile, a `--config` file, or an inline
93
+ * policy cannot be resolved or is malformed — every arm's existing failure
94
+ * mode, unchanged by the extraction.
95
+ */
96
+ export async function resolvePolicy(options, commandContext, cwd) {
97
+ const { root } = commandContext;
98
+ if (hasProfiles(commandContext.options)) {
99
+ const profileName = String(options.config ?? commandContext.options.boundaryConfig);
100
+ const registryPath = resolve(root, commandContext.options.profiles);
101
+ // `profiles` is a tree-derived filename (`nx.json`/`archkeep.json` options),
102
+ // so a tracked symlink in an intermediate component of it would hand
103
+ // outside profile rows in as the workspace's — the same read escape
104
+ // `loadBoundaryConfig` now refuses at `loadBoundaryConfig`; held here for
105
+ // the profiles arm (`../containment.mjs`, the read-side G-10 closure).
106
+ const violation = containmentViolation(root, registryPath);
107
+ if (violation !== null) {
108
+ throw new Error(`archkeep: cannot load ${registryPath}: ${violation}`);
109
+ }
110
+ const config = profilePolicy(
111
+ registryPath,
112
+ profileName,
113
+ options.config ?? commandContext.options.boundaryConfig,
114
+ );
115
+ return { config, profile: profileName, source: relative(root, registryPath) };
116
+ }
117
+ if (options.config) {
118
+ const configPath = isAbsolute(options.config) ? options.config : resolve(cwd, options.config);
119
+ const config = await loadBoundaryConfigFile(configPath);
120
+ return { config, profile: null, source: relative(root, configPath) };
121
+ }
122
+ if (typeof commandContext.options.boundaryConfig === "string") {
123
+ const config = await loadBoundaryConfig(root, commandContext.options.boundaryConfig);
124
+ return {
125
+ config,
126
+ profile: null,
127
+ source: relative(root, resolve(root, commandContext.options.boundaryConfig)),
128
+ };
129
+ }
130
+ if (commandContext.options.boundaryConfig) {
131
+ const config = policyFrom(
132
+ commandContext.options.boundaryConfig,
133
+ `${ARCHKEEP_MODEL_FILE}'s inline boundaryConfig`,
134
+ );
135
+ return { config, profile: null, source: ARCHKEEP_MODEL_FILE };
136
+ }
137
+ return { config: null, profile: null, source: null };
138
+ }