@ecoma-io/archkeep 0.27.1 → 0.28.1
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/commands/README.md +2 -1
- package/src/commands/history.mjs +123 -19
- package/src/containment.mjs +1 -1
- 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/evolution-store.mjs +6 -5
- 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/src/report/health-text.mjs +1 -1
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.1",
|
|
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({
|
package/src/commands/README.md
CHANGED
|
@@ -134,7 +134,8 @@ the resolution order.
|
|
|
134
134
|
- **`history`** (`./history.mjs`'s `historyCommand`) — the architecture's
|
|
135
135
|
evolution across a consumer-managed directory of `graph --format json`
|
|
136
136
|
snapshots. Reads every snapshot (the directory is the sole source of truth —
|
|
137
|
-
no index, no database), in
|
|
137
|
+
no index, no database), in capture-sequence order (each filename's leading
|
|
138
|
+
numeric sequence, not byte order), and classifies
|
|
138
139
|
each transition by what the snapshots carry: graph diff (architecture),
|
|
139
140
|
`policy.fingerprint` (policy/intent), `workspace.provider` (provider), and
|
|
140
141
|
provenance advance with neither changed (code drift). One-sided or cross-repo
|
package/src/commands/history.mjs
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*
|
|
11
11
|
* `--capture` appends a snapshot of the current workspace first — writing
|
|
12
12
|
* `<seq>-<sha8>.json` (a zero-padded monotonic sequence and the snapshot's
|
|
13
|
-
* architecture identity
|
|
13
|
+
* architecture identity; `readSnapshots` parses the leading sequence, so
|
|
14
|
+
* capture-sequence order IS history order) — then
|
|
14
15
|
* produces the record that includes it. Capture deduplicates: when the
|
|
15
16
|
* current architecture identity matches the last snapshot, no new file is
|
|
16
17
|
* written and no empty transition is manufactured. The capture answer — was
|
|
@@ -34,9 +35,11 @@
|
|
|
34
35
|
* An index file would be a second copy of facts the snapshot files already
|
|
35
36
|
* hold, and two copies drift — the same reason `scripts/check-packages.mjs`
|
|
36
37
|
* derives its target list from `ci.yml` rather than holding a copy
|
|
37
|
-
* (`../../../../AGENTS.md`). The directory is ordered by
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* (`../../../../AGENTS.md`). The directory is ordered by capture sequence —
|
|
39
|
+
* `readSnapshots` parses each filename's leading numeric sequence, never
|
|
40
|
+
* byte order, which a sequence widened past 9999 would rewind; a snapshot
|
|
41
|
+
* is replaced or deleted by moving its file; a `history` that cannot make
|
|
42
|
+
* sense of the directory says so instead of guessing.
|
|
40
43
|
*
|
|
41
44
|
* ## What a transition can and cannot assert
|
|
42
45
|
*
|
|
@@ -71,8 +74,16 @@
|
|
|
71
74
|
* owns those (`./README.md`).
|
|
72
75
|
*/
|
|
73
76
|
import { createHash } from "node:crypto";
|
|
74
|
-
import {
|
|
75
|
-
|
|
77
|
+
import {
|
|
78
|
+
existsSync,
|
|
79
|
+
lstatSync,
|
|
80
|
+
readdirSync,
|
|
81
|
+
readFileSync,
|
|
82
|
+
realpathSync,
|
|
83
|
+
renameSync,
|
|
84
|
+
writeFileSync,
|
|
85
|
+
} from "node:fs";
|
|
86
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
76
87
|
|
|
77
88
|
import {
|
|
78
89
|
blindSpotRows,
|
|
@@ -80,7 +91,7 @@ import {
|
|
|
80
91
|
unresolvableLiteralCount,
|
|
81
92
|
} from "../analysis/source-util.mjs";
|
|
82
93
|
import { canonicalizeJson } from "../canonical.mjs";
|
|
83
|
-
import { containmentViolation } from "../containment.mjs";
|
|
94
|
+
import { containmentViolation, deepestExistingAncestor } from "../containment.mjs";
|
|
84
95
|
import { classifyEvolution } from "../governance/evolution-event.mjs";
|
|
85
96
|
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
86
97
|
import { formatHistoryReport } from "../report/history-text.mjs";
|
|
@@ -183,6 +194,14 @@ export function eventSnapshotSide({ revision, projects, dependencies, policyFing
|
|
|
183
194
|
* may share an architecture identity at non-adjacent positions (an A → B → A
|
|
184
195
|
* evolution is real history); only capture dedups, against the last file.
|
|
185
196
|
*
|
|
197
|
+
* The files are returned in capture-sequence order: the leading numeric
|
|
198
|
+
* sequence parsed from each filename, never filename byte order — at the
|
|
199
|
+
* 9999→10000 boundary byte order places a five-digit `10000-…` before
|
|
200
|
+
* `1001-…`, rewinding the record. Byte order is only the deterministic
|
|
201
|
+
* tiebreak for equal sequences; a name with no numeric prefix sorts after
|
|
202
|
+
* every numbered snapshot, byte-ordered among themselves, so a foreign file
|
|
203
|
+
* never interleaves with (and silently rewrites) the record.
|
|
204
|
+
*
|
|
186
205
|
* @param {string} dir Absolute path to the history directory.
|
|
187
206
|
* @param {string} [root] The workspace root, when the caller has one. A
|
|
188
207
|
* directory whose STRING lies inside the workspace but whose realpath
|
|
@@ -221,7 +240,24 @@ export function readSnapshots(dir, root) {
|
|
|
221
240
|
);
|
|
222
241
|
}
|
|
223
242
|
names = names.filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"));
|
|
224
|
-
|
|
243
|
+
// History order is the capture SEQUENCE, not filename bytes: the sequence
|
|
244
|
+
// widens past 9999 (`nextSequence`), where byte-sort would order
|
|
245
|
+
// `10000-…` between `1000-…` and `1001-…` and rewind the record. Parse
|
|
246
|
+
// each leading `\d+` once and sort numerically; byte order is the
|
|
247
|
+
// deterministic tiebreak for equal sequences. A name with no numeric
|
|
248
|
+
// prefix cannot be sequenced — it sorts after every numbered snapshot
|
|
249
|
+
// (`Infinity`), byte-ordered among itself, never displacing the record.
|
|
250
|
+
const sequenced = names.map((name) => {
|
|
251
|
+
const match = /^(\d+)-/.exec(name);
|
|
252
|
+
return {
|
|
253
|
+
name,
|
|
254
|
+
sequence: match === null ? Number.POSITIVE_INFINITY : Number.parseInt(match[1], 10),
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
sequenced.sort(
|
|
258
|
+
(a, b) => a.sequence - b.sequence || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0),
|
|
259
|
+
);
|
|
260
|
+
names = sequenced.map((entry) => entry.name);
|
|
225
261
|
|
|
226
262
|
const files = [];
|
|
227
263
|
for (const name of names) {
|
|
@@ -273,13 +309,14 @@ export function shortId(id) {
|
|
|
273
309
|
* The zero-padded sequence number for `--capture`, taken from the highest
|
|
274
310
|
* existing snapshot filename. `0001` for a fresh directory.
|
|
275
311
|
*
|
|
276
|
-
* The width
|
|
277
|
-
* `
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
312
|
+
* The width is a stable filename shape, not an ordering mechanism:
|
|
313
|
+
* `readSnapshots` parses the leading sequence numerically, so a five-digit
|
|
314
|
+
* `10000-…` follows `9999-…` and history order can never rewind at the
|
|
315
|
+
* width boundary. The four-digit minimum keeps a young history's filenames
|
|
316
|
+
* uniformly shaped, and the width only ever grows to what the next number
|
|
317
|
+
* needs — which is what actually keeps two captures from ever targeting
|
|
318
|
+
* the same filename: the sequence advances past every existing name, so no
|
|
319
|
+
* produced name repeats one that exists, on any width.
|
|
283
320
|
*
|
|
284
321
|
* @param {{files: {name: string}[]}} read From `readSnapshots`.
|
|
285
322
|
* @returns {string} Zero-padded sequence, at least four digits.
|
|
@@ -781,6 +818,39 @@ function historyDirFrom(options, cwd) {
|
|
|
781
818
|
return isAbsolute(options.paths[0]) ? resolve(options.paths[0]) : resolve(cwd, options.paths[0]);
|
|
782
819
|
}
|
|
783
820
|
|
|
821
|
+
/**
|
|
822
|
+
* The physical location `path` names, resolved through every intermediate
|
|
823
|
+
* symlink. When a component does not exist yet (`ENOENT`/`ENOTDIR` — the
|
|
824
|
+
* only errors that mean "missing"), `realpathSync` fails and the fallback
|
|
825
|
+
* walks up to the deepest existing ancestor (`deepestExistingAncestor` — the
|
|
826
|
+
* same primitive the containment probe in `../containment.mjs` walks),
|
|
827
|
+
* resolves THAT physically, and appends the not-yet-existing remainder
|
|
828
|
+
* lexically: a component that does not exist cannot be a symlink, so its
|
|
829
|
+
* lexical spelling is its only spelling. When no component exists at all,
|
|
830
|
+
* nothing is provable and the answer is `null`.
|
|
831
|
+
*
|
|
832
|
+
* @param {string} path An absolute, already-`resolve`d path.
|
|
833
|
+
* @returns {string|null} The physical destination, the lexical form when the
|
|
834
|
+
* path is missing, or `null` when the destination cannot be resolved at
|
|
835
|
+
* all (a symlink loop, an unreadable component) — a no-verdict signal the
|
|
836
|
+
* caller must refuse on, never guess from.
|
|
837
|
+
*/
|
|
838
|
+
function physicalDestination(path) {
|
|
839
|
+
try {
|
|
840
|
+
return realpathSync(path);
|
|
841
|
+
} catch (error) {
|
|
842
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") return null;
|
|
843
|
+
const ancestor = deepestExistingAncestor(path, lstatSync);
|
|
844
|
+
if (ancestor === null) return null;
|
|
845
|
+
try {
|
|
846
|
+
return resolve(realpathSync(ancestor), relative(ancestor, path));
|
|
847
|
+
} catch (error) {
|
|
848
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") return null;
|
|
849
|
+
return resolve(path);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
784
854
|
/**
|
|
785
855
|
* `history`'s self-footgun guard: writing the history report back into the
|
|
786
856
|
* very directory `history` reads would poison every later run (the report
|
|
@@ -788,6 +858,23 @@ function historyDirFrom(options, cwd) {
|
|
|
788
858
|
* non-`graph` snapshot). Declared by the command that owns the law and
|
|
789
859
|
* enforced by the driver's write door; `null` means no refusal.
|
|
790
860
|
*
|
|
861
|
+
* The decision is made on the PHYSICAL destination, not the string spelling:
|
|
862
|
+
* `resolve()` normalizes `..` segments but never resolves symlinks, so an
|
|
863
|
+
* alias of the history directory (`hist-alias -> hist`) makes `dirname` of
|
|
864
|
+
* the output differ from the directory argument while the write would land
|
|
865
|
+
* in the SAME directory. The read side of this command family already
|
|
866
|
+
* decides on the physical path — `readSnapshots`'s containment resolves the
|
|
867
|
+
* history directory through symlinks rather than comparing strings — and
|
|
868
|
+
* this guard now matches it: the realpath of the output's parent against the
|
|
869
|
+
* realpath of the history directory, falling back to the lexical `resolve()`
|
|
870
|
+
* result for components that do not exist yet. A destination that cannot be
|
|
871
|
+
* resolved at all (a symlink loop, an unreadable component) is refused: it
|
|
872
|
+
* cannot be PROVEN outside the history directory, and answering from the
|
|
873
|
+
* spelling would be the silent direction. Refusal is EQUALITY only: a
|
|
874
|
+
* report in a SUBDIRECTORY of the history directory is not read back
|
|
875
|
+
* (`readSnapshots` is non-recursive) and stays allowed. The resolved-string
|
|
876
|
+
* comparison runs first, unchanged.
|
|
877
|
+
*
|
|
791
878
|
* @param {{output: string|null, paths: string[]}} options This run's parsed
|
|
792
879
|
* flags.
|
|
793
880
|
* @param {string} cwd The run's working directory, for relative flag
|
|
@@ -805,13 +892,30 @@ export function historyOutputRefusal(options, cwd) {
|
|
|
805
892
|
? resolve(options.output)
|
|
806
893
|
: resolve(cwd, options.output);
|
|
807
894
|
const dir = historyDirFrom(options, cwd);
|
|
808
|
-
|
|
895
|
+
const refusal =
|
|
896
|
+
`archkeep: --output '${options.output}' is inside the history directory '${dir}' — ` +
|
|
897
|
+
`writing the report there would be read back as a snapshot on the next run. ` +
|
|
898
|
+
`Write it somewhere else.`;
|
|
899
|
+
// Fast path: the resolved string already names the history directory.
|
|
900
|
+
if (dirname(outputAbs) === dir) return refusal;
|
|
901
|
+
// Physical path: the spelling differs but the write would land in the
|
|
902
|
+
// history directory all the same — a symlink alias of it, for example. A
|
|
903
|
+
// `null` destination means the physical answer is unprovable (a symlink
|
|
904
|
+
// loop, an unreadable component): refusing IS the claim there, because a
|
|
905
|
+
// destination that cannot be resolved also cannot be proven outside the
|
|
906
|
+
// history directory.
|
|
907
|
+
const outputPhysical = physicalDestination(dirname(outputAbs));
|
|
908
|
+
const dirPhysical = physicalDestination(dir);
|
|
909
|
+
if (outputPhysical === null || dirPhysical === null) {
|
|
809
910
|
return (
|
|
810
|
-
`archkeep: --output '${options.output}'
|
|
811
|
-
`
|
|
812
|
-
`
|
|
911
|
+
`archkeep: --output '${options.output}' could not be resolved to a ` +
|
|
912
|
+
`physical destination, so it cannot be proven outside the history ` +
|
|
913
|
+
`directory '${dir}' — refusing rather than guessing from the spelling. ` +
|
|
914
|
+
`Check the path for symlink loops or unreadable components, and write ` +
|
|
915
|
+
`somewhere resolvable.`
|
|
813
916
|
);
|
|
814
917
|
}
|
|
918
|
+
if (outputPhysical === dirPhysical) return refusal;
|
|
815
919
|
return null;
|
|
816
920
|
}
|
|
817
921
|
|
package/src/containment.mjs
CHANGED
|
@@ -129,7 +129,7 @@ function within(root, absPath) {
|
|
|
129
129
|
* @param {(path: string) => {isSymbolicLink: () => boolean}} lstat
|
|
130
130
|
* @returns {string|null}
|
|
131
131
|
*/
|
|
132
|
-
function deepestExistingAncestor(absPath, lstat) {
|
|
132
|
+
export function deepestExistingAncestor(absPath, lstat) {
|
|
133
133
|
let probe = absPath;
|
|
134
134
|
for (;;) {
|
|
135
135
|
try {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"tool": {
|
|
4
4
|
"name": "@ecoma-io/archkeep",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.28.1"
|
|
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.1"
|
|
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.1"
|
|
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.1"
|
|
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.1"
|
|
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.1",
|
|
37
37
|
"provenance": {
|
|
38
38
|
"commit": "1fd51709c377d99a6139891e1200291cf08aede9",
|
|
39
39
|
"remote": null,
|
|
@@ -64,11 +64,12 @@ import {
|
|
|
64
64
|
* The zero-padded sequence number for the next event, taken from the highest
|
|
65
65
|
* existing event filename: `0000` for a fresh directory. An event log is
|
|
66
66
|
* zero-based — the first event is index 0 — unlike history's capture
|
|
67
|
-
* ordinals, which start at `0001`. The width widens from a four-digit
|
|
68
|
-
* rather than
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
67
|
+
* ordinals, which start at `0001`. The width widens from a four-digit
|
|
68
|
+
* minimum rather than being pinned to it: the sequence regex reads any
|
|
69
|
+
* width, so the parsed maximum keeps advancing past `9999`, and the width
|
|
70
|
+
* growing to exactly what the next number needs is what keeps repeated
|
|
71
|
+
* writes from clobbering one file — the sequence advances past every
|
|
72
|
+
* existing name, so no produced name repeats one that exists, on any width.
|
|
72
73
|
*
|
|
73
74
|
* @param {string[]} names Event filenames from the directory read.
|
|
74
75
|
* @returns {string} Zero-padded sequence, at least four digits.
|
|
@@ -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 {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* silent gap.
|
|
14
14
|
*
|
|
15
15
|
* Determinism: the metric order is fixed, and the trend rows are the snapshots
|
|
16
|
-
* in
|
|
16
|
+
* in history order (`../commands/history.mjs` orders them). This module
|
|
17
17
|
* decides nothing — a formatter that filtered would be a rule wearing a
|
|
18
18
|
* formatter's name (`../README.md`).
|
|
19
19
|
*
|