@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.
Files changed (37) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/analysis/jvm/gradle.mjs +23 -2
  4. package/src/commands/README.md +2 -1
  5. package/src/commands/history.mjs +123 -19
  6. package/src/containment.mjs +1 -1
  7. package/src/corpus/goldens/adr.json +1 -1
  8. package/src/corpus/goldens/change.json +2 -2
  9. package/src/corpus/goldens/check.json +1 -1
  10. package/src/corpus/goldens/context.json +1 -1
  11. package/src/corpus/goldens/debt.json +1 -1
  12. package/src/corpus/goldens/decisions.json +1 -1
  13. package/src/corpus/goldens/delta.json +2 -2
  14. package/src/corpus/goldens/diff.json +2 -2
  15. package/src/corpus/goldens/discover.json +1 -1
  16. package/src/corpus/goldens/drift.json +1 -1
  17. package/src/corpus/goldens/evolution.json +1 -1
  18. package/src/corpus/goldens/explain.json +1 -1
  19. package/src/corpus/goldens/fitness.json +1 -1
  20. package/src/corpus/goldens/graph.json +1 -1
  21. package/src/corpus/goldens/health.json +1 -1
  22. package/src/corpus/goldens/history.json +1 -1
  23. package/src/corpus/goldens/impact.json +1 -1
  24. package/src/corpus/goldens/provenance.json +1 -1
  25. package/src/corpus/goldens/reconcile.json +1 -1
  26. package/src/corpus/goldens/report.json +1 -1
  27. package/src/corpus/goldens/scenario.json +1 -1
  28. package/src/corpus/goldens/trajectory.json +1 -1
  29. package/src/corpus/goldens/waivers.json +1 -1
  30. package/src/governance/evolution-store.mjs +6 -5
  31. package/src/governance/provenance-graph.mjs +13 -9
  32. package/src/governance/provenance-record.mjs +22 -8
  33. package/src/graph/create-dependencies.mjs +39 -6
  34. package/src/intent/intent-manifest.json +3 -3
  35. package/src/providers/nx-static.mjs +24 -3
  36. package/src/providers/nx.mjs +98 -2
  37. 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, JavaScript, Vue, Java, Kotlin and C#, with Nx and Moon as first-class
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, layered modular
170
- monolith and DDD bounded contexts, as ready-made profile registries under
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.27.1",
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
- if (readFile(path) !== null && readFile(path) !== undefined) settingsFiles.push(path);
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
- const settingsText = readFile(settingsPath);
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({
@@ -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 filename byte-sort (history) order, and classifies
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
@@ -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, so filename byte-sort IS history order) — then
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 filename byte-sort;
38
- * a snapshot is replaced or deleted by moving its file; a `history` that
39
- * cannot make sense of the directory says so instead of guessing.
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 { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
75
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
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
- names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
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 widens from a four-digit minimum rather than overflowing: a
277
- * `10000` that padded to four digits would byte-sort *before* `9999-…` and
278
- * silently rewind history order, and the sequence regex would stop seeing the
279
- * 5-digit name so repeated captures would clobber the same file. Fresh
280
- * directories start at `0001`; each subsequent capture pads to at least the
281
- * width the next number needs, so the sequence always advances and no two
282
- * captures ever target the same file.
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
- if (dirname(outputAbs) === dir) {
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}' is inside the history directory '${dir}' ` +
811
- `writing the report there would be read back as a snapshot on the next run. ` +
812
- `Write it somewhere else.`
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
 
@@ -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.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "adr",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
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.27.1"
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.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "check",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "context",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "debt",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "decisions",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
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.27.1"
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.27.1"
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.27.1",
36
+ "toolVersion": "0.28.1",
37
37
  "provenance": {
38
38
  "commit": "1fd51709c377d99a6139891e1200291cf08aede9",
39
39
  "remote": null,
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "discover",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "drift",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "evolution",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "explain",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "fitness",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "graph",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "health",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "history",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "impact",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "provenance",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "reconcile",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "report",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "scenario",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "trajectory",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.1"
5
+ "version": "0.28.1"
6
6
  },
7
7
  "command": "waivers",
8
8
  "workspace": {
@@ -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 minimum
68
- * rather than overflowing, for the same reason history's `nextSequence`
69
- * documents: a `10000` padded to four digits would byte-sort before `9999-…`
70
- * and silently rewind the log, and the sequence regex would stop seeing the
71
- * 5-digit name so repeated writes would clobber one file.
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 both `buildProvenanceGraph` and the impact/scenario
9
- * evaluation callers use, so decision provenance is computed identically
10
- * everywhere — "via the same graph helper, never re-derived" (PR4).
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
- parentMap.set(nextId, currentId);
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
- endNode: `decision:${chainNodes[chainNodes.length - 1]}`,
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: clock.now()}`, and ONLY those three
151
- * keys — a fresh object, so nothing from untrusted input rides along.
150
+ * @returns {OriginRecord} `{by, tool, on}`, where `on` is the clock's one
151
+ * sampled answer, and ONLY those three keys — a fresh object, so nothing from
152
+ * untrusted input rides along.
152
153
  * @throws {Error} on an invalid author, an unusable clock, or a
153
154
  * non-string/empty clock answer.
154
155
  */
@@ -158,11 +159,24 @@ export function recordOrigin({ by, tool, clock }) {
158
159
  if (shape.length > 0) {
159
160
  throw new Error(shape.join("; "));
160
161
  }
161
- const clockProblems = clockViolations(clock);
162
- if (clockProblems.length > 0) {
163
- throw new Error(`origin.on: ${clockProblems.join("; ")}`);
162
+ // The clock is read exactly once and the read is what ships: sample it, judge
163
+ // the sample, emit it. `clockViolations` cannot render this verdict — it
164
+ // samples the clock itself, so delegating to it here would read the clock a
165
+ // second time, and that second read let a stateful clock answer validation
166
+ // with one instant and the record with another. The checks below restate its
167
+ // messages so a misused clock is still named in the shared vocabulary,
168
+ // read-free until the one sample exists.
169
+ if (clock === null || typeof clock !== "object") {
170
+ throw new Error(
171
+ `origin.on: clock must be an object with a now() function, got ${describe(clock)}`,
172
+ );
173
+ }
174
+ if (typeof clock.now !== "function") {
175
+ throw new Error("origin.on: clock.now must be a function returning a non-empty string");
176
+ }
177
+ const on = clock.now();
178
+ if (typeof on !== "string" || on.length === 0) {
179
+ throw new Error("origin.on: clock.now() must return a non-empty string");
164
180
  }
165
- // The clock is the single door, and it is called exactly once for this
166
- // record, so two calls with the same clock are byte-identical.
167
- return { by, tool, on: clock.now() };
181
+ return { by, tool, on };
168
182
  }
@@ -42,6 +42,20 @@
42
42
  * the rule; its malformed-TOML tolerance stays the documented exception its
43
43
  * own header pins (`../analysis/python.mjs`).
44
44
  *
45
+ * ## The hook's context contract (#843)
46
+ *
47
+ * `context.fileMap.projectFileMap` is validated before any resolver runs: a
48
+ * missing map, or a declared project with no key in it, throws. nx 23.x
49
+ * seeds a key for EVERY declared project before attributing a single file —
50
+ * `createFileMap` writes `projectFileMap[name] ??= []` (measured, nx 23.2.0
51
+ * `dist/src/project-graph/file-map-utils.js`) — so a legitimate zero-file or
52
+ * target-only project reads as an EMPTY ARRAY, never as an absent key. The
53
+ * refusal therefore has no legitimate shape to catch, and the state it
54
+ * replaces was the silent direction: a project whose manifests were never
55
+ * read contributes no edges, byte-for-byte identical to a workspace with
56
+ * nothing to find, while a project whose manifest was read and could not be
57
+ * parsed throws (#364). "Never looked" now fails like "looked and failed".
58
+ *
45
59
  * Resolver contract (see `../analysis/*.mjs`): every resolver returns raw Nx
46
60
  * edges — { source, target, sourceFile, type } and nothing else. Go, Rust and
47
61
  * Python take `resolve(projects, filesOf, readFile)`; the C# and JVM halves
@@ -188,12 +202,31 @@ export function resolveDeclaredManifestFailures(workspace) {
188
202
  */
189
203
  export const createDependencies = (options, context) => {
190
204
  resolveOptions(options);
191
- const projects = Object.entries(context.projects).map(([projectName, config]) => ({
192
- name: projectName,
193
- root: config.root,
194
- }));
195
- const filesOf = (projectName) =>
196
- (context.fileMap?.projectFileMap?.[projectName] ?? []).map((f) => f.file);
205
+ const projectFileMap = context.fileMap?.projectFileMap;
206
+ if (projectFileMap === null || typeof projectFileMap !== "object") {
207
+ throw new Error(
208
+ "archkeep: the Nx plugin context carries no fileMap.projectFileMap — no " +
209
+ "project's file universe is known, so no polyglot manifest can be read and " +
210
+ "no edge can be trusted. Refusing rather than computing a silently empty " +
211
+ "graph: nx 23.x seeds a projectFileMap key for every declared project, so a " +
212
+ "missing map is context-shape drift. Upgrade @ecoma-io/archkeep if a newer " +
213
+ "Nx moved the field.",
214
+ );
215
+ }
216
+ const projects = Object.entries(context.projects).map(([projectName, config]) => {
217
+ if (!Object.hasOwn(projectFileMap, projectName)) {
218
+ throw new Error(
219
+ `archkeep: project "${projectName}" (root "${config.root}") is declared in the ` +
220
+ `Nx plugin context but has no key in fileMap.projectFileMap — its manifests ` +
221
+ `would go unread and its edges undrawn, the under-selection this plugin ` +
222
+ `exists to close. nx 23.x maps every declared project, including projects ` +
223
+ `with no files (an empty array), so a missing key is context-shape drift. ` +
224
+ `Upgrade @ecoma-io/archkeep if a newer Nx moved the field.`,
225
+ );
226
+ }
227
+ return { name: projectName, root: config.root };
228
+ });
229
+ const filesOf = (projectName) => projectFileMap[projectName].map((f) => f.file);
197
230
  const readFile = (workspaceRelativePath) => {
198
231
  const abs = join(context.workspaceRoot, workspaceRelativePath);
199
232
  // Every value this reader is handed comes from the tree's own `fileMap` —
@@ -201,8 +201,8 @@
201
201
  {
202
202
  "type": "documentation",
203
203
  "path": "../../docs/concepts/agentic-development.md",
204
- "assertion": "Section 'What context and impact do not check' warns agents about the semantic gap",
205
- "sha256": "a42bca1e927a59d28160eb947a285c20d1a6cc9633a69c91a45e9d46bbe1686b"
204
+ "assertion": "Section 'What `context` and `impact` do not check' warns agents about the semantic gap",
205
+ "sha256": "81b1b57e19f3f5b4894f8273d33d731f80e6f8e80a513bd64208135852ed3bb8"
206
206
  }
207
207
  ],
208
208
  "status": "proven"
@@ -272,7 +272,7 @@
272
272
  "type": "documentation",
273
273
  "path": "../../docs/concepts/agentic-development.md",
274
274
  "assertion": "Section warns agents: violations:[] means allowed by constraint table, not free of all boundary violations",
275
- "sha256": "a42bca1e927a59d28160eb947a285c20d1a6cc9633a69c91a45e9d46bbe1686b"
275
+ "sha256": "81b1b57e19f3f5b4894f8273d33d731f80e6f8e80a513bd64208135852ed3bb8"
276
276
  }
277
277
  ],
278
278
  "status": "proven"
@@ -37,6 +37,12 @@
37
37
  * not blank the index — where `readProjectGraph` throws the identical refusal;
38
38
  * the two policies are the recorded difference between an acquisition that
39
39
  * still has a tree to index and one that does not.
40
+ *
41
+ * One refusal THROWS rather than skipping: a `package.json` beside a
42
+ * `project.json` that exists but cannot be read or parsed (#846). Falling
43
+ * through to the directory basename there would put the project in the graph
44
+ * under a name no constraint row names — a silently wrong identity. Absent
45
+ * (null) stays the legitimate basename fallback per Nx's own precedence.
40
46
  */
41
47
 
42
48
  import { readWorkspaceLayout, requireCompleteWorkspaceLayout } from "../options.mjs";
@@ -103,15 +109,30 @@ export function discoverProjects({ files, readFile }) {
103
109
  // Nx's own precedence: the name a project states, then the one its
104
110
  // `package.json` states, then the directory it lives in.
105
111
  const packageName = (() => {
106
- const manifest = readFile(root === "" ? "package.json" : `${root}/package.json`);
112
+ const pkgPath = root === "" ? "package.json" : `${root}/package.json`;
113
+ let manifest;
114
+ try {
115
+ manifest = readFile(pkgPath);
116
+ } catch (cause) {
117
+ throw new Error(
118
+ `package.json '${pkgPath}' beside project '${root || "."}' could not be read: ${cause?.message ?? cause}`,
119
+ { cause },
120
+ );
121
+ }
107
122
  if (manifest === null) return undefined;
108
123
  try {
109
124
  // The same parser, because Nx reads this file with the same
110
125
  // `readJsonFile` — a `package.json` Nx can name a project from must
111
126
  // not become a project named after its directory here.
112
127
  return parseProjectJson(manifest).name;
113
- } catch {
114
- return undefined;
128
+ } catch (cause) {
129
+ // An unreadable package.json that was READ must not fall through to
130
+ // the directory basename (#846) — that is a project the graph knows
131
+ // under one name and every constraint row names under another.
132
+ throw new Error(
133
+ `package.json '${pkgPath}' beside project '${root || "."}' could not be read: ${cause?.message ?? cause}`,
134
+ { cause },
135
+ );
115
136
  }
116
137
  })();
117
138
  const name =
@@ -34,6 +34,17 @@
34
34
  * afterwards (`../../cli.mjs`, via `annotateMFERemotes` and
35
35
  * `annotatePackageFacts`), read from disk the way upstream reads them — a
36
36
  * provider that filled them in would only be overwritten.
37
+ *
38
+ * The nodes that graph carries are validated at this seam, not trusted. Nx's
39
+ * own contract is narrow — `type` exactly one of `app`/`e2e`/`lib`, `data` an
40
+ * object with a string `root` and, when present, a `tags` array of non-empty
41
+ * strings — and every one of those fields is read verbatim by the rules layer
42
+ * (`../rules/specifiers.mjs`'s root mappings, `../rules/index.mjs`'s node-kind
43
+ * filter, `../rules/tags.mjs`'s constraint matching), so a drifted node is
44
+ * refused here by project name rather than judged into a wrong analysis: a
45
+ * rootless project silently drops out of every path lookup, a wrong kind
46
+ * silently skips its checks, a scalar `tags` silently matches or misses every
47
+ * tag row. See `readProjectGraph` below for the refusal itself.
37
48
  */
38
49
 
39
50
  import { mkdtempSync, readFileSync, rmSync } from "node:fs";
@@ -105,6 +116,81 @@ function nxCli({ resolveNx = () => require.resolve("nx/package.json") } = {}) {
105
116
  return join(dirname(manifest), typeof bin === "string" ? bin : bin.nx);
106
117
  }
107
118
 
119
+ /**
120
+ * Refuses any project node whose shape drifted from what Nx emits, by name.
121
+ *
122
+ * The command's output reaches `evaluate()` with no shape work in between, and
123
+ * every node field is read verbatim downstream: `data.root` by
124
+ * `../rules/specifiers.mjs`'s `createProjectRootMappings` (a missing root maps
125
+ * the project to `undefined`, and it silently drops out of every
126
+ * path-to-project lookup), `type` by `../rules/index.mjs`'s
127
+ * `isProjectGraphProjectNode` (anything outside `app`/`e2e`/`lib` is judged as
128
+ * an external node, silently skipping its boundary checks), `data.tags` by
129
+ * `../rules/tags.mjs` (a scalar silently matches or misses every constraint
130
+ * row). All four shapes are states `nx graph` does not emit — Nx 23.2.0's own
131
+ * `ProjectGraphProjectNode` carries exactly `type: "app"|"e2e"|"lib"` and a
132
+ * `data` configuration with a string `root` — so one here can only mean an Nx
133
+ * version drift or a defective producer, and it is refused, not guessed at:
134
+ * an unreadable entry refuses, it does not skip.
135
+ *
136
+ * @param {Record<string, object>} nodes The `graph.nodes` map as parsed.
137
+ * @throws {Error} when a node is not an object, its `type` is not one of
138
+ * `app`/`e2e`/`lib`, its `data` is not an object, its `data.root` is not a
139
+ * string, or its `data.tags` is present but not an array of non-empty
140
+ * strings. The error names the project and the offending field.
141
+ */
142
+ function validateProjectNodes(nodes) {
143
+ for (const [name, node] of Object.entries(nodes)) {
144
+ if (typeof node !== "object" || node === null) {
145
+ throw new Error(
146
+ `archkeep: \`nx graph\` node '${name}' is not an object — expected a project node ` +
147
+ `with type, name and data as Nx emits it.`,
148
+ );
149
+ }
150
+ if (node.type !== "app" && node.type !== "e2e" && node.type !== "lib") {
151
+ throw new Error(
152
+ `archkeep: \`nx graph\` node '${name}' has type "${node.type}" — expected one of ` +
153
+ `"app", "e2e", "lib". Anything else is judged as an external node and its boundary ` +
154
+ `checks are silently skipped.`,
155
+ );
156
+ }
157
+ if (typeof node.data !== "object" || node.data === null) {
158
+ throw new Error(
159
+ `archkeep: \`nx graph\` node '${name}' has no data object — expected data with a ` +
160
+ `workspace-relative root, and tags when the project carries any.`,
161
+ );
162
+ }
163
+ if (typeof node.data.root !== "string") {
164
+ throw new Error(
165
+ `archkeep: \`nx graph\` node '${name}' has no string data.root — expected the ` +
166
+ `workspace-relative project root as a string ("" is the workspace root). A rootless ` +
167
+ `project silently drops out of every path-to-project lookup and its imports are ` +
168
+ `judged with no owning project.`,
169
+ );
170
+ }
171
+ if (node.data.tags !== undefined) {
172
+ if (!Array.isArray(node.data.tags)) {
173
+ const got = node.data.tags === null ? "null" : typeof node.data.tags;
174
+ throw new Error(
175
+ `archkeep: \`nx graph\` node '${name}' has data.tags of type ${got} — expected an ` +
176
+ `array of non-empty strings. Tag rows are matched against this list verbatim, so a ` +
177
+ `scalar silently matches or misses every row.`,
178
+ );
179
+ }
180
+ for (const [index, tag] of node.data.tags.entries()) {
181
+ if (typeof tag !== "string" || tag === "") {
182
+ const got = tag === "" ? "an empty string" : `a ${typeof tag}`;
183
+ throw new Error(
184
+ `archkeep: \`nx graph\` node '${name}' has data.tags[${index}] that is not a ` +
185
+ `non-empty string (got ${got}) — expected an array of non-empty strings. A ` +
186
+ `non-string entry silently matches or misses every tag row it is compared with.`,
187
+ );
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+
108
194
  /**
109
195
  * The Nx project graph for `workspaceRoot`, in the shape `evaluate()` consumes.
110
196
  *
@@ -133,6 +219,12 @@ function nxCli({ resolveNx = () => require.resolve("nx/package.json") } = {}) {
133
219
  * agree on the same declared object rather than one merging onto a default
134
220
  * the other would have refused.
135
221
  *
222
+ * Each node in that graph is also validated against Nx's own contract —
223
+ * `type` one of `app`/`e2e`/`lib`, `data` an object, `data.root` a string,
224
+ * `data.tags` (when present) an array of non-empty strings — and a drifted
225
+ * node is refused by project name (`validateProjectNodes` below) rather than
226
+ * forwarded to rules that read it verbatim and would silently misjudge it.
227
+ *
136
228
  * @param {string} workspaceRoot
137
229
  * @param {{ run?: typeof runProcess, resolveNx?: () => string,
138
230
  * readLayout?: typeof readWorkspaceLayout }} [io]
@@ -140,6 +232,9 @@ function nxCli({ resolveNx = () => require.resolve("nx/package.json") } = {}) {
140
232
  * `workspaceLayout` read (see `../options.mjs`).
141
233
  * @returns {object} `{ nodes, dependencies }`, plus `workspaceLayout` when
142
234
  * `nx.json` declares a complete one.
235
+ * @throws {Error} when the emitted graph carries no `graph.nodes` map, or any
236
+ * node drifted from Nx's own shape — the error names the project and the
237
+ * field that refused it.
143
238
  */
144
239
  export function readProjectGraph(
145
240
  workspaceRoot,
@@ -150,12 +245,13 @@ export function readProjectGraph(
150
245
  try {
151
246
  run(process.execPath, [nxCli({ resolveNx }), "graph", `--file=${file}`], workspaceRoot);
152
247
  const { graph } = JSON.parse(readFileSync(file, "utf8"));
153
- if (!graph?.nodes) {
248
+ if (!graph?.nodes || typeof graph.nodes !== "object" || Array.isArray(graph.nodes)) {
154
249
  throw new Error(
155
- `archkeep: \`nx graph\` produced no \`graph.nodes\` in ${file} — ` +
250
+ `archkeep: \`nx graph\` produced no \`graph.nodes\` object in ${file} — ` +
156
251
  `nothing can be judged against a graph with no projects in it`,
157
252
  );
158
253
  }
254
+ validateProjectNodes(graph.nodes);
159
255
  const workspaceLayout = requireCompleteWorkspaceLayout(readLayout(workspaceRoot));
160
256
  return workspaceLayout === null ? graph : { ...graph, workspaceLayout };
161
257
  } finally {
@@ -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 byte-sort order (`../commands/history.mjs` orders them). This module
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
  *