@holmes-lab/holmes-kit 0.19.6 → 0.20.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/CHANGELOG.md +96 -0
- package/README.md +8 -1
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-status.d.ts +24 -0
- package/dist/holmes/cli/approve-status.js +166 -0
- package/dist/holmes/cli/approve-watch.d.ts +6 -0
- package/dist/holmes/cli/approve-watch.js +6 -0
- package/dist/holmes/cli/approve.d.ts +27 -0
- package/dist/holmes/cli/approve.js +68 -7
- package/dist/holmes/cli/cli-execution.d.ts +2 -0
- package/dist/holmes/cli/cli-execution.js +24 -0
- package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
- package/dist/holmes/cli/gitignore-merge.js +6 -2
- package/dist/holmes/cli/index.js +176 -139
- package/dist/holmes/cli/playbook-skills.d.ts +2 -22
- package/dist/holmes/cli/playbook-skills.js +10 -54
- package/dist/holmes/governance/approval-grants.d.ts +22 -0
- package/dist/holmes/governance/approval-grants.js +55 -1
- package/dist/holmes/governance/approval-queue.d.ts +43 -4
- package/dist/holmes/governance/approval-queue.js +86 -12
- package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
- package/dist/holmes/governance/display-id-aliases.js +86 -0
- package/dist/holmes/governance/ledger-store.d.ts +42 -14
- package/dist/holmes/governance/ledger-store.js +49 -3
- package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
- package/dist/holmes/governance/ledger-timeline.js +11 -1
- package/dist/holmes/governance/provenance-chain.d.ts +2 -0
- package/dist/holmes/governance/provenance-chain.js +13 -2
- package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
- package/dist/holmes/governance/provenance-ledger.js +69 -16
- package/dist/holmes/governance/session-context.d.ts +3 -0
- package/dist/holmes/governance/session-context.js +26 -8
- package/dist/holmes/guardrail/risk-classifier.js +2 -2
- package/dist/holmes/guardrail/write-target.js +2 -1
- package/dist/holmes/hooks/pre-tool-use.js +12 -1
- package/dist/holmes/hooks/stop.js +14 -3
- package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
- package/dist/holmes/mcp/handlers/adoption.js +186 -0
- package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
- package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
- package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
- package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
- package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
- package/dist/holmes/mcp/handlers/entity-store.js +212 -0
- package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
- package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
- package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
- package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
- package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
- package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
- package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
- package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
- package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
- package/dist/holmes/mcp/handlers/review-queries.js +79 -0
- package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
- package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +55 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.js +324 -0
- package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
- package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
- package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
- package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
- package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
- package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
- package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
- package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
- package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
- package/dist/holmes/mcp/handlers/test-execution.js +210 -0
- package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
- package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
- package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
- package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
- package/dist/holmes/mcp/handlers.d.ts +653 -424
- package/dist/holmes/mcp/handlers.js +267 -3250
- package/dist/holmes/mcp/server.js +2 -1
- package/dist/holmes/mcp/tool-schemas.js +43 -2
- package/dist/holmes/project/execution-context.d.ts +17 -0
- package/dist/holmes/project/execution-context.js +120 -0
- package/dist/holmes/project/installer-markers.d.ts +34 -0
- package/dist/holmes/project/installer-markers.js +65 -0
- package/dist/holmes/project/root.d.ts +3 -1
- package/dist/holmes/project/root.js +4 -3
- package/dist/holmes/project/workspace-identity.d.ts +29 -0
- package/dist/holmes/project/workspace-identity.js +181 -0
- package/dist/holmes/review/test-outcomes.d.ts +6 -1
- package/dist/holmes/review/test-outcomes.js +23 -5
- package/dist/holmes/review/test-runner.d.ts +18 -0
- package/dist/holmes/review/test-runner.js +132 -4
- package/dist/holmes/rtm/graph-store.d.ts +50 -1
- package/dist/holmes/rtm/graph-store.js +244 -3
- package/dist/holmes/rtm/incremental.d.ts +1 -0
- package/dist/holmes/rtm/incremental.js +12 -3
- package/dist/holmes/rtm/localize.js +7 -0
- package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
- package/dist/holmes/rtm/rtm-graph.js +13 -0
- package/dist/holmes/semantic/vector-cache.d.ts +16 -1
- package/dist/holmes/semantic/vector-cache.js +155 -19
- package/dist/holmes/spec/approval-status.d.ts +10 -0
- package/dist/holmes/spec/approval-status.js +7 -3
- package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
- package/dist/holmes/spec/entity-git-snapshot.js +276 -0
- package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
- package/dist/holmes/spec/entity-integration-discharge.js +233 -0
- package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
- package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
- package/dist/holmes/spec/entity-integration.d.ts +207 -0
- package/dist/holmes/spec/entity-integration.js +747 -0
- package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
- package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
- package/dist/holmes/spec/entity-renumber.d.ts +32 -0
- package/dist/holmes/spec/entity-renumber.js +156 -0
- package/dist/holmes/spec/entity-store.d.ts +135 -0
- package/dist/holmes/spec/entity-store.js +1051 -0
- package/dist/holmes/spec/entity-transaction.d.ts +89 -0
- package/dist/holmes/spec/entity-transaction.js +701 -0
- package/dist/holmes/spec/renumber.d.ts +58 -0
- package/dist/holmes/spec/renumber.js +200 -1
- package/dist/holmes/spec/spec-store.d.ts +3 -2
- package/dist/holmes/spec/spec-store.js +23 -2
- package/dist/holmes/spec/transition-policy.d.ts +36 -0
- package/dist/holmes/spec/transition-policy.js +135 -0
- package/package.json +1 -1
|
@@ -213,6 +213,15 @@ export declare class RtmGraph {
|
|
|
213
213
|
* re-resolving is the only way an incremental update lands on the full-rebuild answer.
|
|
214
214
|
*/
|
|
215
215
|
removeEdgesByRel(rel: string): void;
|
|
216
|
+
/**
|
|
217
|
+
* @implements A-SPEC-644
|
|
218
|
+
* Drop every node of one kind and every edge touching one. FILE nodes exist only as import endpoints
|
|
219
|
+
* and are owned by whichever file's import first resolved to them, so `removeBySource` cannot reach
|
|
220
|
+
* the ones another file owns — measured, deleting an import target left a stale FILE node for the
|
|
221
|
+
* importer. Clearing the kind and re-running the whole-scan import pass is the only way an
|
|
222
|
+
* incremental update lands on the full-rebuild value, exactly as `removeEdgesByRel` does for calls.
|
|
223
|
+
*/
|
|
224
|
+
removeNodesByKind(kind: string): void;
|
|
216
225
|
/** @implements A-SPEC-282 — every stored meta key/value. */
|
|
217
226
|
readMeta(): Record<string, string>;
|
|
218
227
|
/** @implements A-SPEC-282 — replace the stored meta wholesale, so a stale key cannot survive. */
|
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.RtmGraph = void 0;
|
|
7
7
|
// @implements A-SPEC-293
|
|
8
|
+
// @implements A-SPEC-644
|
|
8
9
|
// @implements A-SPEC-289
|
|
9
10
|
// @implements A-SPEC-288
|
|
10
11
|
// @implements A-SPEC-283
|
|
@@ -425,6 +426,18 @@ class RtmGraph {
|
|
|
425
426
|
removeEdgesByRel(rel) {
|
|
426
427
|
this.db.prepare('DELETE FROM edges WHERE rel = ?').run(rel);
|
|
427
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* @implements A-SPEC-644
|
|
431
|
+
* Drop every node of one kind and every edge touching one. FILE nodes exist only as import endpoints
|
|
432
|
+
* and are owned by whichever file's import first resolved to them, so `removeBySource` cannot reach
|
|
433
|
+
* the ones another file owns — measured, deleting an import target left a stale FILE node for the
|
|
434
|
+
* importer. Clearing the kind and re-running the whole-scan import pass is the only way an
|
|
435
|
+
* incremental update lands on the full-rebuild value, exactly as `removeEdgesByRel` does for calls.
|
|
436
|
+
*/
|
|
437
|
+
removeNodesByKind(kind) {
|
|
438
|
+
this.db.prepare('DELETE FROM edges WHERE src IN (SELECT id FROM nodes WHERE kind = ?) OR dst IN (SELECT id FROM nodes WHERE kind = ?)').run(kind, kind);
|
|
439
|
+
this.db.prepare('DELETE FROM nodes WHERE kind = ?').run(kind);
|
|
440
|
+
}
|
|
428
441
|
/** @implements A-SPEC-282 — every stored meta key/value. */
|
|
429
442
|
readMeta() {
|
|
430
443
|
const rows = this.db.prepare('SELECT key, value FROM meta').all();
|
|
@@ -1,11 +1,26 @@
|
|
|
1
|
+
/** Segment count at which a flushing writer attempts compaction (A-SPEC-630). */
|
|
2
|
+
export declare const COMPACTION_THRESHOLD = 16;
|
|
3
|
+
/** Age after which a compaction lock or an abandoned `.tmp` is considered orphaned by a dead writer. */
|
|
4
|
+
export declare const STALE_MS: number;
|
|
1
5
|
export declare class VectorCache {
|
|
2
6
|
private readonly file;
|
|
7
|
+
private readonly dir;
|
|
8
|
+
private readonly lock;
|
|
3
9
|
private store;
|
|
10
|
+
private readonly dirty;
|
|
4
11
|
constructor(root: string);
|
|
5
12
|
private load;
|
|
6
13
|
private key;
|
|
7
14
|
get(text: string, modelTag: string, kind: 'query' | 'doc'): number[] | null;
|
|
15
|
+
/** Stores in memory and marks the key for the next flush. A non-vector (empty or non-finite) is never stored. */
|
|
8
16
|
put(text: string, modelTag: string, kind: 'query' | 'doc', vector: number[]): void;
|
|
9
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Publish the entries put since the last flush as one immutable segment (unique tmp + rename), then
|
|
19
|
+
* compact if the segment count reached the threshold. Nothing dirty → nothing written. Publication
|
|
20
|
+
* errors propagate as before; compaction errors never do.
|
|
21
|
+
*/
|
|
10
22
|
flush(): void;
|
|
23
|
+
private acquireLock;
|
|
24
|
+
/** Merge base + segments FROM DISK into a new base under the lock; any failure leaves the segments in place. */
|
|
25
|
+
private compactIfNeeded;
|
|
11
26
|
}
|
|
@@ -33,41 +33,117 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.VectorCache = void 0;
|
|
36
|
+
exports.VectorCache = exports.STALE_MS = exports.COMPACTION_THRESHOLD = void 0;
|
|
37
37
|
// @implements A-SPEC-476
|
|
38
|
+
// @implements A-SPEC-630
|
|
38
39
|
/**
|
|
39
40
|
* Content-addressed vector cache: never compute the same embedding twice, never serve a stale
|
|
40
|
-
* vector across a model update.
|
|
41
|
+
* vector across a model update, and never let one writer erase another.
|
|
41
42
|
*
|
|
42
43
|
* The key is `hash(text) : modelTag : kind`. The model TAG (id plus a caller-supplied version
|
|
43
44
|
* label) is load-bearing for the cloud tier — an API model can be re-deployed silently, and
|
|
44
45
|
* "same text, different vector" across that boundary would corrupt every similarity the product
|
|
45
46
|
* computes. A tag change is a cache miss by construction.
|
|
46
47
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
48
|
+
* Persistence (A-SPEC-630) — segments, not snapshots. Every Holmes process that touches a
|
|
49
|
+
* workspace opens its own instance, and independent clones/worktrees/runs do so at the same time.
|
|
50
|
+
* The previous layout loaded one private snapshot and wrote it back whole through a fixed `.tmp`,
|
|
51
|
+
* so two writers erased each other (measured: A puts, B puts, A flushes, B flushes → A is gone)
|
|
52
|
+
* and simultaneous writers crashed on the shared temporary name. Now:
|
|
53
|
+
*
|
|
54
|
+
* .ax/cpg_cache/semantic-vectors.json the base — legacy file, rewritten ONLY by compaction
|
|
55
|
+
* .ax/cpg_cache/semantic-vectors.d/<ts>-<pid>-<rnd>.json one immutable segment per flush, holding only
|
|
56
|
+
* the entries THIS instance put since its last flush
|
|
57
|
+
* .ax/cpg_cache/semantic-vectors.d/*.tmp a file still being written — readers never open it
|
|
58
|
+
* .ax/cpg_cache/semantic-vectors.d/.compact.lock/ mkdir-owned compaction; stale after STALE_MS
|
|
59
|
+
*
|
|
60
|
+
* A reader merges base then segments in ascending name order (later wins), validating every entry:
|
|
61
|
+
* anything that is not a non-empty array of finite numbers is a miss, a file that does not parse
|
|
62
|
+
* contributes nothing, and load never throws. Publication is a unique tmp + rename, so a writer
|
|
63
|
+
* killed at any point leaves committed files untouched. Compaction is opportunistic: once the
|
|
64
|
+
* segment count reaches COMPACTION_THRESHOLD the flushing writer takes the lock, re-reads base and
|
|
65
|
+
* segments FROM DISK, writes the merged object over the base and deletes exactly the segments it
|
|
66
|
+
* merged. Its only failure mode is "not compacted" — never "lost".
|
|
67
|
+
*
|
|
68
|
+
* Measured (2026-09-13, macOS arm64, 5,000 × 1,024 floats = 46 MB): one file loads in 139 ms,
|
|
69
|
+
* 8–256 segments in 132–141 ms, per-entry files in 209 ms (and 407 ms to write vs 153 ms) —
|
|
70
|
+
* which is why the unit of publication is a flush, not an entry.
|
|
71
|
+
*
|
|
72
|
+
* The cache stays LOSABLE: it lives under `.ax/cpg_cache/` (derived, gitignored, protected — no
|
|
73
|
+
* gate surface moves) and recomputing is cost, not damage.
|
|
50
74
|
*/
|
|
51
75
|
const fs = __importStar(require("node:fs"));
|
|
52
76
|
const path = __importStar(require("node:path"));
|
|
53
77
|
const node_crypto_1 = require("node:crypto");
|
|
54
78
|
const FILE = path.join('.ax', 'cpg_cache', 'semantic-vectors.json');
|
|
79
|
+
const SEGMENT_DIR = path.join('.ax', 'cpg_cache', 'semantic-vectors.d');
|
|
80
|
+
const LOCK_DIR = '.compact.lock';
|
|
81
|
+
/** Segment count at which a flushing writer attempts compaction (A-SPEC-630). */
|
|
82
|
+
exports.COMPACTION_THRESHOLD = 16;
|
|
83
|
+
/** Age after which a compaction lock or an abandoned `.tmp` is considered orphaned by a dead writer. */
|
|
84
|
+
exports.STALE_MS = 10 * 60_000;
|
|
85
|
+
const isVector = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === 'number' && Number.isFinite(x));
|
|
86
|
+
/** Parse one cache file into its valid entries; null when the file is unreadable, unparseable or not an object. */
|
|
87
|
+
function readValid(file) {
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
96
|
+
return null;
|
|
97
|
+
const out = {};
|
|
98
|
+
for (const [k, v] of Object.entries(parsed))
|
|
99
|
+
if (isVector(v))
|
|
100
|
+
out[k] = v;
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
function listSegments(dir) {
|
|
104
|
+
try {
|
|
105
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function isStale(p) {
|
|
112
|
+
try {
|
|
113
|
+
return Date.now() - fs.statSync(p).mtimeMs > exports.STALE_MS;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Segment names sort by (timestamp, pid, random). The timestamp is made strictly increasing within a
|
|
120
|
+
// process so two flushes in the same millisecond keep a deterministic order — "later wins" must mean
|
|
121
|
+
// the later flush, not the luckier random suffix.
|
|
122
|
+
let lastStamp = 0;
|
|
123
|
+
function segmentName() {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
lastStamp = now > lastStamp ? now : lastStamp + 1;
|
|
126
|
+
return `${String(lastStamp).padStart(15, '0')}-${String(process.pid).padStart(8, '0')}-${(0, node_crypto_1.randomBytes)(4).toString('hex')}.json`;
|
|
127
|
+
}
|
|
55
128
|
class VectorCache {
|
|
56
129
|
file;
|
|
130
|
+
dir;
|
|
131
|
+
lock;
|
|
57
132
|
store = null;
|
|
133
|
+
dirty = new Set();
|
|
58
134
|
constructor(root) {
|
|
59
135
|
this.file = path.join(root, FILE);
|
|
136
|
+
this.dir = path.join(root, SEGMENT_DIR);
|
|
137
|
+
this.lock = path.join(this.dir, LOCK_DIR);
|
|
60
138
|
}
|
|
61
139
|
load() {
|
|
62
140
|
if (this.store !== null)
|
|
63
141
|
return this.store;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
return this.store;
|
|
142
|
+
const merged = readValid(this.file) ?? {};
|
|
143
|
+
for (const s of listSegments(this.dir))
|
|
144
|
+
Object.assign(merged, readValid(path.join(this.dir, s)) ?? {});
|
|
145
|
+
this.store = merged;
|
|
146
|
+
return merged;
|
|
71
147
|
}
|
|
72
148
|
key(text, modelTag, kind) {
|
|
73
149
|
return `${(0, node_crypto_1.createHash)('sha256').update(text).digest('hex').slice(0, 24)}:${modelTag}:${kind}`;
|
|
@@ -75,17 +151,77 @@ class VectorCache {
|
|
|
75
151
|
get(text, modelTag, kind) {
|
|
76
152
|
return this.load()[this.key(text, modelTag, kind)] ?? null;
|
|
77
153
|
}
|
|
154
|
+
/** Stores in memory and marks the key for the next flush. A non-vector (empty or non-finite) is never stored. */
|
|
78
155
|
put(text, modelTag, kind, vector) {
|
|
79
|
-
|
|
156
|
+
if (!isVector(vector))
|
|
157
|
+
return;
|
|
158
|
+
const k = this.key(text, modelTag, kind);
|
|
159
|
+
this.load()[k] = vector;
|
|
160
|
+
this.dirty.add(k);
|
|
80
161
|
}
|
|
81
|
-
/**
|
|
162
|
+
/**
|
|
163
|
+
* Publish the entries put since the last flush as one immutable segment (unique tmp + rename), then
|
|
164
|
+
* compact if the segment count reached the threshold. Nothing dirty → nothing written. Publication
|
|
165
|
+
* errors propagate as before; compaction errors never do.
|
|
166
|
+
*/
|
|
82
167
|
flush() {
|
|
83
|
-
if (this.store === null)
|
|
168
|
+
if (this.store === null || this.dirty.size === 0)
|
|
84
169
|
return;
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
fs.
|
|
170
|
+
const entries = {};
|
|
171
|
+
for (const k of this.dirty)
|
|
172
|
+
entries[k] = this.store[k];
|
|
173
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
174
|
+
const name = segmentName();
|
|
175
|
+
const tmp = path.join(this.dir, `${name}.tmp`);
|
|
176
|
+
fs.writeFileSync(tmp, JSON.stringify(entries));
|
|
177
|
+
fs.renameSync(tmp, path.join(this.dir, name));
|
|
178
|
+
this.dirty.clear();
|
|
179
|
+
this.compactIfNeeded();
|
|
180
|
+
}
|
|
181
|
+
acquireLock() {
|
|
182
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
183
|
+
try {
|
|
184
|
+
fs.mkdirSync(this.lock);
|
|
185
|
+
fs.writeFileSync(path.join(this.lock, 'owner.json'), JSON.stringify({ pid: process.pid, ts: new Date().toISOString() }));
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
if (e.code !== 'EEXIST' || !isStale(this.lock))
|
|
190
|
+
return false;
|
|
191
|
+
fs.rmSync(this.lock, { recursive: true, force: true }); // orphaned by a dead writer — break it once
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
/** Merge base + segments FROM DISK into a new base under the lock; any failure leaves the segments in place. */
|
|
197
|
+
compactIfNeeded() {
|
|
198
|
+
try {
|
|
199
|
+
if (listSegments(this.dir).length < exports.COMPACTION_THRESHOLD)
|
|
200
|
+
return;
|
|
201
|
+
if (!this.acquireLock())
|
|
202
|
+
return;
|
|
203
|
+
try {
|
|
204
|
+
const segments = listSegments(this.dir);
|
|
205
|
+
const merged = readValid(this.file) ?? {};
|
|
206
|
+
for (const s of segments)
|
|
207
|
+
Object.assign(merged, readValid(path.join(this.dir, s)) ?? {});
|
|
208
|
+
const tmp = path.join(this.dir, `base-${segmentName()}.tmp`);
|
|
209
|
+
fs.writeFileSync(tmp, JSON.stringify(merged));
|
|
210
|
+
fs.renameSync(tmp, this.file);
|
|
211
|
+
for (const s of segments)
|
|
212
|
+
fs.rmSync(path.join(this.dir, s), { force: true });
|
|
213
|
+
for (const f of fs.readdirSync(this.dir)) {
|
|
214
|
+
if (f.endsWith('.tmp') && isStale(path.join(this.dir, f)))
|
|
215
|
+
fs.rmSync(path.join(this.dir, f), { force: true });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
fs.rmSync(this.lock, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Not compacted. Every committed segment is still there; a later flush will try again.
|
|
224
|
+
}
|
|
89
225
|
}
|
|
90
226
|
}
|
|
91
227
|
exports.VectorCache = VectorCache;
|
|
@@ -18,6 +18,16 @@ export interface ApprovalStatus {
|
|
|
18
18
|
parents: ParentApproval[];
|
|
19
19
|
/** the concrete reasons this spec cannot be approved right now (empty once approved). */
|
|
20
20
|
blockers: string[];
|
|
21
|
+
/**
|
|
22
|
+
* @implements A-SPEC-641 — what a RE-SEAL of this document as it stands would be refused on, by the
|
|
23
|
+
* gate's predicate (`approvalBlockers`): the same list as `blockers` for a draft, and for an approved
|
|
24
|
+
* document the reasons spec_approve, entity_renumber, entity_integrate and store recovery would refuse
|
|
25
|
+
* to seal it again. Measured 2026-09-14: a hand-sealed same-kind chain read `blockers: []` here while
|
|
26
|
+
* every re-seal refused it; 487 of 2,215 approved documents in this repository carry such a duty.
|
|
27
|
+
* Act-only judgements (duplicate ids, REQ acceptance substance, the OS-content cross-check that
|
|
28
|
+
* needs a root) are not included.
|
|
29
|
+
*/
|
|
30
|
+
resealBlockers: string[];
|
|
21
31
|
}
|
|
22
32
|
/**
|
|
23
33
|
* @implements A-SPEC-538.2
|
|
@@ -18,9 +18,12 @@ function describeApproval(spec, resolve) {
|
|
|
18
18
|
? { id: pid, status: parent.status, sealed: !!(0, spec_digest_1.sealOf)(parent).approvedDigest, resolved: true }
|
|
19
19
|
: { id: pid, status: 'missing', sealed: false, resolved: false };
|
|
20
20
|
});
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
21
|
+
// `blockers` keeps its meaning — what stands between this document and its approval — so an
|
|
22
|
+
// approved document reports none. The same predicate evaluated on the document AS IT STANDS is
|
|
23
|
+
// what a re-seal would be refused on; a document sealed by hand never passed it, and the acts
|
|
24
|
+
// that re-seal (renumber, integration, re-approval) judge exactly this list (A-SPEC-641).
|
|
25
|
+
const resealBlockers = (0, approval_blockers_1.approvalBlockers)(spec, resolve);
|
|
26
|
+
const blockers = spec.status === 'approved' ? [] : resealBlockers;
|
|
24
27
|
return {
|
|
25
28
|
id: spec.id,
|
|
26
29
|
type: spec.type,
|
|
@@ -29,5 +32,6 @@ function describeApproval(spec, resolve) {
|
|
|
29
32
|
approvedDigest: seal.approvedDigest,
|
|
30
33
|
parents,
|
|
31
34
|
blockers,
|
|
35
|
+
resealBlockers,
|
|
32
36
|
};
|
|
33
37
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface EntityGitSnapshot {
|
|
2
|
+
schema: 'holmes-entity-git-snapshot/1';
|
|
3
|
+
commit: string;
|
|
4
|
+
tree: string;
|
|
5
|
+
workspacePrefix: string;
|
|
6
|
+
workspaceId: string;
|
|
7
|
+
storeId: string;
|
|
8
|
+
storeLocator: string;
|
|
9
|
+
/** Names occupied outside the selected store; immutable reads always populate this inventory. */
|
|
10
|
+
otherStoreDisplayIds?: string[];
|
|
11
|
+
entities: Array<{
|
|
12
|
+
entityId: string;
|
|
13
|
+
displayId: string;
|
|
14
|
+
documentLocator: string;
|
|
15
|
+
recordLocator: string;
|
|
16
|
+
documentBlob: string;
|
|
17
|
+
recordBlob: string;
|
|
18
|
+
document: string;
|
|
19
|
+
record: string;
|
|
20
|
+
}>;
|
|
21
|
+
files: Array<{
|
|
22
|
+
path: string;
|
|
23
|
+
mode: string;
|
|
24
|
+
oid: string;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
export interface EntityGitInputs {
|
|
28
|
+
base: EntityGitSnapshot;
|
|
29
|
+
left: EntityGitSnapshot;
|
|
30
|
+
right: EntityGitSnapshot;
|
|
31
|
+
}
|
|
32
|
+
/** Version of the workspace-scoped logical stage inventory, not the on-disk index bytes/stat cache. */
|
|
33
|
+
export declare function readEntityGitIndex(root: string): {
|
|
34
|
+
version: string;
|
|
35
|
+
entries: Array<{
|
|
36
|
+
path: string;
|
|
37
|
+
mode: string;
|
|
38
|
+
oid: string;
|
|
39
|
+
stage: number;
|
|
40
|
+
}>;
|
|
41
|
+
};
|
|
42
|
+
/** Binary-safe object reads; names, paths and replace refs cannot redirect an OID request. */
|
|
43
|
+
export declare function readEntityGitBlobs(root: string, oids: string[]): Map<string, Buffer>;
|
|
44
|
+
export declare function readEntityGitInputs(root: string, revisions: {
|
|
45
|
+
base: string;
|
|
46
|
+
left: string;
|
|
47
|
+
right: string;
|
|
48
|
+
}, storeLocator: string): EntityGitInputs;
|
|
49
|
+
/** Read immutable objects only; dirty files, local identity and Git replace refs are not input truth. */
|
|
50
|
+
export declare function readEntityGitSnapshot(root: string, revision: string, storeLocator: string): EntityGitSnapshot;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.readEntityGitIndex = readEntityGitIndex;
|
|
37
|
+
exports.readEntityGitBlobs = readEntityGitBlobs;
|
|
38
|
+
exports.readEntityGitInputs = readEntityGitInputs;
|
|
39
|
+
exports.readEntityGitSnapshot = readEntityGitSnapshot;
|
|
40
|
+
// @implements A-SPEC-629.1, A-SPEC-629
|
|
41
|
+
// @implements A-SPEC-636
|
|
42
|
+
// @implements A-SPEC-637
|
|
43
|
+
const fs = __importStar(require("node:fs"));
|
|
44
|
+
const node_child_process_1 = require("node:child_process");
|
|
45
|
+
const node_crypto_1 = require("node:crypto");
|
|
46
|
+
const spec_parser_1 = require("./spec-parser");
|
|
47
|
+
const entity_transaction_1 = require("./entity-transaction");
|
|
48
|
+
/** Version of the workspace-scoped logical stage inventory, not the on-disk index bytes/stat cache. */
|
|
49
|
+
function readEntityGitIndex(root) {
|
|
50
|
+
const git = gitReader(root);
|
|
51
|
+
const prefix = utf8(git(['rev-parse', '--show-prefix'])).replace(/\n$/, '');
|
|
52
|
+
if (prefix && (!prefix.endsWith('/') || !locator(prefix.slice(0, -1))))
|
|
53
|
+
fail('Unsafe workspace prefix.');
|
|
54
|
+
const bytes = git(['ls-files', '--stage', '-z', '--full-name']);
|
|
55
|
+
const entries = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
for (const entry of utf8(bytes).split('\0')) {
|
|
58
|
+
if (!entry)
|
|
59
|
+
continue;
|
|
60
|
+
const tab = entry.indexOf('\t'), header = entry.slice(0, tab).split(' '), fullPath = entry.slice(tab + 1);
|
|
61
|
+
if (tab < 0 || header.length !== 3 || !/^[0-7]{6}$/.test(header[0]) || !OID.test(header[1]) || !/^[0-3]$/.test(header[2]) || !locator(fullPath) || !fullPath.startsWith(prefix))
|
|
62
|
+
fail('Malformed Git index inventory.');
|
|
63
|
+
const file = fullPath.slice(prefix.length), key = header[2] + ':' + file;
|
|
64
|
+
if (!locator(file) || seen.has(key))
|
|
65
|
+
fail('Unsafe or duplicate Git index entry.');
|
|
66
|
+
seen.add(key);
|
|
67
|
+
entries.push({ path: file, mode: header[0], oid: header[1], stage: Number(header[2]) });
|
|
68
|
+
}
|
|
69
|
+
return { version: 'sha256:' + (0, node_crypto_1.createHash)('sha256').update(prefix).update('\0').update(bytes).digest('hex'), entries };
|
|
70
|
+
}
|
|
71
|
+
/** Binary-safe object reads; names, paths and replace refs cannot redirect an OID request. */
|
|
72
|
+
function readEntityGitBlobs(root, oids) {
|
|
73
|
+
if (!Array.isArray(oids) || oids.some(oid => typeof oid !== 'string' || !OID.test(oid)))
|
|
74
|
+
fail('Git blob reads require immutable object OIDs.');
|
|
75
|
+
const ids = [...new Set(oids)], blobs = new Map();
|
|
76
|
+
if (!ids.length)
|
|
77
|
+
return blobs;
|
|
78
|
+
const output = gitReader(root)(['cat-file', '--batch'], ids.join('\n') + '\n');
|
|
79
|
+
let offset = 0;
|
|
80
|
+
for (const oid of ids) {
|
|
81
|
+
const end = output.indexOf(10, offset);
|
|
82
|
+
if (end < 0)
|
|
83
|
+
fail('Truncated Git blob response.');
|
|
84
|
+
const header = output.subarray(offset, end).toString('ascii').split(' '), size = Number(header[2]);
|
|
85
|
+
if (header.length !== 3 || header[0] !== oid || header[1] !== 'blob' || !Number.isSafeInteger(size) || size < 0 || end + 1 + size >= output.length || output[end + 1 + size] !== 10)
|
|
86
|
+
fail('Invalid or missing Git blob response.');
|
|
87
|
+
blobs.set(oid, output.subarray(end + 1, end + 1 + size));
|
|
88
|
+
offset = end + size + 2;
|
|
89
|
+
}
|
|
90
|
+
if (offset !== output.length)
|
|
91
|
+
fail('Unexpected trailing Git blob response.');
|
|
92
|
+
return blobs;
|
|
93
|
+
}
|
|
94
|
+
function readEntityGitInputs(root, revisions, storeLocator) {
|
|
95
|
+
if (!revisions || typeof revisions !== 'object' || Object.keys(revisions).length !== 3 || !['base', 'left', 'right'].every(key => Object.prototype.hasOwnProperty.call(revisions, key)))
|
|
96
|
+
fail('Expected exactly base, left and right revisions.');
|
|
97
|
+
const git = gitReader(root);
|
|
98
|
+
// Pin all names before loading any tree; subsequent checks never resolve the names again.
|
|
99
|
+
const commits = { base: resolveCommit(git, revisions.base), left: resolveCommit(git, revisions.left), right: resolveCommit(git, revisions.right) };
|
|
100
|
+
for (const side of [commits.left, commits.right])
|
|
101
|
+
git(['merge-base', '--is-ancestor', commits.base, side], undefined, 'Selected base is not a proven ancestor of both Git inputs.');
|
|
102
|
+
// @implements A-SPEC-636 — a refusal caused by one input names that side and its commit.
|
|
103
|
+
const sideSnapshot = (side) => {
|
|
104
|
+
try {
|
|
105
|
+
return readEntityGitSnapshot(root, commits[side], storeLocator);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error instanceof entity_transaction_1.EntityStoreError && error.code === 'invalid-git-snapshot')
|
|
109
|
+
throw new entity_transaction_1.EntityStoreError('invalid-git-snapshot', side + ' ' + commits[side] + ': ' + error.message);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const inputs = { base: sideSnapshot('base'), left: sideSnapshot('left'), right: sideSnapshot('right') };
|
|
114
|
+
for (const side of [inputs.left, inputs.right]) {
|
|
115
|
+
if (side.workspaceId !== inputs.base.workspaceId)
|
|
116
|
+
fail('Git inputs have different canonical workspace identity.');
|
|
117
|
+
if (side.storeId !== inputs.base.storeId)
|
|
118
|
+
fail('Git inputs have different canonical store identity.');
|
|
119
|
+
}
|
|
120
|
+
return inputs;
|
|
121
|
+
}
|
|
122
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
123
|
+
const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
124
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
125
|
+
function fail(message) { throw new entity_transaction_1.EntityStoreError('invalid-git-snapshot', message); }
|
|
126
|
+
function locator(value) {
|
|
127
|
+
return typeof value === 'string' && value.length > 0 && !/[\\:\x00]/.test(value) && value.split('/').every(part => part && part !== '.' && part !== '..');
|
|
128
|
+
}
|
|
129
|
+
function uuid(value) { return typeof value === 'string' && UUID.test(value); }
|
|
130
|
+
function object(text, keys) {
|
|
131
|
+
let value;
|
|
132
|
+
try {
|
|
133
|
+
value = JSON.parse(text);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return fail('Canonical record contains invalid JSON.');
|
|
137
|
+
}
|
|
138
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length || keys.some(key => !Object.prototype.hasOwnProperty.call(value, key)))
|
|
139
|
+
fail('Unsupported canonical record schema or fields.');
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
function utf8(bytes) {
|
|
143
|
+
const text = bytes.toString('utf8');
|
|
144
|
+
if (!Buffer.from(text, 'utf8').equals(bytes))
|
|
145
|
+
fail('Unsupported non-UTF-8 canonical bytes or path.');
|
|
146
|
+
return text;
|
|
147
|
+
}
|
|
148
|
+
function gitReader(root) {
|
|
149
|
+
const cwd = fs.realpathSync(root);
|
|
150
|
+
// A caller's Git environment must not redirect a root-bound read to another repository or index.
|
|
151
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
|
|
152
|
+
env.GIT_NO_REPLACE_OBJECTS = '1';
|
|
153
|
+
env.GIT_OPTIONAL_LOCKS = '0';
|
|
154
|
+
return (args, input, failure = 'Git snapshot read failed: unavailable revision/object, unsupported repository or 64 MiB response limit.') => {
|
|
155
|
+
try {
|
|
156
|
+
return (0, node_child_process_1.execFileSync)('git', ['-C', cwd, ...args], { env, input, maxBuffer: 64 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return fail(failure);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function resolveCommit(git, revision) {
|
|
164
|
+
if (typeof revision !== 'string' || !revision || /^-/.test(revision) || /[\x00\r\n]/.test(revision))
|
|
165
|
+
fail('Unsafe Git revision.');
|
|
166
|
+
const commit = utf8(git(['rev-parse', '--verify', '--end-of-options', revision + '^{commit}'])).trim();
|
|
167
|
+
if (!OID.test(commit))
|
|
168
|
+
fail('Git revision did not resolve to a commit object.');
|
|
169
|
+
return commit;
|
|
170
|
+
}
|
|
171
|
+
/** Read immutable objects only; dirty files, local identity and Git replace refs are not input truth. */
|
|
172
|
+
function readEntityGitSnapshot(root, revision, storeLocator) {
|
|
173
|
+
if (!locator(storeLocator))
|
|
174
|
+
fail('Unsafe store locator.');
|
|
175
|
+
const git = gitReader(root), commit = resolveCommit(git, revision);
|
|
176
|
+
const tree = utf8(git(['rev-parse', '--verify', '--end-of-options', commit + '^{tree}'])).trim();
|
|
177
|
+
if (!OID.test(tree))
|
|
178
|
+
fail('Git commit has no supported tree object.');
|
|
179
|
+
const workspacePrefix = utf8(git(['rev-parse', '--show-prefix'])).replace(/\n$/, '');
|
|
180
|
+
if (workspacePrefix && (!workspacePrefix.endsWith('/') || !locator(workspacePrefix.slice(0, -1))))
|
|
181
|
+
fail('Unsafe workspace prefix.');
|
|
182
|
+
const files = [];
|
|
183
|
+
for (const entry of utf8(git(['ls-tree', '-r', '-z', '--full-tree', commit])).split('\0')) {
|
|
184
|
+
if (!entry)
|
|
185
|
+
continue;
|
|
186
|
+
const tab = entry.indexOf('\t'), header = entry.slice(0, tab).split(' '), fullPath = entry.slice(tab + 1);
|
|
187
|
+
if (tab < 0 || header.length !== 3 || !OID.test(header[2]) || !locator(fullPath))
|
|
188
|
+
fail('Malformed Git tree inventory.');
|
|
189
|
+
if (!fullPath.startsWith(workspacePrefix))
|
|
190
|
+
continue;
|
|
191
|
+
const file = fullPath.slice(workspacePrefix.length);
|
|
192
|
+
if (!locator(file))
|
|
193
|
+
fail('Unsafe workspace file locator.');
|
|
194
|
+
// @implements A-SPEC-637 — gitlinks enter the inventory (mode 160000) so the planner can exclude or refuse them.
|
|
195
|
+
const gitlink = header[1] === 'commit' && header[0] === '160000';
|
|
196
|
+
if (!gitlink && (header[1] !== 'blob' || !['100644', '100755', '120000'].includes(header[0])))
|
|
197
|
+
fail('Unsupported Git tree object mode: ' + file);
|
|
198
|
+
files.push({ path: file, mode: header[0], oid: header[2] });
|
|
199
|
+
}
|
|
200
|
+
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
201
|
+
const byPath = new Map(files.map(file => [file.path, file]));
|
|
202
|
+
if (byPath.size !== files.length)
|
|
203
|
+
fail('Duplicate Git file inventory.');
|
|
204
|
+
const regular = (file) => {
|
|
205
|
+
const entry = byPath.get(file);
|
|
206
|
+
if (!entry)
|
|
207
|
+
return fail('Missing canonical document or mapping: ' + file);
|
|
208
|
+
if (!['100644', '100755'].includes(entry.mode))
|
|
209
|
+
fail('Canonical document/mapping requires regular mode, not a symlink: ' + file);
|
|
210
|
+
return entry;
|
|
211
|
+
};
|
|
212
|
+
const blobs = new Map();
|
|
213
|
+
const read = (paths) => {
|
|
214
|
+
const ids = [...new Set(paths.map(file => regular(file).oid))].filter(oid => !blobs.has(oid));
|
|
215
|
+
for (const [oid, bytes] of readEntityGitBlobs(root, ids))
|
|
216
|
+
blobs.set(oid, utf8(bytes));
|
|
217
|
+
};
|
|
218
|
+
const text = (file) => {
|
|
219
|
+
const value = blobs.get(regular(file).oid);
|
|
220
|
+
if (value === undefined)
|
|
221
|
+
return fail('Canonical blob was not loaded: ' + file);
|
|
222
|
+
return value;
|
|
223
|
+
};
|
|
224
|
+
const registrations = files.filter(file => /^\.ax\/entities\/stores\/[^/]+\.json$/.test(file.path));
|
|
225
|
+
const recordFiles = files.filter(file => /^\.ax\/entities\/records\/[^/]+\.json$/.test(file.path));
|
|
226
|
+
read(['.ax/workspace.json', ...registrations.map(file => file.path), ...recordFiles.map(file => file.path)]);
|
|
227
|
+
const manifest = object(text('.ax/workspace.json'), ['schema', 'workspaceId']);
|
|
228
|
+
if (manifest.schema !== 'holmes-workspace/1' || !uuid(manifest.workspaceId))
|
|
229
|
+
fail('Unsupported canonical workspace identity.');
|
|
230
|
+
const stores = new Map(), storePaths = new Set();
|
|
231
|
+
for (const file of registrations) {
|
|
232
|
+
const registration = object(text(file.path), ['schema', 'workspaceId', 'storeId', 'storeLocator', 'adoptionDigest']);
|
|
233
|
+
if (registration.schema !== 'holmes-entity-store/1' || registration.workspaceId !== manifest.workspaceId || !uuid(registration.storeId) || file.path !== '.ax/entities/stores/' + registration.storeId + '.json' || !locator(registration.storeLocator) || typeof registration.adoptionDigest !== 'string' || !DIGEST.test(registration.adoptionDigest) || storePaths.has(registration.storeLocator))
|
|
234
|
+
fail('Invalid or duplicate store registration mapping.');
|
|
235
|
+
stores.set(registration.storeId, registration.storeLocator);
|
|
236
|
+
storePaths.add(registration.storeLocator);
|
|
237
|
+
}
|
|
238
|
+
const selected = [...stores].find(([, value]) => value === storeLocator);
|
|
239
|
+
if (!selected)
|
|
240
|
+
fail('Configured store has no canonical adoption mapping.');
|
|
241
|
+
const selectedRecords = [];
|
|
242
|
+
const ids = new Set(), documents = new Set(), otherStoreDisplayIds = new Set();
|
|
243
|
+
for (const file of recordFiles) {
|
|
244
|
+
const record = object(text(file.path), ['schema', 'workspaceId', 'storeId', 'entityId', 'displayId', 'documentLocator', 'origin']);
|
|
245
|
+
const origin = object(JSON.stringify(record.origin), ['kind', 'operationId']);
|
|
246
|
+
if (record.schema !== 'holmes-entity/1' || record.workspaceId !== manifest.workspaceId || !uuid(record.entityId) || file.path !== '.ax/entities/records/' + record.entityId + '.json' || !uuid(record.storeId) || !stores.has(record.storeId) || typeof record.displayId !== 'string' || !record.displayId || !locator(record.documentLocator) || !record.documentLocator.endsWith('.md') || !['adopted', 'created'].includes(String(origin.kind)) || !uuid(origin.operationId))
|
|
247
|
+
fail('Invalid entity document mapping.');
|
|
248
|
+
if (record.storeId !== selected[0]) {
|
|
249
|
+
otherStoreDisplayIds.add(record.displayId);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (ids.has(record.displayId) || documents.has(record.documentLocator))
|
|
253
|
+
fail('Duplicate display ID or document mapping.');
|
|
254
|
+
ids.add(record.displayId);
|
|
255
|
+
documents.add(record.documentLocator);
|
|
256
|
+
selectedRecords.push({ entityId: record.entityId, displayId: record.displayId, documentLocator: record.documentLocator, recordLocator: file.path });
|
|
257
|
+
}
|
|
258
|
+
const documentFiles = files.filter(file => file.path.startsWith(storeLocator + '/') && file.path.endsWith('.md'));
|
|
259
|
+
if (documentFiles.length !== selectedRecords.length || documentFiles.some(file => !documents.has(file.path.slice(storeLocator.length + 1))))
|
|
260
|
+
fail('Document inventory differs from canonical entity mappings.');
|
|
261
|
+
read(selectedRecords.map(record => storeLocator + '/' + record.documentLocator));
|
|
262
|
+
const entities = selectedRecords.map(record => {
|
|
263
|
+
const documentPath = storeLocator + '/' + record.documentLocator, document = text(documentPath);
|
|
264
|
+
let parsed;
|
|
265
|
+
try {
|
|
266
|
+
parsed = (0, spec_parser_1.parseSpec)(document);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return fail('Unparseable canonical document: ' + documentPath);
|
|
270
|
+
}
|
|
271
|
+
if (parsed.id !== record.displayId)
|
|
272
|
+
fail('Document ID differs from its entity mapping: ' + documentPath);
|
|
273
|
+
return { ...record, documentBlob: regular(documentPath).oid, recordBlob: regular(record.recordLocator).oid, document, record: text(record.recordLocator) };
|
|
274
|
+
}).sort((a, b) => a.entityId < b.entityId ? -1 : a.entityId > b.entityId ? 1 : 0);
|
|
275
|
+
return { schema: 'holmes-entity-git-snapshot/1', commit, tree, workspacePrefix, workspaceId: manifest.workspaceId, storeId: selected[0], storeLocator, entities, files, otherStoreDisplayIds: [...otherStoreDisplayIds].sort() };
|
|
276
|
+
}
|