@ecoma-io/archkeep 0.27.0 → 0.28.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 (40) hide show
  1. package/README.md +5 -3
  2. package/package.json +2 -1
  3. package/src/analysis/jvm/gradle.mjs +23 -2
  4. package/src/commands/README.md +7 -4
  5. package/src/commands/change.mjs +3 -2
  6. package/src/commands/delta-snapshot.mjs +17 -0
  7. package/src/commands/delta.mjs +4 -2
  8. package/src/commands/scenario-evaluation.mjs +97 -3
  9. package/src/corpus/goldens/adr.json +1 -1
  10. package/src/corpus/goldens/change.json +2 -2
  11. package/src/corpus/goldens/check.json +1 -1
  12. package/src/corpus/goldens/context.json +1 -1
  13. package/src/corpus/goldens/debt.json +1 -1
  14. package/src/corpus/goldens/decisions.json +1 -1
  15. package/src/corpus/goldens/delta.json +2 -2
  16. package/src/corpus/goldens/diff.json +2 -2
  17. package/src/corpus/goldens/discover.json +1 -1
  18. package/src/corpus/goldens/drift.json +1 -1
  19. package/src/corpus/goldens/evolution.json +1 -1
  20. package/src/corpus/goldens/explain.json +1 -1
  21. package/src/corpus/goldens/fitness.json +1 -1
  22. package/src/corpus/goldens/graph.json +1 -1
  23. package/src/corpus/goldens/health.json +1 -1
  24. package/src/corpus/goldens/history.json +1 -1
  25. package/src/corpus/goldens/impact.json +1 -1
  26. package/src/corpus/goldens/provenance.json +1 -1
  27. package/src/corpus/goldens/reconcile.json +1 -1
  28. package/src/corpus/goldens/report.json +1 -1
  29. package/src/corpus/goldens/scenario.json +1 -1
  30. package/src/corpus/goldens/trajectory.json +1 -1
  31. package/src/corpus/goldens/waivers.json +1 -1
  32. package/src/governance/provenance-graph.mjs +13 -9
  33. package/src/governance/provenance-record.mjs +22 -8
  34. package/src/governance/reconcile-score.mjs +59 -9
  35. package/src/graph/create-dependencies.mjs +39 -6
  36. package/src/intent/intent-manifest.json +3 -3
  37. package/src/providers/nx-static.mjs +24 -3
  38. package/src/providers/nx.mjs +98 -2
  39. package/src/report/text.mjs +1 -1
  40. package/src/fixtures/evolution-lifecycle/workspace.mjs +0 -248
