@ecoma-io/archkeep 0.14.0 → 0.16.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 (68) hide show
  1. package/README.md +11 -5
  2. package/cli.mjs +571 -61
  3. package/commands.mjs +57 -0
  4. package/lsp.mjs +15 -2
  5. package/package.json +8 -2
  6. package/src/analysis/analyze.mjs +15 -0
  7. package/src/analysis/contract.md +36 -18
  8. package/src/analysis/csharp.mjs +485 -0
  9. package/src/analysis/dotnet/csproj.mjs +380 -0
  10. package/src/analysis/dotnet/mask.mjs +178 -0
  11. package/src/analysis/dotnet/namespaces.mjs +172 -0
  12. package/src/analysis/dotnet/resolve.mjs +89 -0
  13. package/src/analysis/go.mjs +289 -5
  14. package/src/analysis/java.mjs +329 -0
  15. package/src/analysis/jvm/gradle.mjs +545 -0
  16. package/src/analysis/jvm/mask.mjs +170 -0
  17. package/src/analysis/jvm/maven.mjs +612 -0
  18. package/src/analysis/jvm/packages.mjs +209 -0
  19. package/src/analysis/jvm/resolve.mjs +139 -0
  20. package/src/analysis/kotlin.mjs +210 -0
  21. package/src/analysis/manifest-util.mjs +30 -0
  22. package/src/analysis/python.mjs +3 -2
  23. package/src/analysis/registry.mjs +11 -0
  24. package/src/analysis/rust.mjs +171 -17
  25. package/src/analysis/source-util.mjs +155 -6
  26. package/src/analysis/typescript.mjs +11 -3
  27. package/src/commands/README.md +52 -1
  28. package/src/commands/change-intent.mjs +461 -0
  29. package/src/commands/change.mjs +612 -0
  30. package/src/commands/check.mjs +2 -1
  31. package/src/commands/context.mjs +124 -16
  32. package/src/commands/custom-rules.mjs +286 -2
  33. package/src/commands/delta-classify.mjs +195 -33
  34. package/src/commands/delta-snapshot.mjs +156 -1
  35. package/src/commands/delta.mjs +142 -17
  36. package/src/commands/diff.mjs +41 -13
  37. package/src/commands/evolution.mjs +473 -0
  38. package/src/commands/history.mjs +130 -103
  39. package/src/commands/policy.mjs +57 -0
  40. package/src/commands/provenance.mjs +7 -44
  41. package/src/commands/rules.mjs +775 -0
  42. package/src/commands/trajectory.mjs +437 -0
  43. package/src/governance/profile-registry.mjs +0 -1
  44. package/src/graph/create-dependencies.mjs +138 -15
  45. package/src/lsp/diagnose.mjs +1 -1
  46. package/src/lsp/server.mjs +97 -1
  47. package/src/lsp/workspace-index.mjs +106 -15
  48. package/src/options.mjs +30 -7
  49. package/src/path-util.mjs +40 -0
  50. package/src/process.mjs +10 -1
  51. package/src/providers/moon.mjs +287 -36
  52. package/src/providers/native/differential.fixtures.mjs +32 -6
  53. package/src/providers/native/discover.mjs +83 -4
  54. package/src/providers/native/graph.mjs +58 -0
  55. package/src/providers/native/model.mjs +59 -1
  56. package/src/report/change-text.mjs +148 -0
  57. package/src/report/delta-text.mjs +82 -1
  58. package/src/report/evolution-text.mjs +83 -0
  59. package/src/report/history-text.mjs +4 -114
  60. package/src/report/sarif.mjs +255 -0
  61. package/src/report/snapshot-text.mjs +123 -0
  62. package/src/report/trajectory-text.mjs +143 -0
  63. package/src/rules/index.mjs +21 -6
  64. package/src/rules/reachability.mjs +2 -0
  65. package/src/rules/tags.mjs +7 -5
  66. package/src/rules/topology.mjs +5 -3
  67. package/src/tsconfig-paths.mjs +3 -2
  68. package/src/workspace.mjs +115 -23
@@ -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
+ }