@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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- 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
|
+
}
|