@ecoma-io/archkeep 0.27.1 → 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 +1 -1
- package/src/analysis/jvm/gradle.mjs +23 -2
- 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/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/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**Architecture governance for polyglot repositories** — a deterministic
|
|
4
4
|
authority that keeps the architecture your team declared aligned with the code
|
|
5
5
|
your team keeps changing. Dependency graphs and module boundaries for Go, Rust,
|
|
6
|
-
Python, TypeScript
|
|
6
|
+
Python, TypeScript and JavaScript, Vue, Java, Kotlin and C#, with Nx and Moon as first-class
|
|
7
7
|
integrations. Coding agents read the same verdicts, machine-readably, through
|
|
8
8
|
the `arch-*` skills. The system boundary — what Archkeep is and what it is not —
|
|
9
9
|
is owned by [architecture-authority.md](https://github.com/ecoma-io/archkeep/blob/main/docs/doctrine/architecture-authority.md).
|
|
@@ -166,8 +166,10 @@ a file it cannot edit ([overview.md](https://github.com/ecoma-io/archkeep/blob/m
|
|
|
166
166
|
mismatch, undeclared imports) refuses the run — never a silent skip
|
|
167
167
|
([custom-rules.md](https://github.com/ecoma-io/archkeep/blob/main/docs/concepts/custom-rules.md),
|
|
168
168
|
[writing one](https://github.com/ecoma-io/archkeep/blob/main/docs/usage/custom-rules.md)).
|
|
169
|
-
- **Shipped policy packs** — Clean Architecture, hexagonal,
|
|
170
|
-
|
|
169
|
+
- **Shipped policy packs** — six shipped packs: Clean Architecture, hexagonal,
|
|
170
|
+
traditional layering, layered modular monolith, vertical slices and DDD
|
|
171
|
+
bounded contexts, as
|
|
172
|
+
ready-made profile registries under
|
|
171
173
|
this package's `presets/` directory. Copy one into your workspace, or point
|
|
172
174
|
the `profiles` option straight at it; either way it is enforced by the same
|
|
173
175
|
path a registry you wrote yourself is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecoma-io/archkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Architecture authority for human and agentic software development — deterministic, evidence-backed enforcement of declared architecture.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"architecture",
|
|
@@ -69,6 +69,12 @@
|
|
|
69
69
|
* below THROWS on the same list (#364's posture, `../source-util.mjs`'s
|
|
70
70
|
* `refuseUnreadTree`), so `nx affected` fails loudly instead of
|
|
71
71
|
* under-selecting on it.
|
|
72
|
+
*
|
|
73
|
+
* A settings file that cannot be READ at all — one `readFile` throws on, an
|
|
74
|
+
* unreadable include file — is refused with a thrown Error naming the file
|
|
75
|
+
* path, not an empty include list (#847): a missing settings file is fine
|
|
76
|
+
* (a root may have no reactor), but a settings file whose read fails must
|
|
77
|
+
* not collapse the reactor to "no includes" silently.
|
|
72
78
|
*/
|
|
73
79
|
|
|
74
80
|
import { normalizePath } from "../manifest-util.mjs";
|
|
@@ -380,12 +386,27 @@ function buildGradleModel(workspace) {
|
|
|
380
386
|
for (const dir of candidateDirs) {
|
|
381
387
|
for (const name of SETTINGS_FILENAMES) {
|
|
382
388
|
const path = normalizePath(dir, name);
|
|
383
|
-
|
|
389
|
+
try {
|
|
390
|
+
if (readFile(path) !== null && readFile(path) !== undefined) settingsFiles.push(path);
|
|
391
|
+
} catch (cause) {
|
|
392
|
+
throw new Error(
|
|
393
|
+
`Gradle settings file '${path}' could not be read: ${cause?.message ?? cause}`,
|
|
394
|
+
{ cause },
|
|
395
|
+
);
|
|
396
|
+
}
|
|
384
397
|
}
|
|
385
398
|
}
|
|
386
399
|
for (const settingsPath of settingsFiles) {
|
|
387
400
|
const settingsDir = dirnameOf(settingsPath);
|
|
388
|
-
|
|
401
|
+
let settingsText;
|
|
402
|
+
try {
|
|
403
|
+
settingsText = readFile(settingsPath);
|
|
404
|
+
} catch (cause) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`Gradle settings file '${settingsPath}' could not be read: ${cause?.message ?? cause}`,
|
|
407
|
+
{ cause },
|
|
408
|
+
);
|
|
409
|
+
}
|
|
389
410
|
const settingsParsed = parseGradleSettings(settingsText ?? "");
|
|
390
411
|
if (settingsParsed.reason !== undefined) {
|
|
391
412
|
failures.push({
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"tool": {
|
|
4
4
|
"name": "@ecoma-io/archkeep",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.28.0"
|
|
6
6
|
},
|
|
7
7
|
"command": "change",
|
|
8
8
|
"workspace": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"path": "<fixture-root>/.archkeep-delta.json",
|
|
46
46
|
"tool": {
|
|
47
47
|
"name": "@ecoma-io/archkeep",
|
|
48
|
-
"version": "0.
|
|
48
|
+
"version": "0.28.0"
|
|
49
49
|
},
|
|
50
50
|
"provider": "native",
|
|
51
51
|
"provenance": {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"tool": {
|
|
4
4
|
"name": "@ecoma-io/archkeep",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.28.0"
|
|
6
6
|
},
|
|
7
7
|
"command": "delta",
|
|
8
8
|
"workspace": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"path": "<fixture-root>/.archkeep-delta.json",
|
|
32
32
|
"tool": {
|
|
33
33
|
"name": "@ecoma-io/archkeep",
|
|
34
|
-
"version": "0.
|
|
34
|
+
"version": "0.28.0"
|
|
35
35
|
},
|
|
36
36
|
"provider": "native",
|
|
37
37
|
"provenance": {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"tool": {
|
|
4
4
|
"name": "@ecoma-io/archkeep",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.28.0"
|
|
6
6
|
},
|
|
7
7
|
"command": "diff",
|
|
8
8
|
"workspace": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"path": "<fixture-root>/.archkeep-graph.json",
|
|
34
34
|
"projects": 3,
|
|
35
35
|
"edges": 2,
|
|
36
|
-
"toolVersion": "0.
|
|
36
|
+
"toolVersion": "0.28.0",
|
|
37
37
|
"provenance": {
|
|
38
38
|
"commit": "1fd51709c377d99a6139891e1200291cf08aede9",
|
|
39
39
|
"remote": null,
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* to `{attested, attribution}`. When `fileAttribution` cannot answer (returns
|
|
6
6
|
* null), every decision is unattested.
|
|
7
7
|
*
|
|
8
|
-
* This is the shared helper
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* This is the shared helper the impact and scenario evaluation callers use, so
|
|
9
|
+
* decision provenance is computed identically everywhere — "via the same graph
|
|
10
|
+
* helper, never re-derived" (PR4). `buildProvenanceGraph` is not a caller: it
|
|
11
|
+
* reads attestation from its `decisionLifecycle` input directly (#882).
|
|
11
12
|
*
|
|
12
13
|
* @param {{id: string}[]} records ADR records
|
|
13
14
|
* @param {(path: string) => {createdBy: object|null,
|
|
@@ -92,9 +93,6 @@ import { resolveDecisionRef, stripRuleFitnessPrefix, stripAdrPrefix } from "./ad
|
|
|
92
93
|
* Decision record lookup map.
|
|
93
94
|
* @property {Set<string>} knownFitness
|
|
94
95
|
* Fitness record names for resolution.
|
|
95
|
-
* @property {(path: string) => object|null} [fileAttribution]
|
|
96
|
-
* Resolves git attribution for a decision record file. Passed through to
|
|
97
|
-
* `computeDecisionProvenance`. Defaults to a function that always returns null.
|
|
98
96
|
* @property {{id: string, attested: boolean, attribution: object|null}[]}
|
|
99
97
|
* decisionLifecycle
|
|
100
98
|
* @typedef {object} ProvenanceGraphNode
|
|
@@ -172,7 +170,6 @@ export function buildProvenanceGraph({
|
|
|
172
170
|
records = [],
|
|
173
171
|
byId = new Map(),
|
|
174
172
|
knownFitness = new Set(),
|
|
175
|
-
fileAttribution: _fileAttribution = () => null,
|
|
176
173
|
decisionLifecycle = [],
|
|
177
174
|
}) {
|
|
178
175
|
const nodes = [];
|
|
@@ -408,7 +405,12 @@ export function buildProvenanceGraph({
|
|
|
408
405
|
for (const nextId of sortedArray(record.supersedes)) {
|
|
409
406
|
if (!visited.has(nextId)) {
|
|
410
407
|
queue.push(nextId);
|
|
411
|
-
|
|
408
|
+
// First queued parent wins: when a second supersession path reaches
|
|
409
|
+
// a decision already queued, overwriting its parent would drop the
|
|
410
|
+
// first path's hop from the chain while `edges` keeps both relations.
|
|
411
|
+
if (!parentMap.has(nextId)) {
|
|
412
|
+
parentMap.set(nextId, currentId);
|
|
413
|
+
}
|
|
412
414
|
}
|
|
413
415
|
}
|
|
414
416
|
|
|
@@ -457,7 +459,9 @@ export function buildProvenanceGraph({
|
|
|
457
459
|
causalChains.push({
|
|
458
460
|
id: chainId,
|
|
459
461
|
startNode: rowId,
|
|
460
|
-
|
|
462
|
+
// `chainNodes` entries are node ids already (`decision:<id>`); wrapping
|
|
463
|
+
// them a second time emitted the dangling `decision:decision:<id>`.
|
|
464
|
+
endNode: chainNodes[chainNodes.length - 1],
|
|
461
465
|
hops: chainEdges,
|
|
462
466
|
});
|
|
463
467
|
}
|
|
@@ -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
|
}
|
|
@@ -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 {
|