@ecoma-io/archkeep 0.14.0 → 0.15.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/commands.mjs ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The command layer's public face — what an integration imports from this
3
+ * package's `./commands` subpath to run the same commands `cli.mjs` runs,
4
+ * in-process, without spawning the CLI or parsing its text output.
5
+ *
6
+ * It holds no logic on purpose, the same bargain `./nx` (`nx.mjs`) makes: a
7
+ * named entry that is a re-export, so the command layer can grow under
8
+ * `src/commands/` without a second copy of any decision appearing beside it.
9
+ * `cli.mjs` and this file are two faces of one layer — the CLI owns argv,
10
+ * output destinations and exit codes; an importer of this face owns those for
11
+ * itself, and gets back exactly what the command functions return: statuses,
12
+ * payloads and reports, never a printed byte.
13
+ *
14
+ * Who this is for: integrations that compose the CLI's verbs programmatically
15
+ * — `packages/archkeep-mcp`, the agent capability interface, is the first. The
16
+ * engine primitives (discovery and judgment) remain the root entry
17
+ * (`index.mjs`); a caller that wants to JUDGE import records itself composes
18
+ * those, while a caller that wants the CLI's answers — a verdict, an impact
19
+ * set, an explanation — composes these. The two entries never overlap: a
20
+ * function exported here is never also exported there, so no integration can
21
+ * accidentally hold two spellings of one decision.
22
+ *
23
+ * The roster is exactly what the first consumer composes, no more — a
24
+ * function joins it when an integration calls it, never in anticipation of
25
+ * one. What is deliberately absent: the `run*` drivers and argv parsing
26
+ * (`cli.mjs` alone owns what a process's stdout and exit code mean), and the
27
+ * renderers (`src/report/` shapes bytes for humans; an importer reads the
28
+ * envelopes the commands already return).
29
+ *
30
+ * The seams the CLI threads are threaded the same way here: `readGraph` and
31
+ * `listFiles` are injectable on every entry point that reaches outside the
32
+ * process, so a caller drives the real analysis, rules and reports over a
33
+ * fixture tree with neither Nx nor git present.
34
+ */
35
+
36
+ export { resolveCommandContext, WORKSPACE_MARKERS } from "./src/commands/context.mjs";
37
+
38
+ export { resolveDescribedPolicy, resolvePolicy } from "./src/commands/policy.mjs";
39
+
40
+ export { check } from "./src/commands/check.mjs";
41
+ export { graphCommand } from "./src/commands/graph.mjs";
42
+ export { impactCommand } from "./src/commands/impact.mjs";
43
+ export { explainCommand } from "./src/commands/explain.mjs";
44
+ export { driftCommand } from "./src/commands/drift.mjs";
45
+ export { planContextCommand } from "./src/commands/plan-context-command.mjs";
46
+ export { historyCommand } from "./src/commands/history.mjs";
47
+ export { adrCommand } from "./src/commands/adr.mjs";
48
+ export { discoverCommand } from "./src/commands/discover.mjs";
49
+ export { reconcileCommand } from "./src/commands/reconcile.mjs";
50
+
51
+ export { UsageError } from "./src/errors.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Architecture enforcement for polyglot repositories — dependency graphs and module boundaries for Go, Rust, Python, TypeScript, JavaScript and Vue",
5
5
  "keywords": [
6
6
  "architecture",
@@ -28,12 +28,14 @@
28
28
  "exports": {
29
29
  ".": "./index.mjs",
30
30
  "./nx": "./nx.mjs",
31
+ "./commands": "./commands.mjs",
31
32
  "./presets/*.json": "./presets/*.json",
32
33
  "./package.json": "./package.json"
33
34
  },
34
35
  "files": [
35
36
  "index.mjs",
36
37
  "nx.mjs",
38
+ "commands.mjs",
37
39
  "cli.mjs",
38
40
  "lsp.mjs",
39
41
  "src/",
@@ -83,6 +83,7 @@ import { isBuiltin } from "node:module";
83
83
  import ts from "typescript";
84
84
 
85
85
  import { DEFAULT_OPTIONS } from "../options.mjs";
86
+ import { stripTrailingSlashes } from "../path-util.mjs";
86
87
  import { normalizePath } from "./manifest-util.mjs";
87
88
  import { emptyResult, fileFailure, perWorkspace, projectOwning } from "./source-util.mjs";
88
89
 
@@ -378,7 +379,7 @@ function scriptKindFor(sourceFile, lang) {
378
379
  * translation to the workspace-relative reader happens here and once.
379
380
  */
380
381
  function resolutionHostFor(workspace) {
381
- const root = workspace.root.replace(/\/+$/, "");
382
+ const root = stripTrailingSlashes(workspace.root);
382
383
  const prefix = `${root}/`;
383
384
  const contents = new Map();
384
385
  const read = (absolute) => {
@@ -56,10 +56,32 @@ the resolution order.
56
56
  incomplete head coverage, and an Nx workspace with polyglot manifests but no
57
57
  plugin registration; a policy-fingerprint change is a loud coverage note, not
58
58
  a refusal. A verdict, not a description: a non-waived introduced violation is
59
- a finding (exit 1 — the third verb beside `check` and `fitness`), an
59
+ a finding (exit 1 — then the third verb beside `check` and `fitness`; `change`
60
+ and its declared-intent question arrived later as the fourth), an
60
61
  unclassifiable item is a no-verdict (exit 3), and a waived-introduced entry
61
62
  is reported without gating. Capture stays descriptive: never exits 1.
62
63
 
64
+ - **`change`** (`./change.mjs`'s `changeCommand`, with the manifest grammar in
65
+ `./change-intent.mjs`) — a declared change-intent contract reconciled
66
+ against the actual architectural delta: did the change produce exactly the
67
+ material consequences its declaration named? The material delta is
68
+ `./diff.mjs`'s own `computeDiff` over the baseline evidence snapshot's
69
+ stored graph and this run's graph — never a second opinion about which edits
70
+ count; declared constraints re-judge BOTH sides under ONE current law and
71
+ instant through the same engine `./delta.mjs` uses (`../rules/index.mjs`,
72
+ `./delta-classify.mjs`, and `../governance/fitness-rules.mjs`'s
73
+ `cyclicProjects`). Verdicts `matched | undeclared | unfulfilled | unproven`
74
+ stay separate: an undeclared consequence is a review signal, not a law
75
+ verdict, and the workspace-law axis it reports is informational — computed,
76
+ labeled as evidence, never folded into the exit code, because `check`
77
+ remains the authority on the law. Undeclared, unfulfilled, or a failed
78
+ declared constraint is a finding (exit 1 — the fourth verb beside `check`,
79
+ `fitness`, `delta`); an unproven base identity or an undeterminable
80
+ constraint is exit 3, and constraints are left unevaluated over a base the
81
+ run cannot vouch for. Refuses a manifest that fails shape or reference
82
+ validation, an unreadable/malformed/incomplete baseline, a provider
83
+ mismatch, incomplete head coverage, and the unregistered-plugin graph.
84
+
63
85
  - **`impact`** (`./impact.mjs`'s `impactCommand`) — reverse reachability from
64
86
  the project graph: given a project name, lists every project that transitively
65
87
  depends on it. Separates direct from transitive dependents. When a boundary
@@ -114,6 +136,35 @@ the resolution order.
114
136
  head coverage. Refuses an empty or unreadable directory, and a snapshot that
115
137
  parses as an incomplete envelope. Descriptive: never exits 1.
116
138
 
139
+ - **`trajectory`** (`./trajectory.mjs`'s `trajectoryCommand`) — the aggregate
140
+ over the same directory `history` reads: signal counts, structural churn
141
+ and persistence, derived through `./history.mjs`'s own `readSnapshots` and
142
+ `classifyTransition` so the two commands cannot disagree about a single
143
+ classification. One observation is one stored graph snapshot — never a
144
+ commit or a day. Identities are `./diff.mjs`'s (`edgeIdentityKey`); no
145
+ violation-level trajectory exists because stored snapshots carry no
146
+ findings. A one-snapshot directory answers `insufficient_history` with null
147
+ derived fields, never zeros; an empty or malformed one refuses (exit 3).
148
+ No boundary law is loaded and nothing is captured here — the fingerprints
149
+ compared travel inside the snapshots, and exactly one command writes them.
150
+ Descriptive: never exits 1.
151
+
152
+ - **`evolution`** (`./evolution.mjs`'s `evolutionCommand`) — the same
153
+ evolution record across a selected range of Git revisions instead of a
154
+ snapshot directory. Git answers only which trees to read: both endpoints are
155
+ resolved with `rev-parse`, base must be an ancestor of head, every commit in
156
+ `base..head` must be single-parent (a merge refuses loudly rather than being
157
+ flattened), and each selected commit is materialized into a temporary
158
+ detached worktree that is released before the run reports — the caller's
159
+ working tree is never touched. Each materialized tree runs through the
160
+ ordinary context and policy ladders, and the transitions are classified by
161
+ `./history.mjs`'s own `computeEvolution`, so the two commands cannot
162
+ disagree about what a transition means. A change is attributed to the first
163
+ analyzed revision where it is observable — where history shows it, never why
164
+ it was made. Refuses every unusable selection and every revision that cannot
165
+ be fully analyzed, so no failure can read as a shorter history. Descriptive:
166
+ never exits 1.
167
+
117
168
  - **`provenance`** (`./provenance-command.mjs`'s `provenanceCommand`) — where
118
169
  this run's facts came from and which governance rows carry an origin.
119
170
  Two surfaces: repository provenance (the git commit, remote and dirty state
@@ -0,0 +1,461 @@
1
+ /**
2
+ * The change-intent contract — grammar, validation, and loading.
3
+ *
4
+ * A change intent is a PER-CHANGE declaration of the material architectural
5
+ * consequences its author expects one specific edit to produce: which projects
6
+ * appear or disappear, which project-to-project dependencies appear or
7
+ * disappear, and which delta-level constraints the change must hold. It is
8
+ * written beside the work, verified by the `change` command against the actual
9
+ * architectural delta (`./change.mjs`), and then belongs to the pull request
10
+ * as the reviewable answer to "what did this change do to the architecture?".
11
+ *
12
+ * This file mirrors `../architecture-intent/model.mjs`'s split — a pure
13
+ * `(raw) -> string[]` validator, a thin loader that reads, parses, validates
14
+ * and throws one Error naming every violation at once — and deliberately does
15
+ * NOT extend that module: architecture-intent.json is the workspace's
16
+ * long-lived law, judged by `drift`/`check`; a change intent names one
17
+ * transaction's expected consequences and expires with its pull request. One
18
+ * grammar per concept, so neither can grow the other's semantics by accident.
19
+ *
20
+ * Four contracts, each the honest side of the empty-result invariant
21
+ * (`../../../../AGENTS.md` — an empty list must mean "nothing unexpected",
22
+ * and nothing else):
23
+ *
24
+ * - **It is strict JSON, never JSONC**, for the same reason
25
+ * `../architecture-intent/model.mjs` refuses comments: this is a
26
+ * machine-written declaration a verification run consumes, and a comment
27
+ * is a load error, not leniency.
28
+ * - **An unknown key is rejected by name**, never ignored — a typo'd
29
+ * section would silently declare nothing, and a run over a declaration
30
+ * that said nothing must not read as "the change declared nothing
31
+ * material".
32
+ * - **A duplicate declaration is a load error**, not last-one-wins: the
33
+ * contract is a SET of expected facts, and two rows under one fact would
34
+ * make the reconciliation depend on array order.
35
+ * - **Shape is nodes-free.** Whether a declared project reference can exist
36
+ * is a question about the captured baseline, so it belongs to the
37
+ * command (`./change.mjs`'s reference check), not here. A manifest must
38
+ * load and be told its references are wrong — loudly — rather than fail
39
+ * to parse at all.
40
+ */
41
+
42
+ import { readFile as readFileFromDisk } from "node:fs/promises";
43
+
44
+ /** The only `version` this module accepts. A different value is a load error. */
45
+ export const CHANGE_INTENT_VERSION = "1";
46
+
47
+ /** The only keys a valid change-intent file may carry at the top level. */
48
+ export const CHANGE_INTENT_TOP_LEVEL_KEYS = Object.freeze([
49
+ "version",
50
+ "base",
51
+ "summary",
52
+ "projects",
53
+ "edges",
54
+ "constraints",
55
+ ]);
56
+
57
+ /** The keys the `base` section may carry. */
58
+ export const CHANGE_INTENT_BASE_KEYS = Object.freeze(["commit"]);
59
+
60
+ /** The sub-keys the `projects` section may carry. */
61
+ export const CHANGE_INTENT_PROJECT_SECTION_KEYS = Object.freeze(["add", "remove"]);
62
+ /** The sub-keys the `edges` section may carry. */
63
+ export const CHANGE_INTENT_EDGE_SECTION_KEYS = Object.freeze(["add", "remove"]);
64
+ /** The keys an edge row may carry. */
65
+ export const CHANGE_INTENT_EDGE_ROW_KEYS = Object.freeze(["from", "to"]);
66
+ /** The keys the `constraints` section may carry. */
67
+ export const CHANGE_INTENT_CONSTRAINT_KEYS = Object.freeze(["noNewViolations", "noNewCycles"]);
68
+
69
+ /**
70
+ * The constraints the reconciliation judges, in the fixed order their verdict
71
+ * rows are emitted — the order itself is part of the deterministic output
72
+ * contract (`docs/reference/json-output.md`), so a manifest that declares them
73
+ * in either order gets the same row order.
74
+ */
75
+ export const CONSTRAINT_ORDER = Object.freeze(["noNewViolations", "noNewCycles"]);
76
+
77
+ /**
78
+ * The manifest key a constraint row answers to, keyed by the row name the
79
+ * report and JSON use. One copy, so a renamed key cannot desynchronize the
80
+ * loader's accepted set from the judge's row names.
81
+ */
82
+ export const CONSTRAINT_ROW_NAMES = Object.freeze({
83
+ noNewViolations: "no-new-violations",
84
+ noNewCycles: "no-new-cycles",
85
+ });
86
+
87
+ /** A value's type, for an error message that shows what was actually there. */
88
+ function describe(value) {
89
+ if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
90
+ if (value === null) return "null";
91
+ return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
92
+ }
93
+
94
+ /** @type {(value: unknown) => value is Record<string, unknown>} */
95
+ const isPlainObject = (value) =>
96
+ value !== null && typeof value === "object" && !Array.isArray(value);
97
+
98
+ /** `key` on `obj` that is not one of `allowed` — the reject-by-name rule. */
99
+ function unknownKeys(obj, allowed) {
100
+ return Object.keys(obj).filter((key) => !allowed.includes(key));
101
+ }
102
+
103
+ /** Non-empty-string guard with its message fragment. */
104
+ function nonEmptyString(value) {
105
+ return typeof value === "string" && value.length > 0;
106
+ }
107
+
108
+ /**
109
+ * Everything wrong with the `projects`/`edges` sections' shared list shape —
110
+ * present-or-absent wholesale, an array when present, strings (for projects)
111
+ * or `{from, to}` rows (for edges) inside, no duplicates within a list, no
112
+ * member of `add` also in `remove`. Written once for both sections because a
113
+ * second copy of the duplicate rule is how the two sections drift into
114
+ * answering "is this a set?" differently.
115
+ *
116
+ * @param {unknown} section The raw section value.
117
+ * @param {{name: string, keys: readonly string[], row: (row: unknown, at: string) => string[],
118
+ * identity: (row: unknown) => string|null}} spec
119
+ * `name` is the dotted path prefix, `keys` the allowed sub-keys, `row` the
120
+ * per-element validator, `identity` the element's dedup key (or `null` when
121
+ * the element is malformed and already reported).
122
+ * @returns {string[]}
123
+ */
124
+ function sectionListViolations(section, spec) {
125
+ const problems = [];
126
+ if (section === undefined) return problems;
127
+ if (!isPlainObject(section)) {
128
+ problems.push(`${spec.name}: must be an object when present, got ${describe(section)}`);
129
+ return problems;
130
+ }
131
+ for (const key of unknownKeys(section, spec.keys)) {
132
+ problems.push(
133
+ `${spec.name}.${key}: unknown key — ${spec.name} may carry only ${spec.keys.join(", ")}`,
134
+ );
135
+ }
136
+ /** @type {Map<string, number>} */
137
+ const seen = new Map();
138
+ for (const kind of spec.keys) {
139
+ const list = section[kind];
140
+ if (list === undefined) continue;
141
+ if (!Array.isArray(list)) {
142
+ problems.push(`${spec.name}.${kind}: must be an array when present, got ${describe(list)}`);
143
+ continue;
144
+ }
145
+ list.forEach((entry, index) => {
146
+ const at = `${spec.name}.${kind}[${index}]`;
147
+ const rowProblems = spec.row(entry, at);
148
+ if (rowProblems.length > 0) {
149
+ problems.push(...rowProblems);
150
+ return;
151
+ }
152
+ const id = /** @type {string} */ (spec.identity(entry));
153
+ const first = seen.get(id);
154
+ if (first !== undefined) {
155
+ problems.push(
156
+ `${at}: duplicates ${spec.name}.${kind}[${first}] (${id}) — the contract is a set ` +
157
+ `of expected facts, and a repeated declaration would make reconciliation depend ` +
158
+ `on array order`,
159
+ );
160
+ } else {
161
+ seen.set(id, index);
162
+ }
163
+ });
164
+ }
165
+ // An `add` and a `remove` naming the same fact cancel to nothing while
166
+ // reading as a declaration — refuse the pair rather than reconcile a
167
+ // contradiction.
168
+ if (Array.isArray(section.add) && Array.isArray(section.remove)) {
169
+ const removeIds = new Set(section.remove.map(spec.identity));
170
+ section.add.forEach((entry, index) => {
171
+ const id = spec.identity(entry);
172
+ if (id !== null && removeIds.has(id)) {
173
+ problems.push(
174
+ `${spec.name}.add[${index}]: "${id}" is also declared in ${spec.name}.remove — ` +
175
+ `a fact cannot be both expected to appear and expected to disappear`,
176
+ );
177
+ }
178
+ });
179
+ }
180
+ return problems;
181
+ }
182
+
183
+ /**
184
+ * Everything wrong with a raw change-intent file, as messages; empty when it
185
+ * is well-formed. Pure — no baseline, no graph, no clock: whether a declared
186
+ * reference CAN exist is `findChangeIntentReferenceViolations`' question,
187
+ * below.
188
+ *
189
+ * @param {unknown} raw The parsed JSON value.
190
+ * @returns {string[]}
191
+ */
192
+ export function findChangeIntentViolations(raw) {
193
+ const violations = [];
194
+ if (!isPlainObject(raw)) {
195
+ return [`top level: must be an object, got ${describe(raw)}`];
196
+ }
197
+ for (const key of unknownKeys(raw, CHANGE_INTENT_TOP_LEVEL_KEYS)) {
198
+ violations.push(
199
+ `unknown key "${key}" — a change intent may carry only ` +
200
+ `${CHANGE_INTENT_TOP_LEVEL_KEYS.join(", ")}`,
201
+ );
202
+ }
203
+
204
+ if (raw.version !== CHANGE_INTENT_VERSION) {
205
+ violations.push(
206
+ `version: must be exactly "${CHANGE_INTENT_VERSION}", got ${describe(raw.version)}`,
207
+ );
208
+ }
209
+
210
+ // The base pin is the whole proof that reconciliation compares the same two
211
+ // architectures the author saw: without a commit there is nothing to verify
212
+ // the baseline against, and the run must answer unproven rather than guess
213
+ // (`./change.mjs`). So the section itself is required, not optional.
214
+ if (!isPlainObject(raw.base)) {
215
+ violations.push(`base: is required and must be an object, got ${describe(raw.base)}`);
216
+ } else {
217
+ for (const key of unknownKeys(raw.base, CHANGE_INTENT_BASE_KEYS)) {
218
+ violations.push(
219
+ `base.${key}: unknown key — base may carry only ${CHANGE_INTENT_BASE_KEYS.join(", ")}`,
220
+ );
221
+ }
222
+ if (!nonEmptyString(raw.base.commit)) {
223
+ violations.push(
224
+ "base.commit: is required and must be a non-empty string — the git commit the " +
225
+ "baseline snapshot was captured at (`archkeep delta --capture` records it in the " +
226
+ "snapshot's provenance)",
227
+ );
228
+ }
229
+ }
230
+
231
+ if (raw.summary !== undefined && !nonEmptyString(raw.summary)) {
232
+ violations.push(
233
+ `summary: must be a non-empty string when present, got ${describe(raw.summary)} — it is ` +
234
+ "informational only and is never read by the reconciliation",
235
+ );
236
+ }
237
+
238
+ violations.push(
239
+ ...sectionListViolations(raw.projects, {
240
+ name: "projects",
241
+ keys: CHANGE_INTENT_PROJECT_SECTION_KEYS,
242
+ row: (entry, at) =>
243
+ nonEmptyString(entry)
244
+ ? []
245
+ : [`${at}: must be a non-empty project name, got ${describe(entry)}`],
246
+ identity: (entry) => (typeof entry === "string" ? entry : ""),
247
+ }),
248
+ );
249
+
250
+ violations.push(
251
+ ...sectionListViolations(raw.edges, {
252
+ name: "edges",
253
+ keys: CHANGE_INTENT_EDGE_SECTION_KEYS,
254
+ row: (entry, at) => {
255
+ if (!isPlainObject(entry)) {
256
+ return [`${at}: must be an object with "from" and "to", got ${describe(entry)}`];
257
+ }
258
+ const problems = [];
259
+ for (const key of unknownKeys(entry, CHANGE_INTENT_EDGE_ROW_KEYS)) {
260
+ problems.push(
261
+ `${at}.${key}: unknown key — an edge row may carry only ` +
262
+ `${CHANGE_INTENT_EDGE_ROW_KEYS.join(", ")}`,
263
+ );
264
+ }
265
+ for (const side of ["from", "to"]) {
266
+ if (!nonEmptyString(entry[side])) {
267
+ problems.push(
268
+ `${at}.${side}: must be a non-empty project name, got ${describe(entry[side])}`,
269
+ );
270
+ }
271
+ }
272
+ if (nonEmptyString(entry.from) && entry.from === entry.to) {
273
+ problems.push(
274
+ `${at}: "from" and "to" are both "${entry.from}" — the project graph strips ` +
275
+ `self-edges, so a self-dependency can never be observed and the declaration ` +
276
+ `could never match`,
277
+ );
278
+ }
279
+ return problems;
280
+ },
281
+ identity: (entry) =>
282
+ isPlainObject(entry) && nonEmptyString(entry.from) && nonEmptyString(entry.to)
283
+ ? `${entry.from}\u0000${entry.to}`
284
+ : "",
285
+ }),
286
+ );
287
+
288
+ if (raw.constraints !== undefined) {
289
+ if (!isPlainObject(raw.constraints)) {
290
+ violations.push(
291
+ `constraints: must be an object when present, got ${describe(raw.constraints)}`,
292
+ );
293
+ } else {
294
+ for (const key of unknownKeys(raw.constraints, CHANGE_INTENT_CONSTRAINT_KEYS)) {
295
+ violations.push(
296
+ `constraints.${key}: unknown key — constraints may carry only ` +
297
+ `${CHANGE_INTENT_CONSTRAINT_KEYS.join(", ")}`,
298
+ );
299
+ }
300
+ for (const key of CHANGE_INTENT_CONSTRAINT_KEYS) {
301
+ const value = raw.constraints[key];
302
+ if (value === undefined) continue;
303
+ if (value !== true) {
304
+ violations.push(
305
+ `constraints.${key}: must be exactly true when present, got ${describe(value)} — ` +
306
+ "a constraint this run should not judge is declared by omitting the key, not by " +
307
+ "writing false",
308
+ );
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ return violations;
315
+ }
316
+
317
+ /**
318
+ * Parses and validates change-intent text into the normalized shape the
319
+ * `change` command reconciles against. Pure: text in, validated contract out.
320
+ *
321
+ * Absent sections normalize to empty expectations — a manifest with no
322
+ * `projects` section expects no project to appear or disappear, which is a
323
+ * REAL expectation the reconciliation enforces, never an absence of one.
324
+ *
325
+ * @param {string} text The file contents.
326
+ * @param {string} path The path the text came from, for error messages.
327
+ * @returns {{
328
+ * version: string,
329
+ * base: {commit: string},
330
+ * summary?: string,
331
+ * projects: {add: string[], remove: string[]},
332
+ * edges: {add: {from: string, to: string}[], remove: {from: string, to: string}[]},
333
+ * constraints: {noNewViolations?: true, noNewCycles?: true},
334
+ * }}
335
+ * @throws {Error} naming every violation at once — these become exit-3-class
336
+ * input errors upstream, and a run that consumed a malformed declaration
337
+ * silently would reconcile against an expectation nobody wrote.
338
+ */
339
+ export function parseChangeIntent(text, path) {
340
+ let parsed;
341
+ try {
342
+ parsed = JSON.parse(text);
343
+ } catch (cause) {
344
+ throw new Error(
345
+ `archkeep: the change intent '${path}' is not valid JSON: ${cause?.message ?? cause}`,
346
+ { cause },
347
+ );
348
+ }
349
+ const violations = findChangeIntentViolations(parsed);
350
+ if (violations.length > 0) {
351
+ throw new Error(
352
+ `archkeep: the change intent '${path}' is not a usable contract:\n ` +
353
+ violations.join("\n "),
354
+ );
355
+ }
356
+ return {
357
+ version: parsed.version,
358
+ base: { commit: parsed.base.commit },
359
+ ...(parsed.summary === undefined ? {} : { summary: parsed.summary }),
360
+ projects: {
361
+ add: parsed.projects?.add ?? [],
362
+ remove: parsed.projects?.remove ?? [],
363
+ },
364
+ edges: {
365
+ add: (parsed.edges?.add ?? []).map(({ from, to }) => ({ from, to })),
366
+ remove: (parsed.edges?.remove ?? []).map(({ from, to }) => ({ from, to })),
367
+ },
368
+ constraints: { ...(parsed.constraints ?? {}) },
369
+ };
370
+ }
371
+
372
+ /**
373
+ * Reads the change-intent file from a path — the module's one filesystem seam,
374
+ * injectable so tests and embedders drive validation without disk.
375
+ *
376
+ * @param {string} path Absolute path to the manifest.
377
+ * @param {{read?: (path: string) => Promise<string>}} [io]
378
+ * @returns {Promise<object>} Whatever `parseChangeIntent` returns.
379
+ * @throws {Error} when the file cannot be read, and whatever
380
+ * `parseChangeIntent` throws.
381
+ */
382
+ export async function readChangeIntent(path, io = {}) {
383
+ const read = io.read ?? ((p) => readFileFromDisk(p, "utf8"));
384
+ let text;
385
+ try {
386
+ text = await read(path);
387
+ } catch (cause) {
388
+ throw new Error(
389
+ `archkeep: cannot read the change intent '${path}': ${cause?.message ?? cause}`,
390
+ { cause },
391
+ );
392
+ }
393
+ return parseChangeIntent(text, path);
394
+ }
395
+
396
+ /**
397
+ * Everything wrong with a loaded manifest's REFERENCES — whether each declared
398
+ * fact could exist in a tree descended from the captured baseline. Pure: the
399
+ * baseline's project-name set arrives as an argument.
400
+ *
401
+ * These are load errors too (the command throws on the first list), because a
402
+ * declaration that references a project that exists nowhere would sit in
403
+ * `missingExpected` forever, reading as "the change forgot something" when
404
+ * the truth is "the declaration cannot be satisfied by ANY tree".
405
+ *
406
+ * @param {object} intent A `parseChangeIntent` result.
407
+ * @param {Set<string>} baselineProjects Every project name in the captured
408
+ * baseline's graph.
409
+ * @returns {string[]} One entry per problem, empty when every reference lands.
410
+ */
411
+ export function findChangeIntentReferenceViolations(intent, baselineProjects) {
412
+ const violations = [];
413
+ const known = new Set([...baselineProjects, ...intent.projects.add]);
414
+ for (const name of intent.projects.add) {
415
+ if (baselineProjects.has(name)) {
416
+ violations.push(
417
+ `projects.add: "${name}" already exists at the captured base — a project the base ` +
418
+ `graph already has cannot be declared as appearing`,
419
+ );
420
+ }
421
+ }
422
+ for (const name of intent.projects.remove) {
423
+ if (!baselineProjects.has(name)) {
424
+ violations.push(
425
+ `projects.remove: "${name}" does not exist at the captured base — there is no such ` +
426
+ `project to expect gone`,
427
+ );
428
+ }
429
+ }
430
+ for (const [kind, list] of [
431
+ ["add", intent.edges.add],
432
+ ["remove", intent.edges.remove],
433
+ ]) {
434
+ for (const { from, to } of list) {
435
+ for (const side of ["from", "to"]) {
436
+ const name = side === "from" ? from : to;
437
+ if (kind === "add") {
438
+ if (intent.projects.remove.includes(name)) {
439
+ violations.push(
440
+ `edges.${kind}: ${from} -> ${to} names "${name}" in projects.remove — an edge ` +
441
+ `cannot appear attached to a project declared to disappear`,
442
+ );
443
+ } else if (!known.has(name)) {
444
+ violations.push(
445
+ `edges.${kind}: ${from} -> ${to} names "${name}", which is neither in the ` +
446
+ `baseline graph nor declared in projects.add — the reference can never match ` +
447
+ `anything`,
448
+ );
449
+ }
450
+ } else if (!baselineProjects.has(name)) {
451
+ violations.push(
452
+ `edges.${kind}: ${from} -> ${to} names "${name}", which is not in the baseline ` +
453
+ `graph — an edge of a project the base does not have cannot be expected to ` +
454
+ `disappear${intent.projects.add.includes(name) ? " (it is declared in projects.add, so it had no base edges)" : ""}`,
455
+ );
456
+ }
457
+ }
458
+ }
459
+ }
460
+ return violations;
461
+ }