@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.
- package/README.md +5 -3
- package/package.json +2 -1
- package/src/analysis/jvm/gradle.mjs +23 -2
- package/src/commands/README.md +7 -4
- package/src/commands/change.mjs +3 -2
- package/src/commands/delta-snapshot.mjs +17 -0
- package/src/commands/delta.mjs +4 -2
- package/src/commands/scenario-evaluation.mjs +97 -3
- package/src/corpus/goldens/adr.json +1 -1
- package/src/corpus/goldens/change.json +2 -2
- package/src/corpus/goldens/check.json +1 -1
- package/src/corpus/goldens/context.json +1 -1
- package/src/corpus/goldens/debt.json +1 -1
- package/src/corpus/goldens/decisions.json +1 -1
- package/src/corpus/goldens/delta.json +2 -2
- package/src/corpus/goldens/diff.json +2 -2
- package/src/corpus/goldens/discover.json +1 -1
- package/src/corpus/goldens/drift.json +1 -1
- package/src/corpus/goldens/evolution.json +1 -1
- package/src/corpus/goldens/explain.json +1 -1
- package/src/corpus/goldens/fitness.json +1 -1
- package/src/corpus/goldens/graph.json +1 -1
- package/src/corpus/goldens/health.json +1 -1
- package/src/corpus/goldens/history.json +1 -1
- package/src/corpus/goldens/impact.json +1 -1
- package/src/corpus/goldens/provenance.json +1 -1
- package/src/corpus/goldens/reconcile.json +1 -1
- package/src/corpus/goldens/report.json +1 -1
- package/src/corpus/goldens/scenario.json +1 -1
- package/src/corpus/goldens/trajectory.json +1 -1
- package/src/corpus/goldens/waivers.json +1 -1
- package/src/governance/provenance-graph.mjs +13 -9
- package/src/governance/provenance-record.mjs +22 -8
- package/src/governance/reconcile-score.mjs +59 -9
- package/src/graph/create-dependencies.mjs +39 -6
- package/src/intent/intent-manifest.json +3 -3
- package/src/providers/nx-static.mjs +24 -3
- package/src/providers/nx.mjs +98 -2
- package/src/report/text.mjs +1 -1
- 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
|
|
151
|
-
* keys — a fresh object, so nothing from
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|
272
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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": "
|
|
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": "
|
|
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
|
|
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
|
-
|
|
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 =
|
package/src/providers/nx.mjs
CHANGED
|
@@ -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 {
|
package/src/report/text.mjs
CHANGED
|
@@ -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}
|
|
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
|
-
}
|