@ecoma-io/archkeep 0.28.0 → 0.29.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.28.0",
3
+ "version": "0.29.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",
@@ -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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.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.28.0"
48
+ "version": "0.29.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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.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.28.0"
34
+ "version": "0.29.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.28.0"
5
+ "version": "0.29.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.28.0",
36
+ "toolVersion": "0.29.0",
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.28.0"
5
+ "version": "0.29.0"
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.
@@ -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
  *