@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 +1 -1
- 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/report/health-text.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecoma-io/archkeep",
|
|
3
|
-
"version": "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",
|
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.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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
36
|
+
"toolVersion": "0.29.0",
|
|
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.
|
|
@@ -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
|
*
|