@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.
Files changed (59) hide show
  1. package/README.md +96 -1
  2. package/dist/cli/index.js +1238 -396
  3. package/dist/constitution/adapters.js +31 -14
  4. package/dist/constitution/behaviorEvaluator.js +20 -7
  5. package/dist/constitution/behaviorProof.js +3 -2
  6. package/dist/constitution/canonical.js +7 -1
  7. package/dist/constitution/card.js +7 -2
  8. package/dist/constitution/compiler.js +71 -1
  9. package/dist/constitution/correctionPolicyMaterializer.js +496 -0
  10. package/dist/constitution/delta.js +3 -2
  11. package/dist/constitution/evaluator.js +29 -3
  12. package/dist/constitution/experiment.js +96 -5
  13. package/dist/constitution/experimentRunner.js +43 -14
  14. package/dist/constitution/g2BehaviorCandidates.js +49 -26
  15. package/dist/constitution/g2BehaviorDependencies.js +203 -14
  16. package/dist/constitution/g2Candidates.js +1 -1
  17. package/dist/constitution/lifecycle.js +17 -0
  18. package/dist/constitution/plan.js +26 -9
  19. package/dist/constitution/replacementFreeGit.js +67 -0
  20. package/dist/constitution/replay.js +6 -0
  21. package/dist/constitution/replayCache.js +1 -1
  22. package/dist/constitution/replayWorker.js +1 -1
  23. package/dist/constitution/repository.js +141 -5
  24. package/dist/constitution/safeCheckout.js +75 -0
  25. package/dist/constitution/schema.js +30 -5
  26. package/dist/constitution/service.js +74 -14
  27. package/dist/constitution/sourceMutation.js +65 -12
  28. package/dist/constitution/staticGraphBaseline.js +44 -0
  29. package/dist/constitution/structural.js +60 -4
  30. package/dist/core/autoreview.js +1 -1
  31. package/dist/core/canonicalOrder.js +6 -0
  32. package/dist/core/conformance.js +68 -27
  33. package/dist/core/docscan.js +2 -1
  34. package/dist/core/escalations.js +11 -0
  35. package/dist/core/io.js +44 -9
  36. package/dist/core/overlaySafety.js +178 -0
  37. package/dist/core/paths.js +13 -2
  38. package/dist/core/safeRepoFile.js +74 -0
  39. package/dist/extractors/comments.js +6 -8
  40. package/dist/extractors/git.js +1631 -82
  41. package/dist/extractors/indexer.js +86 -47
  42. package/dist/extractors/repoSource.js +390 -0
  43. package/dist/integrations/ciAction.js +10 -2
  44. package/dist/integrations/gitignore.js +44 -5
  45. package/dist/integrations/mergeDriver.js +23 -5
  46. package/dist/integrations/sync.js +61 -5
  47. package/dist/integrations/team.js +666 -23
  48. package/dist/mcp/server.js +261 -34
  49. package/dist/store/db.js +57 -7
  50. package/dist/store/hunchStore.js +92 -11
  51. package/dist/store/jsonStore.js +350 -63
  52. package/dist/store/schema.js +27 -11
  53. package/dist/synthesis/provider.js +13 -4
  54. package/dist/synthesis/synthesize.js +56 -19
  55. package/dist/wiki/graph.js +5 -4
  56. package/dist/wiki/wiki.js +16 -10
  57. package/package.json +15 -3
  58. package/tooling/competitive-watch.mjs +108 -0
  59. package/tooling/md1-benchmark.mjs +628 -0
@@ -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, readdirSync, readFileSync, existsSync, rmSync, statSync } from "node:fs";
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 { migrateRaw, readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
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 = !existsSync(this.paths.hunch);
48
- mkdirSync(this.paths.hunch, { recursive: true });
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
- mkdirSync(this.paths.dir(kind), { recursive: true });
51
- if (fresh && !existsSync(this.paths.manifest))
52
- writeManifest(this.paths, SCHEMA_VERSION);
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 v = readManifest(this.paths).schema_version;
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 dir = this.paths.dir(kind);
82
- const mtimeMs = existsSync(dir) ? statSync(dir).mtimeMs : 0;
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 dir = this.paths.dir(kind);
94
- if (!existsSync(dir))
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(dir, single);
102
- if (!existsSync(f))
103
- return [];
365
+ const f = join(directory.lexical, single);
104
366
  let arr;
105
367
  try {
106
- arr = JSON.parse(readFileSync(f, "utf8"));
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 readdirSync(dir)) {
122
- if (!name.endsWith(".json"))
123
- continue;
386
+ for (const name of this.jsonFileNames(kind)) {
124
387
  let raw;
125
388
  try {
126
- raw = JSON.parse(readFileSync(join(dir, name), "utf8"));
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
- writeFileAtomic(f, encode(arr));
423
+ this.writeContainedFile(directory, f, encode(arr), this.maxBytes(kind));
156
424
  }
157
425
  else {
158
- writeFileAtomic(this.fileFor(kind, validated.id), encode(validated));
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
- writeFileAtomic(this.fileFor(kind, "index"), encode(validated));
446
+ this.writeContainedFile(directory, this.fileFor(kind, "index"), encode(validated), this.maxBytes(kind));
175
447
  return;
176
448
  }
177
- // one file per record: clear stale files, then write
178
- for (const name of existsSync(this.paths.dir(kind)) ? readdirSync(this.paths.dir(kind)) : []) {
179
- if (name.endsWith(".json"))
180
- rmSync(join(this.paths.dir(kind), name));
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
- writeFileAtomic(this.fileFor(kind, r.id), encode(r));
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
- if (!existsSync(f))
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
- if (!existsSync(f))
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
- writeFileAtomic(f, encode(next));
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 (!existsSync(f))
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 dir = this.paths.dir(kind);
238
- if (!existsSync(dir))
520
+ const directory = this.safeKindDirectory(kind, false);
521
+ if (!directory)
239
522
  return 0;
240
- let n = 0;
241
- for (const name of readdirSync(dir)) {
242
- if (name.endsWith(".json")) {
243
- rmSync(join(dir, name));
244
- n++;
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 n;
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 dir = this.paths.dir(kind);
261
- if (!existsSync(dir))
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(dir, single);
267
- if (!existsSync(f))
268
- continue;
551
+ const f = join(directory.lexical, single);
269
552
  let arr;
270
553
  try {
271
- arr = JSON.parse(readFileSync(f, "utf8"));
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
- writeFileAtomic(f, encode(kept));
579
+ this.writeContainedFile(directory, f, encode(kept), this.maxBytes(kind));
294
580
  }
295
581
  else {
296
- for (const name of readdirSync(dir)) {
297
- if (!name.endsWith(".json"))
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
- raw = JSON.parse(readFileSync(p, "utf8"));
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
- writeFileAtomic(p, encode(r.data));
597
+ this.writeContainedFile(directory, p, encode(r.data), this.maxBytes(kind));
311
598
  migrated++;
312
599
  }
313
600
  else {
@@ -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 is a single unified
7
- * FTS5 table; the graph is plain tables walked with recursive CTEs.
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 */ `