@@ -147,8 +147,9 @@ export function validateOrigin(raw, io = {}, at = "origin") {
147
147
  * clock that supplies `on`. `clock` is required — an `on` produced without a
148
148
  * clock is the non-determinism this module exists to exclude, so the absence
149
149
  * is a loud Error, never a default.
150
- * @returns {OriginRecord} `{by, tool, on: clock.now()}`, and ONLY those three
151
- * keys — a fresh object, so nothing from untrusted input rides along.
150
+ * @returns {OriginRecord} `{by, tool, on}`, where `on` is the clock's one
151
+ * sampled answer, and ONLY those three keys — a fresh object, so nothing from
152
+ * untrusted input rides along.
152
153
  * @throws {Error} on an invalid author, an unusable clock, or a
153
154
  * non-string/empty clock answer.
154
155
  */
@@ -158,11 +159,24 @@ export function recordOrigin({ by, tool, clock }) {
158
159
  if (shape.length > 0) {
159
160
  throw new Error(shape.join("; "));
160
161
  }
161
- const clockProblems = clockViolations(clock);
162
- if (clockProblems.length > 0) {
163
- throw new Error(`origin.on: ${clockProblems.join("; ")}`);
162
+ // The clock is read exactly once and the read is what ships: sample it, judge
163
+ // the sample, emit it. `clockViolations` cannot render this verdict — it
164
+ // samples the clock itself, so delegating to it here would read the clock a
165
+ // second time, and that second read let a stateful clock answer validation
166
+ // with one instant and the record with another. The checks below restate its
167
+ // messages so a misused clock is still named in the shared vocabulary,
168
+ // read-free until the one sample exists.
169
+ if (clock === null || typeof clock !== "object") {
170
+ throw new Error(
171
+ `origin.on: clock must be an object with a now() function, got ${describe(clock)}`,
172
+ );
173
+ }
174
+ if (typeof clock.now !== "function") {
175
+ throw new Error("origin.on: clock.now must be a function returning a non-empty string");
176
+ }
177
+ const on = clock.now();
178
+ if (typeof on !== "string" || on.length === 0) {
179
+ throw new Error("origin.on: clock.now() must return a non-empty string");
164
180
  }
165
- // The clock is the single door, and it is called exactly once for this
166
- // record, so two calls with the same clock are byte-identical.
167
- return { by, tool, on: clock.now() };
181
+ return { by, tool, on };
168
182
  }
@@ -268,8 +268,13 @@ export function scoreEdge(edge, keys, intentForbiddenPairs, tagForbiddenPairs) {
268
268
  * Boundary `allowed`/`forbidden` rows are scored from the canonical judge's
269
269
  * findings (matched exactly by `from`/`to`): a `forbidden` row with an
270
270
  * `intentForbiddenEdge` finding is `unexpected`, an `allowed` row with an
271
- * `intentAllowedMissing` finding is `absent`. Project and dependency rows are
272
- * scored directly against the observed names and edges. A
271
+ * `intentAllowedMissing` finding is `absent`. Project rows are scored against
272
+ * the observed names, and the two forbidden drift planes are scored from the
273
+ * judge's findings as well — `dependencies.forbidden` through its
274
+ * `dependencyForbidden` findings, `forbiddenTags` through its
275
+ * `tagDependencyForbidden` witnesses attributed via `tagsByProject` — so a
276
+ * row's verdict IS the verdict `check` and `drift` render, including the
277
+ * transitive closure a direct-edge re-derivation would read as "match". A
273
278
  * `dependencies.allowed` row is an allowlist entry, not an existence claim —
274
279
  * its absence in the graph is not divergence, so it scores `match` either
275
280
  * way (the divergent direction is the observed edge outside the list, scored
@@ -285,7 +290,29 @@ export function scoreIntentRows(intent, judgeVerdict, observed, tagsByProject) {
285
290
  // used by its own test
286
291
  const rows = [];
287
292
  const observedNames = new Set(observed.projects.map((p) => p.name));
288
- const observedEdgeKeys = new Set(observed.edges.map((e) => `${e.source} ${e.target}`));
293
+ // The judge has already judged every forbidden plane on the any-path
294
+ // closure (`../architecture-intent/judge.mjs` emits `dependencyForbidden`
295
+ // and `tagDependencyForbidden` for direct AND transitive paths, as concrete
296
+ // `source`/`target` project names with `boundaryFrom: null`). These two
297
+ // projections score the drift rows from those findings — projecting the
298
+ // canonical verdict, never re-deriving reachability here: a re-walk of the
299
+ // direct-edge list would score a transitive violation the judge reported
300
+ // as a row "match", the divergence `check` and `drift` do report. The
301
+ // judge's pairless findings (`intentUnknownProject`, `intentUnknownTag`)
302
+ // carry no witness pair; the rows they belong to are scored below from the
303
+ // observed names and tags themselves — the same existence predicate the
304
+ // judge used to emit them: a row whose endpoint can never resolve is
305
+ // unknown/unverifiable, never a silent "match".
306
+ const dependencyForbiddenPairs = new Set();
307
+ const tagForbiddenWitnesses = [];
308
+ for (const finding of judgeVerdict.findings) {
309
+ if (finding.source === null || finding.target === null) continue;
310
+ if (finding.rule === "dependencyForbidden") {
311
+ dependencyForbiddenPairs.add(`${finding.source} → ${finding.target}`);
312
+ } else if (finding.rule === "tagDependencyForbidden") {
313
+ tagForbiddenWitnesses.push([finding.source, finding.target]);
314
+ }
315
+ }
289
316
 
290
317
  const boundaryFinding = new Map();
291
318
  for (const finding of judgeVerdict.findings) {
@@ -341,22 +368,45 @@ export function scoreIntentRows(intent, judgeVerdict, observed, tagsByProject) {
341
368
  }
342
369
  for (const forbidden of dependencies.forbidden ?? []) {
343
370
  const key = `${forbidden.source} → ${forbidden.target}`;
371
+ // A row naming a project the observed architecture does not have can
372
+ // never fire — the judge reports it as `intentUnknownProject` with no
373
+ // witness pair, so the projection above stays empty for it. Reading
374
+ // that as "match — the ban holds" would be the silent direction; score
375
+ // it unknown, mirroring the boundary plane (`reconcileScores`).
376
+ if (!observedNames.has(forbidden.source) || !observedNames.has(forbidden.target)) {
377
+ row("edge", key, "unknown", "intentUnknownProject", "forbidden", key);
378
+ continue;
379
+ }
380
+ const violated = dependencyForbiddenPairs.has(key);
344
381
  row(
345
382
  "edge",
346
383
  key,
347
- observedEdgeKeys.has(key) ? "unexpected" : "match",
348
- observedEdgeKeys.has(key) ? "dependencyForbidden" : "match",
384
+ violated ? "unexpected" : "match",
385
+ violated ? "dependencyForbidden" : "match",
349
386
  "forbidden",
350
387
  key,
351
388
  );
352
389
  }
353
390
 
391
+ // Every tag any observed project carries — the existence side of a
392
+ // `forbiddenTags` row, the same vocabulary the judge checks when it emits
393
+ // `intentUnknownTag`.
394
+ const allTags = new Set();
395
+ for (const tags of tagsByProject.values()) {
396
+ for (const tag of tags) allTags.add(tag);
397
+ }
354
398
  for (const tagRow of intent.forbiddenTags ?? []) {
355
399
  const key = `${tagRow.from} → ${tagRow.to}`;
356
- const violated = observed.edges.some((e) => {
357
- if (e.source === e.target) return false;
358
- const sourceTags = tagsByProject.get(e.source) ?? [];
359
- const targetTags = tagsByProject.get(e.target) ?? [];
400
+ // A row naming a tag no observed project carries can never fire — the
401
+ // judge reports it as `intentUnknownTag` with no witness pair. Score it
402
+ // unknown, never a silent "match".
403
+ if (!allTags.has(tagRow.from) || !allTags.has(tagRow.to)) {
404
+ row("tag", key, "unknown", "intentUnknownTag", "tag-forbidden", key);
405
+ continue;
406
+ }
407
+ const violated = tagForbiddenWitnesses.some(([source, target]) => {
408
+ const sourceTags = tagsByProject.get(source) ?? [];
409
+ const targetTags = tagsByProject.get(target) ?? [];
360
410
  return sourceTags.includes(tagRow.from) && targetTags.includes(tagRow.to);
361
411
  });
362
412
  row(
@@ -42,6 +42,20 @@
42
42
  * the rule; its malformed-TOML tolerance stays the documented exception its
43
43
  * own header pins (`../analysis/python.mjs`).
44
44
  *
45
+ * ## The hook's context contract (#843)
46
+ *
47
+ * `context.fileMap.projectFileMap` is validated before any resolver runs: a
48
+ * missing map, or a declared project with no key in it, throws. nx 23.x
49
+ * seeds a key for EVERY declared project before attributing a single file —
50
+ * `createFileMap` writes `projectFileMap[name] ??= []` (measured, nx 23.2.0
51
+ * `dist/src/project-graph/file-map-utils.js`) — so a legitimate zero-file or
52
+ * target-only project reads as an EMPTY ARRAY, never as an absent key. The
53
+ * refusal therefore has no legitimate shape to catch, and the state it
54
+ * replaces was the silent direction: a project whose manifests were never
55
+ * read contributes no edges, byte-for-byte identical to a workspace with
56
+ * nothing to find, while a project whose manifest was read and could not be
57
+ * parsed throws (#364). "Never looked" now fails like "looked and failed".
58
+ *
45
59
  * Resolver contract (see `../analysis/*.mjs`): every resolver returns raw Nx
46
60
  * edges — { source, target, sourceFile, type } and nothing else. Go, Rust and
47
61
  * Python take `resolve(projects, filesOf, readFile)`; the C# and JVM halves
@@ -188,12 +202,31 @@ export function resolveDeclaredManifestFailures(workspace) {
188
202
  */
189
203
  export const createDependencies = (options, context) => {
190
204
  resolveOptions(options);
191
- const projects = Object.entries(context.projects).map(([projectName, config]) => ({
192
- name: projectName,
193
- root: config.root,
194
- }));
195
- const filesOf = (projectName) =>
196
- (context.fileMap?.projectFileMap?.[projectName] ?? []).map((f) => f.file);
205
+ const projectFileMap = context.fileMap?.projectFileMap;
206
+ if (projectFileMap === null || typeof projectFileMap !== "object") {
207
+ throw new Error(
208
+ "archkeep: the Nx plugin context carries no fileMap.projectFileMap — no " +
209
+ "project's file universe is known, so no polyglot manifest can be read and " +
210
+ "no edge can be trusted. Refusing rather than computing a silently empty " +
211
+ "graph: nx 23.x seeds a projectFileMap key for every declared project, so a " +
212
+ "missing map is context-shape drift. Upgrade @ecoma-io/archkeep if a newer " +
213
+ "Nx moved the field.",
214
+ );
215
+ }
216
+ const projects = Object.entries(context.projects).map(([projectName, config]) => {
217
+ if (!Object.hasOwn(projectFileMap, projectName)) {
218
+ throw new Error(
219
+ `archkeep: project "${projectName}" (root "${config.root}") is declared in the ` +
220
+ `Nx plugin context but has no key in fileMap.projectFileMap — its manifests ` +
221
+ `would go unread and its edges undrawn, the under-selection this plugin ` +
222
+ `exists to close. nx 23.x maps every declared project, including projects ` +
223
+ `with no files (an empty array), so a missing key is context-shape drift. ` +
224
+ `Upgrade @ecoma-io/archkeep if a newer Nx moved the field.`,
225
+ );
226
+ }
227
+ return { name: projectName, root: config.root };
228
+ });
229
+ const filesOf = (projectName) => projectFileMap[projectName].map((f) => f.file);
197
230
  const readFile = (workspaceRelativePath) => {
198
231
  const abs = join(context.workspaceRoot, workspaceRelativePath);
199
232
  // Every value this reader is handed comes from the tree's own `fileMap` —
@@ -201,8 +201,8 @@
201
201
  {
202
202
  "type": "documentation",
203
203
  "path": "../../docs/concepts/agentic-development.md",
204
- "assertion": "Section 'What context and impact do not check' warns agents about the semantic gap",
205
- "sha256": "a42bca1e927a59d28160eb947a285c20d1a6cc9633a69c91a45e9d46bbe1686b"
204
+ "assertion": "Section 'What `context` and `impact` do not check' warns agents about the semantic gap",
205
+ "sha256": "81b1b57e19f3f5b4894f8273d33d731f80e6f8e80a513bd64208135852ed3bb8"
206
206
  }
207
207
  ],
208
208
  "status": "proven"
@@ -272,7 +272,7 @@
272
272
  "type": "documentation",
273
273
  "path": "../../docs/concepts/agentic-development.md",
274
274
  "assertion": "Section warns agents: violations:[] means allowed by constraint table, not free of all boundary violations",
275
- "sha256": "a42bca1e927a59d28160eb947a285c20d1a6cc9633a69c91a45e9d46bbe1686b"
275
+ "sha256": "81b1b57e19f3f5b4894f8273d33d731f80e6f8e80a513bd64208135852ed3bb8"
276
276
  }
277
277
  ],
278
278
  "status": "proven"
@@ -37,6 +37,12 @@
37
37
  * not blank the index — where `readProjectGraph` throws the identical refusal;
38
38
  * the two policies are the recorded difference between an acquisition that
39
39
  * still has a tree to index and one that does not.
40
+ *
41
+ * One refusal THROWS rather than skipping: a `package.json` beside a
42
+ * `project.json` that exists but cannot be read or parsed (#846). Falling
43
+ * through to the directory basename there would put the project in the graph
44
+ * under a name no constraint row names — a silently wrong identity. Absent
45
+ * (null) stays the legitimate basename fallback per Nx's own precedence.
40
46
  */
41
47
 
42
48
  import { readWorkspaceLayout, requireCompleteWorkspaceLayout } from "../options.mjs";
@@ -103,15 +109,30 @@ export function discoverProjects({ files, readFile }) {
103
109
  // Nx's own precedence: the name a project states, then the one its
104
110
  // `package.json` states, then the directory it lives in.
105
111
  const packageName = (() => {
106
- const manifest = readFile(root === "" ? "package.json" : `${root}/package.json`);
112
+ const pkgPath = root === "" ? "package.json" : `${root}/package.json`;
113
+ let manifest;
114
+ try {
115
+ manifest = readFile(pkgPath);
116
+ } catch (cause) {
117
+ throw new Error(
118
+ `package.json '${pkgPath}' beside project '${root || "."}' could not be read: ${cause?.message ?? cause}`,
119
+ { cause },
120
+ );
121
+ }
107
122
  if (manifest === null) return undefined;
108
123
  try {
109
124
  // The same parser, because Nx reads this file with the same
110
125
  // `readJsonFile` — a `package.json` Nx can name a project from must
111
126
  // not become a project named after its directory here.
112
127
  return parseProjectJson(manifest).name;
113
- } catch {
114
- return undefined;
128
+ } catch (cause) {
129
+ // An unreadable package.json that was READ must not fall through to
130
+ // the directory basename (#846) — that is a project the graph knows
131
+ // under one name and every constraint row names under another.
132
+ throw new Error(
133
+ `package.json '${pkgPath}' beside project '${root || "."}' could not be read: ${cause?.message ?? cause}`,
134
+ { cause },
135
+ );
115
136
  }
116
137
  })();
117
138
  const name =
@@ -34,6 +34,17 @@
34
34
  * afterwards (`../../cli.mjs`, via `annotateMFERemotes` and
35
35
  * `annotatePackageFacts`), read from disk the way upstream reads them — a
36
36
  * provider that filled them in would only be overwritten.
37
+ *
38
+ * The nodes that graph carries are validated at this seam, not trusted. Nx's
39
+ * own contract is narrow — `type` exactly one of `app`/`e2e`/`lib`, `data` an
40
+ * object with a string `root` and, when present, a `tags` array of non-empty
41
+ * strings — and every one of those fields is read verbatim by the rules layer
42
+ * (`../rules/specifiers.mjs`'s root mappings, `../rules/index.mjs`'s node-kind
43
+ * filter, `../rules/tags.mjs`'s constraint matching), so a drifted node is
44
+ * refused here by project name rather than judged into a wrong analysis: a
45
+ * rootless project silently drops out of every path lookup, a wrong kind
46
+ * silently skips its checks, a scalar `tags` silently matches or misses every
47
+ * tag row. See `readProjectGraph` below for the refusal itself.
37
48
  */
38
49
 
39
50
  import { mkdtempSync, readFileSync, rmSync } from "node:fs";
@@ -105,6 +116,81 @@ function nxCli({ resolveNx = () => require.resolve("nx/package.json") } = {}) {
105
116
  return join(dirname(manifest), typeof bin === "string" ? bin : bin.nx);
106
117
  }
107
118
 
119
+ /**
120
+ * Refuses any project node whose shape drifted from what Nx emits, by name.
121
+ *
122
+ * The command's output reaches `evaluate()` with no shape work in between, and
123
+ * every node field is read verbatim downstream: `data.root` by
124
+ * `../rules/specifiers.mjs`'s `createProjectRootMappings` (a missing root maps
125
+ * the project to `undefined`, and it silently drops out of every
126
+ * path-to-project lookup), `type` by `../rules/index.mjs`'s
127
+ * `isProjectGraphProjectNode` (anything outside `app`/`e2e`/`lib` is judged as
128
+ * an external node, silently skipping its boundary checks), `data.tags` by
129
+ * `../rules/tags.mjs` (a scalar silently matches or misses every constraint
130
+ * row). All four shapes are states `nx graph` does not emit — Nx 23.2.0's own
131
+ * `ProjectGraphProjectNode` carries exactly `type: "app"|"e2e"|"lib"` and a
132
+ * `data` configuration with a string `root` — so one here can only mean an Nx
133
+ * version drift or a defective producer, and it is refused, not guessed at:
134
+ * an unreadable entry refuses, it does not skip.
135
+ *
136
+ * @param {Record<string, object>} nodes The `graph.nodes` map as parsed.
137
+ * @throws {Error} when a node is not an object, its `type` is not one of
138
+ * `app`/`e2e`/`lib`, its `data` is not an object, its `data.root` is not a
139
+ * string, or its `data.tags` is present but not an array of non-empty
140
+ * strings. The error names the project and the offending field.
141
+ */
142
+ function validateProjectNodes(nodes) {
143
+ for (const [name, node] of Object.entries(nodes)) {
144
+ if (typeof node !== "object" || node === null) {
145
+ throw new Error(
146
+ `archkeep: \`nx graph\` node '${name}' is not an object — expected a project node ` +
147
+ `with type, name and data as Nx emits it.`,
148
+ );
149
+ }
150
+ if (node.type !== "app" && node.type !== "e2e" && node.type !== "lib") {
151
+ throw new Error(
152
+ `archkeep: \`nx graph\` node '${name}' has type "${node.type}" — expected one of ` +
153
+ `"app", "e2e", "lib". Anything else is judged as an external node and its boundary ` +
154
+ `checks are silently skipped.`,
155
+ );
156
+ }
157
+ if (typeof node.data !== "object" || node.data === null) {
158
+ throw new Error(
159
+ `archkeep: \`nx graph\` node '${name}' has no data object — expected data with a ` +
160
+ `workspace-relative root, and tags when the project carries any.`,
161
+ );
162
+ }
163
+ if (typeof node.data.root !== "string") {
164
+ throw new Error(
165
+ `archkeep: \`nx graph\` node '${name}' has no string data.root — expected the ` +
166
+ `workspace-relative project root as a string ("" is the workspace root). A rootless ` +
167
+ `project silently drops out of every path-to-project lookup and its imports are ` +
168
+ `judged with no owning project.`,
169
+ );
170
+ }
171
+ if (node.data.tags !== undefined) {
172
+ if (!Array.isArray(node.data.tags)) {
173
+ const got = node.data.tags === null ? "null" : typeof node.data.tags;
174
+ throw new Error(
175
+ `archkeep: \`nx graph\` node '${name}' has data.tags of type ${got} — expected an ` +
176
+ `array of non-empty strings. Tag rows are matched against this list verbatim, so a ` +
177
+ `scalar silently matches or misses every row.`,
178
+ );
179
+ }
180
+ for (const [index, tag] of node.data.tags.entries()) {
181
+ if (typeof tag !== "string" || tag === "") {
182
+ const got = tag === "" ? "an empty string" : `a ${typeof tag}`;
183
+ throw new Error(
184
+ `archkeep: \`nx graph\` node '${name}' has data.tags[${index}] that is not a ` +
185
+ `non-empty string (got ${got}) — expected an array of non-empty strings. A ` +
186
+ `non-string entry silently matches or misses every tag row it is compared with.`,
187
+ );
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+
108
194
  /**
109
195
  * The Nx project graph for `workspaceRoot`, in the shape `evaluate()` consumes.
110
196
  *
@@ -133,6 +219,12 @@ function nxCli({ resolveNx = () => require.resolve("nx/package.json") } = {}) {
133
219
  * agree on the same declared object rather than one merging onto a default
134
220
  * the other would have refused.
135
221
  *
222
+ * Each node in that graph is also validated against Nx's own contract —
223
+ * `type` one of `app`/`e2e`/`lib`, `data` an object, `data.root` a string,
224
+ * `data.tags` (when present) an array of non-empty strings — and a drifted
225
+ * node is refused by project name (`validateProjectNodes` below) rather than
226
+ * forwarded to rules that read it verbatim and would silently misjudge it.
227
+ *
136
228
  * @param {string} workspaceRoot
137
229
  * @param {{ run?: typeof runProcess, resolveNx?: () => string,
138
230
  * readLayout?: typeof readWorkspaceLayout }} [io]
@@ -140,6 +232,9 @@ function nxCli({ resolveNx = () => require.resolve("nx/package.json") } = {}) {
140
232
  * `workspaceLayout` read (see `../options.mjs`).
141
233
  * @returns {object} `{ nodes, dependencies }`, plus `workspaceLayout` when
142
234
  * `nx.json` declares a complete one.
235
+ * @throws {Error} when the emitted graph carries no `graph.nodes` map, or any
236
+ * node drifted from Nx's own shape — the error names the project and the
237
+ * field that refused it.
143
238
  */
144
239
  export function readProjectGraph(
145
240
  workspaceRoot,
@@ -150,12 +245,13 @@ export function readProjectGraph(
150
245
  try {
151
246
  run(process.execPath, [nxCli({ resolveNx }), "graph", `--file=${file}`], workspaceRoot);
152
247
  const { graph } = JSON.parse(readFileSync(file, "utf8"));
153
- if (!graph?.nodes) {
248
+ if (!graph?.nodes || typeof graph.nodes !== "object" || Array.isArray(graph.nodes)) {
154
249
  throw new Error(
155
- `archkeep: \`nx graph\` produced no \`graph.nodes\` in ${file} — ` +
250
+ `archkeep: \`nx graph\` produced no \`graph.nodes\` object in ${file} — ` +
156
251
  `nothing can be judged against a graph with no projects in it`,
157
252
  );
158
253
  }
254
+ validateProjectNodes(graph.nodes);
159
255
  const workspaceLayout = requireCompleteWorkspaceLayout(readLayout(workspaceRoot));
160
256
  return workspaceLayout === null ? graph : { ...graph, workspaceLayout };
161
257
  } finally {
@@ -774,7 +774,7 @@ function formatUntrackedFilesGap(gap) {
774
774
  `⚠ ${count} project-owned file${count === 1 ? "" : "s"} ${count === 1 ? "is" : "are"} ` +
775
775
  `not tracked by git — never read by this run, so no boundary verdict here covers ${them}\n` +
776
776
  `${lines.join("\n")}\n` +
777
- `${DETAIL}git add ${them} so the next run reads ${them}, or let git ignore ${them}`
777
+ `${DETAIL}git add ${them} so the next run reads ${them}`
778
778
  );
779
779
  }
780
780
 
@@ -1,248 +0,0 @@
1
- /**
2
- * Fixture scaffolding for the Wave 3 W8 evolution-lifecycle conformance suite
3
- * (`../evolution-lifecycle.integration.test.mjs`). This module is the ONE home
4
- * for the real-git workspace builders that suite uses — a throwaway native Go
5
- * workspace per case, materialized through real `git`, driven through the real
6
- * `archkeep evolution` entry point.
7
- *
8
- * It deliberately reuses the native-workspace recipe already proven by
9
- * `../commands/evolution.cli.integration.test.mjs` (an `archkeep.json` model,
10
- * a `module-boundaries.config.mjs` law, and Go sources) rather than inventing
11
- * a second convention, and threads the same environment guard (`../process.mjs`).
12
- *
13
- * Nothing here decides a verdict. It builds trees and drives the CLI; the
14
- * assertions live in the suite. Keeping the builders here (and only here) is
15
- * what the W8 task boundary requires: fixture scaffolding lives in
16
- * `./fixtures/evolution-lifecycle/`, nowhere else.
17
- */
18
-
19
- import { execFileSync } from "node:child_process";
20
- import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
21
- import { tmpdir } from "node:os";
22
- import { join } from "node:path";
23
-
24
- import { EXIT, runCli } from "../../../cli.mjs";
25
- import { SPAWN_BUDGET_MS, SPAWN_TEST_BUDGET_MS } from "../../../spawn-budget.mjs";
26
- import { environmentForTree } from "../../workspace.mjs";
27
-
28
- export { SPAWN_TEST_BUDGET_MS, EXIT };
29
-
30
- /** Identity flags keeping every fixture commit independent of the machine. */
31
- const IDENTITY = ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"];
32
-
33
- /**
34
- * Runs git in `cwd` through the same environment guard production uses, with
35
- * the single-spawn budget on every child so a wedged git fails the test rather
36
- * than blocking the worker thread forever.
37
- */
38
- export function git(cwd, ...args) {
39
- // used by its own test
40
- return execFileSync("git", args, {
41
- cwd,
42
- env: environmentForTree(),
43
- encoding: "utf8",
44
- timeout: SPAWN_BUDGET_MS,
45
- killSignal: "SIGKILL",
46
- });
47
- }
48
-
49
- /** Writes `text` to `root/relativePath`, creating parent directories. */
50
- export function writeIn(root, relativePath, text) {
51
- // used by its own test
52
- mkdirSync(join(root, relativePath, ".."), { recursive: true });
53
- writeFileSync(join(root, relativePath), text);
54
- }
55
-
56
- /** Stages every change and commits with the fixture identity; returns the SHA. */
57
- export function commit(root, message) {
58
- // used by its own test
59
- git(root, ...IDENTITY, "add", "-A");
60
- git(root, ...IDENTITY, "commit", "-q", "-m", message);
61
- return git(root, "rev-parse", "HEAD").trim();
62
- }
63
-
64
- /**
65
- * Opens a brand-new throwaway native git workspace (never the repository's own
66
- * tree). `archkeep.json` declares two Go projects on two layers, exactly the
67
- * MODEL `../commands/evolution.cli.integration.test.mjs` uses, so a case can
68
- * lay an edge between them and the native provider draws it.
69
- *
70
- * @returns {{root: string}}
71
- */
72
- export function createWorkspace() {
73
- const root = mkdtempSync(join(tmpdir(), "archkeep-lifecycle-"));
74
- git(root, "init", "-q", "-b", "main");
75
- writeIn(root, "archkeep.json", `${MODEL()}\n`);
76
- writeIn(root, "libs/alpha/go.mod", "module example.com/alpha\n\ngo 1.22\n");
77
- writeIn(root, "libs/beta/go.mod", "module example.com/beta\n\ngo 1.22\n");
78
- return { root };
79
- }
80
-
81
- /**
82
- * The native workspace model: two Go projects on two layers (alpha is
83
- * `layer:a`, beta is `layer:b`), the law file exempted from coverage.
84
- */
85
- const MODEL = () =>
86
- JSON.stringify(
87
- {
88
- projects: {
89
- declared: [
90
- { root: "libs/alpha", name: "alpha", tags: ["layer:a"] },
91
- { root: "libs/beta", name: "beta", tags: ["layer:b"] },
92
- ],
93
- },
94
- coverage: {
95
- exempt: [{ path: "module-boundaries.config.mjs", reason: "the workspace's own law" }],
96
- },
97
- },
98
- null,
99
- 2,
100
- );
101
-
102
- /** The eight options a valid boundary law must carry, per `policyFrom`. */
103
- const OPTIONS = `export const moduleBoundaryOptions = {
104
- allow: [],
105
- buildTargets: ["build"],
106
- enforceBuildableLibDependency: false,
107
- allowCircularSelfDependency: false,
108
- checkDynamicDependenciesExceptions: [],
109
- ignoredCircularDependencies: [],
110
- banTransitiveDependencies: false,
111
- checkNestedExternalImports: false,
112
- };
113
- `;
114
-
115
- export const ALPHA_CLEAN = `package alpha // used by its own test
116
-
117
- func Name() string { return "alpha" }
118
- `;
119
-
120
- export const ALPHA_REACHING = `package alpha // used by its own test
121
-
122
- import (
123
- "example.com/beta"
124
- )
125
-
126
- func Name() string { return "alpha" + beta.Suffix() }
127
- `;
128
-
129
- export const BETA = `package beta // used by its own test
130
-
131
- func Suffix() string { return "-beta" }
132
- `;
133
-
134
- /**
135
- * Writes a `module-boundaries.config.mjs` law at `root` with the given
136
- * `depConstraints` rows and optional `fitness` array.
137
- *
138
- * @param {string} root
139
- * @param {{rows?: string, fitness?: string}} [law]
140
- */
141
- export function writeLaw(root, { rows = "", fitness } = {}) {
142
- // used by its own test
143
- writeIn(
144
- root,
145
- "module-boundaries.config.mjs",
146
- `export const depConstraints = [\n${rows}\n];\n${OPTIONS}` +
147
- (fitness === undefined ? "" : `\nexport const fitness = ${fitness};\n`),
148
- );
149
- }
150
-
151
- /**
152
- * A single permitted layer rule (a may reach b). The same ONE_ROW the
153
- * evolution CLI integration fixtures use, so an allowed alpha→beta edge never
154
- * trips a boundary rule.
155
- */
156
- export const ALLOW_A_TO_B = ` { sourceTag: "layer:a", onlyDependOnLibsWithTags: ["layer:b"] },`; // used by its own test
157
-
158
- /**
159
- * Writes `architecture-intent.json` at `root`. `sections` carries the top-level
160
- * keys directly (`version`, `boundaries`, `allowed`, `forbidden`,
161
- * `dependencies`, …); `version` defaults to "1".
162
- */
163
- export function writeIntent(root, sections) {
164
- // used by its own test
165
- writeIn(root, "architecture-intent.json", `${JSON.stringify(sections, null, 2)}\n`);
166
- }
167
-
168
- /**
169
- * Writes one ADR record under `docs/adr/`, the shape `adr-registry.mjs` reads.
170
- * `record` is the frontmatter map (`{id, status, supersedes?, bindings?}`).
171
- */
172
- export function writeAdr(root, filename, record) {
173
- // used by its own test
174
- const lines = ["---", `id: ${record.id}`, `status: ${record.status}`];
175
- if (record.supersedes?.length) {
176
- lines.push("supersedes:");
177
- for (const target of record.supersedes) lines.push(` - ${target}`);
178
- }
179
- if (record.bindings?.length) {
180
- lines.push("bindings:");
181
- for (const binding of record.bindings) lines.push(` - ${binding}`);
182
- }
183
- lines.push("---", "", `# ${record.id}`, "");
184
- writeIn(root, join("docs/adr", filename), `${lines.join("\n")}\n`);
185
- }
186
-
187
- /**
188
- * Drives the CLI in-process over `cwd`, capturing streams. Returns the exit
189
- * code and joined `out`/`err`. `runCli` is the real entry point
190
- * (`../cli.mjs`), never a shell-out to a binary named `archkeep`.
191
- */
192
- export async function runEvolution(cwd, argv) {
193
- // used by its own test
194
- const out = [];
195
- const err = [];
196
- const exitCode = await runCli(argv, {
197
- out: (text) => out.push(text),
198
- err: (text) => err.push(text),
199
- cwd,
200
- });
201
- return { exitCode, out: out.join("\n"), err: err.join("\n") };
202
- }
203
-
204
- /**
205
- * Invokes `evolution --base <base> [--head <head>] [--event-out <dir>] [--format json]`.
206
- *
207
- * @param {string} base The base revision (full SHA).
208
- * @param {{head?: string, eventOut?: string, format?: string}} [options]
209
- */
210
- export function evolutionArgs(base, { head, eventOut, format = "json" } = {}) {
211
- // used by its own test
212
- const args = ["evolution", "--base", base];
213
- if (head) args.push("--head", head);
214
- if (eventOut) args.push("--event-out", eventOut);
215
- if (format) args.push("--format", format);
216
- return args;
217
- }
218
-
219
- /**
220
- * Parses the `--format json` envelope out of a successful evolution run.
221
- */
222
- export function parseEnvelope(run) {
223
- // used by its own test
224
- if (run.exitCode !== EXIT.ok) throw new Error(`evolution exited ${run.exitCode}: ${run.err}`);
225
- return JSON.parse(run.out);
226
- }
227
-
228
- /** The parsed event files in `dir`, in filename order. */
229
- export function readEvents(dir) {
230
- return readdirSync(dir)
231
- .filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"))
232
- .sort()
233
- .map((name) => JSON.parse(readFileSync(join(dir, name), "utf8")));
234
- }
235
-
236
- /** The event store's file names in `dir`, in filename order. */
237
- export function eventFiles(dir) {
238
- // used by its own test
239
- return readdirSync(dir)
240
- .filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"))
241
- .sort();
242
- }
243
-
244
- /** Removes a throwaway workspace. */
245
- export function dispose(root) {
246
- // used by its own test
247
- rmSync(root, { recursive: true, force: true });
248
- }