@davesheffer/hunch 1.8.2 → 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -1
- package/dist/cli/index.js +1238 -396
- package/dist/constitution/adapters.js +31 -14
- package/dist/constitution/behaviorEvaluator.js +20 -7
- package/dist/constitution/behaviorProof.js +3 -2
- package/dist/constitution/canonical.js +7 -1
- package/dist/constitution/card.js +7 -2
- package/dist/constitution/compiler.js +71 -1
- package/dist/constitution/correctionPolicyMaterializer.js +496 -0
- package/dist/constitution/delta.js +3 -2
- package/dist/constitution/evaluator.js +29 -3
- package/dist/constitution/experiment.js +96 -5
- package/dist/constitution/experimentRunner.js +43 -14
- package/dist/constitution/g2BehaviorCandidates.js +49 -26
- package/dist/constitution/g2BehaviorDependencies.js +203 -14
- package/dist/constitution/g2Candidates.js +1 -1
- package/dist/constitution/lifecycle.js +17 -0
- package/dist/constitution/plan.js +26 -9
- package/dist/constitution/replacementFreeGit.js +67 -0
- package/dist/constitution/replay.js +6 -0
- package/dist/constitution/replayCache.js +1 -1
- package/dist/constitution/replayWorker.js +1 -1
- package/dist/constitution/repository.js +141 -5
- package/dist/constitution/safeCheckout.js +75 -0
- package/dist/constitution/schema.js +30 -5
- package/dist/constitution/service.js +74 -14
- package/dist/constitution/sourceMutation.js +65 -12
- package/dist/constitution/staticGraphBaseline.js +44 -0
- package/dist/constitution/structural.js +60 -4
- package/dist/core/autoreview.js +1 -1
- package/dist/core/canonicalOrder.js +6 -0
- package/dist/core/conformance.js +68 -27
- package/dist/core/docscan.js +2 -1
- package/dist/core/escalations.js +11 -0
- package/dist/core/io.js +44 -9
- package/dist/core/overlaySafety.js +178 -0
- package/dist/core/paths.js +13 -2
- package/dist/core/safeRepoFile.js +74 -0
- package/dist/extractors/comments.js +6 -8
- package/dist/extractors/git.js +1631 -82
- package/dist/extractors/indexer.js +86 -47
- package/dist/extractors/repoSource.js +390 -0
- package/dist/integrations/ciAction.js +10 -2
- package/dist/integrations/gitignore.js +44 -5
- package/dist/integrations/mergeDriver.js +23 -5
- package/dist/integrations/sync.js +61 -5
- package/dist/integrations/team.js +666 -23
- package/dist/mcp/server.js +261 -34
- package/dist/store/db.js +57 -7
- package/dist/store/hunchStore.js +92 -11
- package/dist/store/jsonStore.js +350 -63
- package/dist/store/schema.js +27 -11
- package/dist/synthesis/provider.js +13 -4
- package/dist/synthesis/synthesize.js +56 -19
- package/dist/wiki/graph.js +5 -4
- package/dist/wiki/wiki.js +16 -10
- package/package.json +15 -3
- package/tooling/competitive-watch.mjs +108 -0
- package/tooling/md1-benchmark.mjs +628 -0
package/dist/store/jsonStore.js
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* in PRs, diffable, mergeable). This layer never touches SQLite — it is the
|
|
4
4
|
* authoritative read/write surface; SQLite is rebuilt from it.
|
|
5
5
|
*/
|
|
6
|
-
import { mkdirSync,
|
|
7
|
-
import { join } from "node:path";
|
|
6
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, realpathSync, rmSync, } from "node:fs";
|
|
7
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
|
|
9
|
-
import {
|
|
9
|
+
import { BASELINE_VERSION, migrateRaw, SCHEMA_VERSION } from "../core/migrate.js";
|
|
10
10
|
import { writeFileAtomic } from "../core/io.js";
|
|
11
11
|
/** High-cardinality collections (symbols, edges) are stored as a single
|
|
12
12
|
* index.json array — there can be thousands, and one file per edge would create
|
|
@@ -14,6 +14,22 @@ import { writeFileAtomic } from "../core/io.js";
|
|
|
14
14
|
* constraints) are one file per record so they're cleanly reviewable in PRs. */
|
|
15
15
|
const SINGLE_FILE = { symbols: "index.json", edges: "index.json" };
|
|
16
16
|
const encode = (v) => JSON.stringify(v, null, 2) + "\n";
|
|
17
|
+
/** Curated entities are intentionally small, human-reviewable records. Symbols
|
|
18
|
+
* and edges are dense indexes, so they get a much larger but still finite cap. */
|
|
19
|
+
export const MAX_JSON_RECORD_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
export const MAX_JSON_INDEX_BYTES = 256 * 1024 * 1024;
|
|
21
|
+
export const MAX_JSON_MANIFEST_BYTES = 64 * 1024;
|
|
22
|
+
export const MAX_JSON_DIRECTORY_ENTRIES_PER_KIND = 100_000;
|
|
23
|
+
function missing(error) {
|
|
24
|
+
return error.code === "ENOENT";
|
|
25
|
+
}
|
|
26
|
+
function pathIsWithin(path, parent) {
|
|
27
|
+
const rel = relative(parent, path);
|
|
28
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
29
|
+
}
|
|
30
|
+
function unsafePath(path, reason) {
|
|
31
|
+
return new Error(`[hunch] refusing unsafe JSON store path ${path}: ${reason}`);
|
|
32
|
+
}
|
|
17
33
|
export class JsonStore {
|
|
18
34
|
paths;
|
|
19
35
|
_warnedForward = false;
|
|
@@ -25,8 +41,212 @@ export class JsonStore {
|
|
|
25
41
|
* process self-invalidates on the next read. In-process writes also invalidate
|
|
26
42
|
* explicitly (exact, independent of mtime granularity). */
|
|
27
43
|
cache = new Map();
|
|
44
|
+
lexicalRoot;
|
|
45
|
+
canonicalRoot;
|
|
46
|
+
lexicalHunch;
|
|
28
47
|
constructor(paths) {
|
|
29
48
|
this.paths = paths;
|
|
49
|
+
this.lexicalRoot = resolve(paths.root);
|
|
50
|
+
this.canonicalRoot = realpathSync(this.lexicalRoot);
|
|
51
|
+
this.lexicalHunch = resolve(paths.hunch);
|
|
52
|
+
const hunchRelative = relative(this.lexicalRoot, this.lexicalHunch);
|
|
53
|
+
if (!hunchRelative || !pathIsWithin(this.lexicalHunch, this.lexicalRoot)) {
|
|
54
|
+
throw unsafePath(paths.hunch, "the Hunch directory is not a child of its declared store root");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
lstatOrMissing(path) {
|
|
58
|
+
try {
|
|
59
|
+
return lstatSync(path);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (missing(error))
|
|
63
|
+
return null;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
expectedCanonical(path) {
|
|
68
|
+
const lexical = resolve(path);
|
|
69
|
+
if (!pathIsWithin(lexical, this.lexicalRoot)) {
|
|
70
|
+
throw unsafePath(path, "path escapes the declared store root");
|
|
71
|
+
}
|
|
72
|
+
return resolve(this.canonicalRoot, relative(this.lexicalRoot, lexical));
|
|
73
|
+
}
|
|
74
|
+
/** Require an ordinary, canonically-contained directory. Creation is one
|
|
75
|
+
* component at a time after its parent has been validated; recursive mkdir
|
|
76
|
+
* would otherwise traverse a malicious pre-existing symlink. */
|
|
77
|
+
safeDirectory(path, create) {
|
|
78
|
+
const lexical = resolve(path);
|
|
79
|
+
const expected = this.expectedCanonical(lexical);
|
|
80
|
+
let stat = this.lstatOrMissing(lexical);
|
|
81
|
+
if (!stat) {
|
|
82
|
+
if (!create)
|
|
83
|
+
return null;
|
|
84
|
+
mkdirSync(lexical);
|
|
85
|
+
stat = lstatSync(lexical);
|
|
86
|
+
}
|
|
87
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
88
|
+
throw unsafePath(lexical, "expected an ordinary directory (symlinks are not followed)");
|
|
89
|
+
}
|
|
90
|
+
const canonical = realpathSync(lexical);
|
|
91
|
+
if (canonical !== expected || !pathIsWithin(canonical, this.canonicalRoot)) {
|
|
92
|
+
throw unsafePath(lexical, "canonical directory escapes its declared store root");
|
|
93
|
+
}
|
|
94
|
+
return { lexical, canonical, stat };
|
|
95
|
+
}
|
|
96
|
+
safeHunchDirectory(create) {
|
|
97
|
+
const current = this.safeDirectory(this.lexicalHunch, false);
|
|
98
|
+
if (current || !create)
|
|
99
|
+
return current;
|
|
100
|
+
// The declared root was canonicalized in the constructor and the Hunch dir
|
|
101
|
+
// is its direct child for both public and private-overlay path builders.
|
|
102
|
+
const parent = resolve(this.lexicalHunch, "..");
|
|
103
|
+
if (parent !== this.lexicalRoot) {
|
|
104
|
+
throw unsafePath(this.lexicalHunch, "the Hunch directory is not directly beneath its store root");
|
|
105
|
+
}
|
|
106
|
+
return this.safeDirectory(this.lexicalHunch, true);
|
|
107
|
+
}
|
|
108
|
+
assertKind(kind) {
|
|
109
|
+
if (!ENTITY_KINDS.includes(kind)) {
|
|
110
|
+
throw new Error(`[hunch] unknown JSON entity kind: ${String(kind)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
safeKindDirectory(kind, create) {
|
|
114
|
+
this.assertKind(kind);
|
|
115
|
+
const hunch = this.safeHunchDirectory(create);
|
|
116
|
+
if (!hunch)
|
|
117
|
+
return null;
|
|
118
|
+
const lexical = resolve(this.paths.dir(kind));
|
|
119
|
+
if (resolve(lexical, "..") !== this.lexicalHunch || lexical !== resolve(this.lexicalHunch, kind)) {
|
|
120
|
+
throw unsafePath(lexical, `kind directory ${kind} is not directly beneath the Hunch directory`);
|
|
121
|
+
}
|
|
122
|
+
return this.safeDirectory(lexical, create);
|
|
123
|
+
}
|
|
124
|
+
maxBytes(kind) {
|
|
125
|
+
return SINGLE_FILE[kind] ? MAX_JSON_INDEX_BYTES : MAX_JSON_RECORD_BYTES;
|
|
126
|
+
}
|
|
127
|
+
assertSafeRecordId(id) {
|
|
128
|
+
// Entity schemas intentionally accept free-form IDs for backwards
|
|
129
|
+
// compatibility. The storage boundary must still reject path syntax.
|
|
130
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id) || id === "." || id === "..") {
|
|
131
|
+
throw new Error(`[hunch] refusing unsafe JSON record id: ${JSON.stringify(id)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
assertFileBelongsTo(directory, file) {
|
|
135
|
+
const lexical = resolve(file);
|
|
136
|
+
const rel = relative(directory.lexical, lexical);
|
|
137
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel) || rel.includes(sep)) {
|
|
138
|
+
throw unsafePath(file, "record path is not a direct child of its kind directory");
|
|
139
|
+
}
|
|
140
|
+
return lexical;
|
|
141
|
+
}
|
|
142
|
+
validateExistingFile(directory, file, maxBytes) {
|
|
143
|
+
const lexical = this.assertFileBelongsTo(directory, file);
|
|
144
|
+
const stat = this.lstatOrMissing(lexical);
|
|
145
|
+
if (!stat)
|
|
146
|
+
return null;
|
|
147
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
148
|
+
throw unsafePath(lexical, "expected an ordinary file (symlinks and special files are not followed)");
|
|
149
|
+
}
|
|
150
|
+
if (stat.nlink !== 1) {
|
|
151
|
+
throw unsafePath(lexical, "hard-linked records are not accepted");
|
|
152
|
+
}
|
|
153
|
+
if (stat.size > maxBytes) {
|
|
154
|
+
throw new Error(`[hunch] refusing oversized JSON file ${lexical}: ${stat.size} bytes exceeds ${maxBytes}`);
|
|
155
|
+
}
|
|
156
|
+
const canonical = realpathSync(lexical);
|
|
157
|
+
if (canonical !== resolve(directory.canonical, relative(directory.lexical, lexical))) {
|
|
158
|
+
throw unsafePath(lexical, "canonical record path escapes its kind directory");
|
|
159
|
+
}
|
|
160
|
+
return stat;
|
|
161
|
+
}
|
|
162
|
+
/** Read through the exact descriptor whose type, identity, containment, and
|
|
163
|
+
* finite size were checked. Returning null means the file is absent. */
|
|
164
|
+
readContainedFile(directory, file, maxBytes) {
|
|
165
|
+
const lexical = this.assertFileBelongsTo(directory, file);
|
|
166
|
+
const before = this.validateExistingFile(directory, lexical, maxBytes);
|
|
167
|
+
if (!before)
|
|
168
|
+
return null;
|
|
169
|
+
let descriptor;
|
|
170
|
+
try {
|
|
171
|
+
descriptor = openSync(lexical, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
172
|
+
const opened = fstatSync(descriptor);
|
|
173
|
+
const after = lstatSync(lexical);
|
|
174
|
+
if (!opened.isFile() || !after.isFile() || after.isSymbolicLink()
|
|
175
|
+
|| opened.nlink !== 1 || after.nlink !== 1
|
|
176
|
+
|| opened.size > maxBytes
|
|
177
|
+
|| opened.dev !== before.dev || opened.ino !== before.ino
|
|
178
|
+
|| after.dev !== opened.dev || after.ino !== opened.ino
|
|
179
|
+
|| realpathSync(lexical) !== resolve(directory.canonical, relative(directory.lexical, lexical))) {
|
|
180
|
+
throw unsafePath(lexical, "record changed identity or containment while it was opened");
|
|
181
|
+
}
|
|
182
|
+
// Read only the byte count that passed fstat. Reading the descriptor to
|
|
183
|
+
// EOF would let an in-place grow race turn a bounded check into an
|
|
184
|
+
// unbounded allocation.
|
|
185
|
+
const bytes = Buffer.allocUnsafe(opened.size);
|
|
186
|
+
let offset = 0;
|
|
187
|
+
while (offset < bytes.length) {
|
|
188
|
+
const read = readSync(descriptor, bytes, offset, bytes.length - offset, offset);
|
|
189
|
+
if (read === 0)
|
|
190
|
+
break;
|
|
191
|
+
offset += read;
|
|
192
|
+
}
|
|
193
|
+
return bytes.subarray(0, offset).toString("utf8");
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
if (descriptor !== undefined)
|
|
197
|
+
closeSync(descriptor);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
writeContainedFile(directory, file, data, maxBytes) {
|
|
201
|
+
const lexical = this.assertFileBelongsTo(directory, file);
|
|
202
|
+
const bytes = Buffer.byteLength(data);
|
|
203
|
+
if (bytes > maxBytes) {
|
|
204
|
+
throw new Error(`[hunch] refusing oversized JSON write ${lexical}: ${bytes} bytes exceeds ${maxBytes}`);
|
|
205
|
+
}
|
|
206
|
+
// Refuse an existing symlink/hardlink/special file rather than relying on
|
|
207
|
+
// rename semantics that differ across platforms.
|
|
208
|
+
this.validateExistingFile(directory, lexical, maxBytes);
|
|
209
|
+
writeFileAtomic(lexical, data);
|
|
210
|
+
this.validateExistingFile(directory, lexical, maxBytes);
|
|
211
|
+
// Detect a kind-dir replacement around the atomic rename before reporting
|
|
212
|
+
// success. (Node has no portable openat/renameat API; this closes the static
|
|
213
|
+
// committed-symlink attack and detects the common concurrent replacement.)
|
|
214
|
+
this.safeDirectory(directory.lexical, false);
|
|
215
|
+
}
|
|
216
|
+
removeContainedFile(directory, file, maxBytes) {
|
|
217
|
+
const lexical = this.assertFileBelongsTo(directory, file);
|
|
218
|
+
if (!this.validateExistingFile(directory, lexical, maxBytes))
|
|
219
|
+
return false;
|
|
220
|
+
rmSync(lexical);
|
|
221
|
+
this.safeDirectory(directory.lexical, false);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
jsonFileNames(kind) {
|
|
225
|
+
const directory = this.safeKindDirectory(kind, false);
|
|
226
|
+
if (!directory)
|
|
227
|
+
return [];
|
|
228
|
+
const names = [];
|
|
229
|
+
let entries = 0;
|
|
230
|
+
const handle = opendirSync(directory.lexical);
|
|
231
|
+
try {
|
|
232
|
+
for (;;) {
|
|
233
|
+
const entry = handle.readSync();
|
|
234
|
+
if (!entry)
|
|
235
|
+
break;
|
|
236
|
+
entries++;
|
|
237
|
+
if (entries > MAX_JSON_DIRECTORY_ENTRIES_PER_KIND) {
|
|
238
|
+
throw new Error(`[hunch] refusing JSON kind ${kind}: more than ${MAX_JSON_DIRECTORY_ENTRIES_PER_KIND} directory entries`);
|
|
239
|
+
}
|
|
240
|
+
if (!entry.name.endsWith(".json"))
|
|
241
|
+
continue;
|
|
242
|
+
names.push(entry.name);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
handle.closeSync();
|
|
247
|
+
}
|
|
248
|
+
this.safeDirectory(directory.lexical, false);
|
|
249
|
+
return names.sort();
|
|
30
250
|
}
|
|
31
251
|
/** Drop memoized loadAll results. Writes through this store invalidate the
|
|
32
252
|
* affected kind automatically; call this for OUT-OF-BAND changes to .hunch/
|
|
@@ -35,6 +255,30 @@ export class JsonStore {
|
|
|
35
255
|
clearCache() {
|
|
36
256
|
this.cache.clear();
|
|
37
257
|
}
|
|
258
|
+
/** Cheap revision marker for the JSON source tree. Hunch writes records with
|
|
259
|
+
* temp-file + rename, so every supported add/update/delete bumps the containing
|
|
260
|
+
* kind directory's metadata. Include the manifest separately because a schema
|
|
261
|
+
* migration can change how otherwise-identical record bytes are interpreted.
|
|
262
|
+
* This lets long-lived readers notice another process without hashing or
|
|
263
|
+
* reparsing the complete graph on every request. */
|
|
264
|
+
changeStamp() {
|
|
265
|
+
const hunch = this.safeHunchDirectory(false);
|
|
266
|
+
const manifestStamp = () => {
|
|
267
|
+
if (!hunch)
|
|
268
|
+
return "missing";
|
|
269
|
+
const stat = this.validateExistingFile(hunch, this.paths.manifest, MAX_JSON_MANIFEST_BYTES);
|
|
270
|
+
if (!stat)
|
|
271
|
+
return "missing";
|
|
272
|
+
return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`;
|
|
273
|
+
};
|
|
274
|
+
return [
|
|
275
|
+
`manifest:${manifestStamp()}`,
|
|
276
|
+
...ENTITY_KINDS.map((kind) => {
|
|
277
|
+
const directory = this.safeKindDirectory(kind, false);
|
|
278
|
+
return `${kind}:${directory ? `${directory.stat.mtimeMs}:${directory.stat.ctimeMs}:${directory.stat.size}` : "missing"}`;
|
|
279
|
+
}),
|
|
280
|
+
].join("|");
|
|
281
|
+
}
|
|
38
282
|
invalidate(kind) {
|
|
39
283
|
this.cache.delete(kind);
|
|
40
284
|
}
|
|
@@ -44,17 +288,36 @@ export class JsonStore {
|
|
|
44
288
|
* a manifest is LEGACY — left unstamped so it defaults to the baseline and
|
|
45
289
|
* `hunch migrate` upgrades it. */
|
|
46
290
|
ensureDirs() {
|
|
47
|
-
const fresh = !
|
|
48
|
-
|
|
291
|
+
const fresh = !this.safeHunchDirectory(false);
|
|
292
|
+
const hunch = this.safeHunchDirectory(true);
|
|
293
|
+
// Preflight every existing kind before creating anything else. An unsafe
|
|
294
|
+
// committed kind symlink therefore fails init without partially scaffolding.
|
|
295
|
+
for (const kind of ENTITY_KINDS)
|
|
296
|
+
this.safeKindDirectory(kind, false);
|
|
49
297
|
for (const kind of ENTITY_KINDS)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
298
|
+
this.safeKindDirectory(kind, true);
|
|
299
|
+
const manifest = this.validateExistingFile(hunch, this.paths.manifest, MAX_JSON_MANIFEST_BYTES);
|
|
300
|
+
if (fresh && !manifest) {
|
|
301
|
+
this.writeContainedFile(hunch, this.paths.manifest, JSON.stringify({ schema_version: SCHEMA_VERSION }, null, 2) + "\n", MAX_JSON_MANIFEST_BYTES);
|
|
302
|
+
}
|
|
53
303
|
}
|
|
54
304
|
/** The on-disk schema version (from the manifest), read FRESH each call so a
|
|
55
305
|
* long-lived process (the MCP server) reflects an out-of-band `hunch migrate`. */
|
|
56
306
|
schemaVersion() {
|
|
57
|
-
const
|
|
307
|
+
const hunch = this.safeHunchDirectory(false);
|
|
308
|
+
let v = BASELINE_VERSION;
|
|
309
|
+
if (hunch) {
|
|
310
|
+
const text = this.readContainedFile(hunch, this.paths.manifest, MAX_JSON_MANIFEST_BYTES);
|
|
311
|
+
if (text !== null) {
|
|
312
|
+
try {
|
|
313
|
+
const manifest = JSON.parse(text);
|
|
314
|
+
if (typeof manifest.schema_version === "number" && Number.isInteger(manifest.schema_version)) {
|
|
315
|
+
v = manifest.schema_version;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch { /* corrupt manifests intentionally retain baseline semantics */ }
|
|
319
|
+
}
|
|
320
|
+
}
|
|
58
321
|
if (v > SCHEMA_VERSION && !this._warnedForward) {
|
|
59
322
|
this._warnedForward = true;
|
|
60
323
|
console.warn(`[hunch] .hunch/ was written by a newer schema (v${v} > v${SCHEMA_VERSION}); ` +
|
|
@@ -72,14 +335,15 @@ export class JsonStore {
|
|
|
72
335
|
const single = SINGLE_FILE[kind];
|
|
73
336
|
if (single)
|
|
74
337
|
return join(this.paths.dir(kind), single);
|
|
338
|
+
this.assertSafeRecordId(id);
|
|
75
339
|
return join(this.paths.dir(kind), `${id}.json`);
|
|
76
340
|
}
|
|
77
341
|
/** Load every record of a kind, validated against its schema. Memoized — the
|
|
78
342
|
* returned array is shared and MUST be treated read-only (every caller already
|
|
79
343
|
* derives via filter/map/sort, which copy). Invalidated on write. */
|
|
80
344
|
loadAll(kind) {
|
|
81
|
-
const
|
|
82
|
-
const mtimeMs =
|
|
345
|
+
const directory = this.safeKindDirectory(kind, false);
|
|
346
|
+
const mtimeMs = directory?.stat.mtimeMs ?? 0;
|
|
83
347
|
const hit = this.cache.get(kind);
|
|
84
348
|
if (hit && hit.mtimeMs === mtimeMs)
|
|
85
349
|
return hit.data;
|
|
@@ -90,20 +354,21 @@ export class JsonStore {
|
|
|
90
354
|
/** Uncached disk read + validate. Invalid records are skipped with a warning
|
|
91
355
|
* rather than crashing the whole load. */
|
|
92
356
|
readAllFromDisk(kind) {
|
|
93
|
-
const
|
|
94
|
-
if (!
|
|
357
|
+
const directory = this.safeKindDirectory(kind, false);
|
|
358
|
+
if (!directory)
|
|
95
359
|
return [];
|
|
96
360
|
const schema = SCHEMAS[kind];
|
|
97
361
|
const out = [];
|
|
98
362
|
const version = this.schemaVersion(); // read the manifest ONCE per load, not per record
|
|
99
363
|
const single = SINGLE_FILE[kind];
|
|
100
364
|
if (single) {
|
|
101
|
-
const f = join(
|
|
102
|
-
if (!existsSync(f))
|
|
103
|
-
return [];
|
|
365
|
+
const f = join(directory.lexical, single);
|
|
104
366
|
let arr;
|
|
105
367
|
try {
|
|
106
|
-
|
|
368
|
+
const text = this.readContainedFile(directory, f, this.maxBytes(kind));
|
|
369
|
+
if (text === null)
|
|
370
|
+
return [];
|
|
371
|
+
arr = JSON.parse(text);
|
|
107
372
|
}
|
|
108
373
|
catch (e) {
|
|
109
374
|
console.warn(`[hunch] skipping corrupt ${kind}/${single}: ${e.message}`);
|
|
@@ -118,12 +383,13 @@ export class JsonStore {
|
|
|
118
383
|
}
|
|
119
384
|
return out;
|
|
120
385
|
}
|
|
121
|
-
for (const name of
|
|
122
|
-
if (!name.endsWith(".json"))
|
|
123
|
-
continue;
|
|
386
|
+
for (const name of this.jsonFileNames(kind)) {
|
|
124
387
|
let raw;
|
|
125
388
|
try {
|
|
126
|
-
|
|
389
|
+
const text = this.readContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
|
|
390
|
+
if (text === null)
|
|
391
|
+
continue;
|
|
392
|
+
raw = JSON.parse(text);
|
|
127
393
|
}
|
|
128
394
|
catch (e) {
|
|
129
395
|
console.warn(`[hunch] skipping corrupt ${kind}/${name}: ${e.message}`);
|
|
@@ -141,21 +407,23 @@ export class JsonStore {
|
|
|
141
407
|
put(kind, record) {
|
|
142
408
|
const schema = SCHEMAS[kind];
|
|
143
409
|
const validated = schema.parse(record);
|
|
144
|
-
mkdirSync(this.paths.dir(kind), { recursive: true });
|
|
145
410
|
const single = SINGLE_FILE[kind];
|
|
411
|
+
if (!single)
|
|
412
|
+
this.assertSafeRecordId(validated.id);
|
|
413
|
+
const directory = this.safeKindDirectory(kind, true);
|
|
146
414
|
if (single) {
|
|
147
415
|
// Operate on the RAW array (NOT the validating loadAll) so updating one
|
|
148
416
|
// record can't silently drop schema-invalid / future-schema siblings — the
|
|
149
417
|
// same reason delete() reads raw. Keep the index sorted by id (stable diff,
|
|
150
418
|
// and agrees with the merge driver so a re-index after a merge is a no-op).
|
|
151
419
|
const f = this.fileFor(kind, validated.id);
|
|
152
|
-
const arr = this.readRawArray(f).filter((r) => r?.id !== validated.id);
|
|
420
|
+
const arr = this.readRawArray(kind, directory, f).filter((r) => r?.id !== validated.id);
|
|
153
421
|
arr.push(validated);
|
|
154
422
|
arr.sort((a, b) => String(a?.id).localeCompare(String(b?.id)));
|
|
155
|
-
|
|
423
|
+
this.writeContainedFile(directory, f, encode(arr), this.maxBytes(kind));
|
|
156
424
|
}
|
|
157
425
|
else {
|
|
158
|
-
|
|
426
|
+
this.writeContainedFile(directory, this.fileFor(kind, validated.id), encode(validated), this.maxBytes(kind));
|
|
159
427
|
}
|
|
160
428
|
this.invalidate(kind);
|
|
161
429
|
return validated;
|
|
@@ -164,33 +432,42 @@ export class JsonStore {
|
|
|
164
432
|
replaceAll(kind, records) {
|
|
165
433
|
const schema = SCHEMAS[kind];
|
|
166
434
|
const validated = records.map((r) => schema.parse(r));
|
|
167
|
-
this.invalidate(kind);
|
|
168
|
-
mkdirSync(this.paths.dir(kind), { recursive: true });
|
|
169
435
|
const single = SINGLE_FILE[kind];
|
|
436
|
+
if (!single) {
|
|
437
|
+
for (const record of validated)
|
|
438
|
+
this.assertSafeRecordId(String(record.id));
|
|
439
|
+
}
|
|
440
|
+
this.invalidate(kind);
|
|
441
|
+
const directory = this.safeKindDirectory(kind, true);
|
|
170
442
|
if (single) {
|
|
171
443
|
// Sorted by id so the index has ONE canonical order — re-indexing after a
|
|
172
444
|
// git merge (which the driver also id-sorts) doesn't churn the whole file.
|
|
173
445
|
validated.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
174
|
-
|
|
446
|
+
this.writeContainedFile(directory, this.fileFor(kind, "index"), encode(validated), this.maxBytes(kind));
|
|
175
447
|
return;
|
|
176
448
|
}
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
449
|
+
// One file per record: preflight EVERY existing JSON file before deleting
|
|
450
|
+
// any, so one malicious symlink cannot cause a partially-cleared store.
|
|
451
|
+
const existing = this.jsonFileNames(kind);
|
|
452
|
+
for (const name of existing) {
|
|
453
|
+
this.validateExistingFile(directory, join(directory.lexical, name), this.maxBytes(kind));
|
|
454
|
+
}
|
|
455
|
+
for (const name of existing) {
|
|
456
|
+
this.removeContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
|
|
181
457
|
}
|
|
182
458
|
for (const r of validated) {
|
|
183
|
-
|
|
459
|
+
const id = r.id;
|
|
460
|
+
this.writeContainedFile(directory, this.fileFor(kind, id), encode(r), this.maxBytes(kind));
|
|
184
461
|
}
|
|
185
462
|
}
|
|
186
463
|
/** Read a single-file index as a raw array (no validation). Missing/empty → [].
|
|
187
464
|
* A non-empty file that fails to parse THROWS — we must never silently treat a
|
|
188
465
|
* corrupt index as empty and then rewrite it, which would flatten every existing
|
|
189
466
|
* record. (`hunch index` rebuilds from scratch via replaceAll to recover.) */
|
|
190
|
-
readRawArray(f) {
|
|
191
|
-
|
|
467
|
+
readRawArray(kind, directory, f) {
|
|
468
|
+
const text = this.readContainedFile(directory, f, this.maxBytes(kind));
|
|
469
|
+
if (text === null)
|
|
192
470
|
return [];
|
|
193
|
-
const text = readFileSync(f, "utf8");
|
|
194
471
|
if (!text.trim())
|
|
195
472
|
return [];
|
|
196
473
|
let v;
|
|
@@ -211,21 +488,27 @@ export class JsonStore {
|
|
|
211
488
|
delete(kind, id) {
|
|
212
489
|
const single = SINGLE_FILE[kind];
|
|
213
490
|
if (single) {
|
|
491
|
+
const directory = this.safeKindDirectory(kind, false);
|
|
492
|
+
if (!directory)
|
|
493
|
+
return false;
|
|
214
494
|
const f = this.fileFor(kind, "index");
|
|
215
|
-
|
|
495
|
+
const arr = this.readRawArray(kind, directory, f);
|
|
496
|
+
if (!this.validateExistingFile(directory, f, this.maxBytes(kind)))
|
|
216
497
|
return false;
|
|
217
|
-
const arr = this.readRawArray(f);
|
|
218
498
|
const next = arr.filter((r) => r?.id !== id);
|
|
219
499
|
if (next.length === arr.length)
|
|
220
500
|
return false;
|
|
221
|
-
|
|
501
|
+
this.writeContainedFile(directory, f, encode(next), this.maxBytes(kind));
|
|
222
502
|
this.invalidate(kind);
|
|
223
503
|
return true;
|
|
224
504
|
}
|
|
505
|
+
this.assertSafeRecordId(id);
|
|
506
|
+
const directory = this.safeKindDirectory(kind, false);
|
|
507
|
+
if (!directory)
|
|
508
|
+
return false;
|
|
225
509
|
const f = this.fileFor(kind, id);
|
|
226
|
-
if (!
|
|
510
|
+
if (!this.removeContainedFile(directory, f, this.maxBytes(kind)))
|
|
227
511
|
return false;
|
|
228
|
-
rmSync(f);
|
|
229
512
|
this.invalidate(kind);
|
|
230
513
|
return true;
|
|
231
514
|
}
|
|
@@ -234,18 +517,20 @@ export class JsonStore {
|
|
|
234
517
|
* to empty the PUBLIC store after its records have been moved into the private
|
|
235
518
|
* overlay. Returns the number of files removed. Invalidates the memoized load. */
|
|
236
519
|
dropAll(kind) {
|
|
237
|
-
const
|
|
238
|
-
if (!
|
|
520
|
+
const directory = this.safeKindDirectory(kind, false);
|
|
521
|
+
if (!directory)
|
|
239
522
|
return 0;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
523
|
+
const names = this.jsonFileNames(kind);
|
|
524
|
+
// Preflight first: never partially delete a kind because a later entry is a
|
|
525
|
+
// symlink, device, oversized file, or hard link.
|
|
526
|
+
for (const name of names) {
|
|
527
|
+
this.validateExistingFile(directory, join(directory.lexical, name), this.maxBytes(kind));
|
|
528
|
+
}
|
|
529
|
+
for (const name of names) {
|
|
530
|
+
this.removeContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
|
|
246
531
|
}
|
|
247
532
|
this.invalidate(kind);
|
|
248
|
-
return
|
|
533
|
+
return names.length;
|
|
249
534
|
}
|
|
250
535
|
/** Persist a schema migration: rewrite every LOADABLE record in its current shape.
|
|
251
536
|
* A record that still fails validation after migration is kept untouched (never
|
|
@@ -257,18 +542,19 @@ export class JsonStore {
|
|
|
257
542
|
this.cache.clear(); // every record is rewritten; drop all memoized loads
|
|
258
543
|
const version = this.schemaVersion(); // read once; we're migrating FROM this
|
|
259
544
|
for (const kind of ENTITY_KINDS) {
|
|
260
|
-
const
|
|
261
|
-
if (!
|
|
545
|
+
const directory = this.safeKindDirectory(kind, false);
|
|
546
|
+
if (!directory)
|
|
262
547
|
continue;
|
|
263
548
|
const schema = SCHEMAS[kind];
|
|
264
549
|
const single = SINGLE_FILE[kind];
|
|
265
550
|
if (single) {
|
|
266
|
-
const f = join(
|
|
267
|
-
if (!existsSync(f))
|
|
268
|
-
continue;
|
|
551
|
+
const f = join(directory.lexical, single);
|
|
269
552
|
let arr;
|
|
270
553
|
try {
|
|
271
|
-
|
|
554
|
+
const text = this.readContainedFile(directory, f, this.maxBytes(kind));
|
|
555
|
+
if (text === null)
|
|
556
|
+
continue;
|
|
557
|
+
arr = JSON.parse(text);
|
|
272
558
|
}
|
|
273
559
|
catch {
|
|
274
560
|
skipped++;
|
|
@@ -290,16 +576,17 @@ export class JsonStore {
|
|
|
290
576
|
skipped++;
|
|
291
577
|
}
|
|
292
578
|
}
|
|
293
|
-
|
|
579
|
+
this.writeContainedFile(directory, f, encode(kept), this.maxBytes(kind));
|
|
294
580
|
}
|
|
295
581
|
else {
|
|
296
|
-
for (const name of
|
|
297
|
-
|
|
298
|
-
continue;
|
|
299
|
-
const p = join(dir, name);
|
|
582
|
+
for (const name of this.jsonFileNames(kind)) {
|
|
583
|
+
const p = join(directory.lexical, name);
|
|
300
584
|
let raw;
|
|
301
585
|
try {
|
|
302
|
-
|
|
586
|
+
const text = this.readContainedFile(directory, p, this.maxBytes(kind));
|
|
587
|
+
if (text === null)
|
|
588
|
+
continue;
|
|
589
|
+
raw = JSON.parse(text);
|
|
303
590
|
}
|
|
304
591
|
catch {
|
|
305
592
|
skipped++;
|
|
@@ -307,7 +594,7 @@ export class JsonStore {
|
|
|
307
594
|
}
|
|
308
595
|
const r = schema.safeParse(this.migrate(kind, raw, version));
|
|
309
596
|
if (r.success) {
|
|
310
|
-
|
|
597
|
+
this.writeContainedFile(directory, p, encode(r.data), this.maxBytes(kind));
|
|
311
598
|
migrated++;
|
|
312
599
|
}
|
|
313
600
|
else {
|
package/dist/store/schema.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The JSON files under .hunch/ are the source of truth; this database is rebuilt
|
|
5
5
|
* from them by `hunch index`. JSON-array/object fields are stored as TEXT (JSON)
|
|
6
|
-
* — we only need them indexed where we query them. Search
|
|
7
|
-
* FTS5 table
|
|
6
|
+
* — we only need them indexed where we query them. Search prefers one unified
|
|
7
|
+
* FTS5 table, but db.ts substitutes a plain table when the host Node binary was
|
|
8
|
+
* built without FTS5; the graph is plain tables walked with recursive CTEs.
|
|
8
9
|
*/
|
|
9
10
|
import { shortHash } from "../core/ids.js";
|
|
10
11
|
/** Canonical content hash of the exact title+body that fed both FTS and the
|
|
@@ -71,15 +72,6 @@ CREATE TABLE IF NOT EXISTS constraints (
|
|
|
71
72
|
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
|
|
72
73
|
);
|
|
73
74
|
|
|
74
|
-
-- Unified full-text search across every entity. Rebuilt on index; bm25-ranked.
|
|
75
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
|
|
76
|
-
ref UNINDEXED, -- entity id
|
|
77
|
-
kind UNINDEXED, -- components | edges | symbols | decisions | bugs | constraints
|
|
78
|
-
title,
|
|
79
|
-
body,
|
|
80
|
-
tokenize = 'porter unicode61'
|
|
81
|
-
);
|
|
82
|
-
|
|
83
75
|
-- Local semantic-search vectors (opt-in; written by \`hunch embed\`). One row per
|
|
84
76
|
-- (ref, model); vec is a Float32 BLOB. DELIBERATELY NOT in RESET_SQL: reindex()
|
|
85
77
|
-- runs RESET on nearly every path (MCP startup, every query/context), so resetting
|
|
@@ -91,6 +83,30 @@ CREATE TABLE IF NOT EXISTS embeddings (
|
|
|
91
83
|
PRIMARY KEY (ref, model)
|
|
92
84
|
);
|
|
93
85
|
`;
|
|
86
|
+
/** Preferred search table when the host SQLite build includes FTS5. Kept
|
|
87
|
+
* separate from SCHEMA_SQL so a missing optional SQLite module cannot prevent
|
|
88
|
+
* deterministic graph/constraint operations from opening the derived index. */
|
|
89
|
+
export const FTS_SEARCH_SCHEMA_SQL = /* sql */ `
|
|
90
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
|
|
91
|
+
ref UNINDEXED, -- entity id
|
|
92
|
+
kind UNINDEXED, -- components | edges | symbols | decisions | bugs | constraints
|
|
93
|
+
title,
|
|
94
|
+
body,
|
|
95
|
+
tokenize = 'porter unicode61'
|
|
96
|
+
);
|
|
97
|
+
`;
|
|
98
|
+
/** Portable keyword-search fallback. HunchStore detects that MATCH/bm25 are
|
|
99
|
+
* unavailable and performs a bounded LIKE scan over the same four columns. */
|
|
100
|
+
export const PLAIN_SEARCH_SCHEMA_SQL = /* sql */ `
|
|
101
|
+
CREATE TABLE IF NOT EXISTS search (
|
|
102
|
+
ref TEXT,
|
|
103
|
+
kind TEXT,
|
|
104
|
+
title TEXT,
|
|
105
|
+
body TEXT
|
|
106
|
+
);
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_search_ref ON search(ref);
|
|
108
|
+
CREATE INDEX IF NOT EXISTS idx_search_kind ON search(kind);
|
|
109
|
+
`;
|
|
94
110
|
/** Drop derived data (used before a full reindex). NOTE: embeddings is omitted on
|
|
95
111
|
* purpose — see the embeddings table comment above. */
|
|
96
112
|
export const RESET_SQL = /* sql */ `
|