@spikedpunch/mast 0.1.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.
Files changed (257) hide show
  1. package/LICENSE +21 -0
  2. package/MAST_SPEC.md +2926 -0
  3. package/README.md +842 -0
  4. package/assets/skill.md +45 -0
  5. package/dist/ast/extract.d.ts +65 -0
  6. package/dist/ast/extract.d.ts.map +1 -0
  7. package/dist/ast/extract.js +147 -0
  8. package/dist/ast/extract.js.map +1 -0
  9. package/dist/ast/extractor.d.ts +85 -0
  10. package/dist/ast/extractor.d.ts.map +1 -0
  11. package/dist/ast/extractor.js +2 -0
  12. package/dist/ast/extractor.js.map +1 -0
  13. package/dist/ast/extractors/markdown.d.ts +40 -0
  14. package/dist/ast/extractors/markdown.d.ts.map +1 -0
  15. package/dist/ast/extractors/markdown.js +175 -0
  16. package/dist/ast/extractors/markdown.js.map +1 -0
  17. package/dist/ast/extractors/typescript.d.ts +179 -0
  18. package/dist/ast/extractors/typescript.d.ts.map +1 -0
  19. package/dist/ast/extractors/typescript.js +1439 -0
  20. package/dist/ast/extractors/typescript.js.map +1 -0
  21. package/dist/ast/parser.d.ts +5 -0
  22. package/dist/ast/parser.d.ts.map +1 -0
  23. package/dist/ast/parser.js +21 -0
  24. package/dist/ast/parser.js.map +1 -0
  25. package/dist/ast/types.d.ts +586 -0
  26. package/dist/ast/types.d.ts.map +1 -0
  27. package/dist/ast/types.js +3 -0
  28. package/dist/ast/types.js.map +1 -0
  29. package/dist/cli/docs-cmd.d.ts +22 -0
  30. package/dist/cli/docs-cmd.d.ts.map +1 -0
  31. package/dist/cli/docs-cmd.js +100 -0
  32. package/dist/cli/docs-cmd.js.map +1 -0
  33. package/dist/cli/index-cmd.d.ts +72 -0
  34. package/dist/cli/index-cmd.d.ts.map +1 -0
  35. package/dist/cli/index-cmd.js +263 -0
  36. package/dist/cli/index-cmd.js.map +1 -0
  37. package/dist/cli/index.d.ts +3 -0
  38. package/dist/cli/index.d.ts.map +1 -0
  39. package/dist/cli/index.js +4 -0
  40. package/dist/cli/index.js.map +1 -0
  41. package/dist/cli/init.d.ts +14 -0
  42. package/dist/cli/init.d.ts.map +1 -0
  43. package/dist/cli/init.js +64 -0
  44. package/dist/cli/init.js.map +1 -0
  45. package/dist/cli/install-hooks.d.ts +3 -0
  46. package/dist/cli/install-hooks.d.ts.map +1 -0
  47. package/dist/cli/install-hooks.js +34 -0
  48. package/dist/cli/install-hooks.js.map +1 -0
  49. package/dist/cli/metrics-cmd.d.ts +3 -0
  50. package/dist/cli/metrics-cmd.d.ts.map +1 -0
  51. package/dist/cli/metrics-cmd.js +164 -0
  52. package/dist/cli/metrics-cmd.js.map +1 -0
  53. package/dist/cli/program.d.ts +14 -0
  54. package/dist/cli/program.d.ts.map +1 -0
  55. package/dist/cli/program.js +44 -0
  56. package/dist/cli/program.js.map +1 -0
  57. package/dist/cli/query.d.ts +46 -0
  58. package/dist/cli/query.d.ts.map +1 -0
  59. package/dist/cli/query.js +153 -0
  60. package/dist/cli/query.js.map +1 -0
  61. package/dist/cli/search-cmd.d.ts +33 -0
  62. package/dist/cli/search-cmd.d.ts.map +1 -0
  63. package/dist/cli/search-cmd.js +114 -0
  64. package/dist/cli/search-cmd.js.map +1 -0
  65. package/dist/cli/serve.d.ts +3 -0
  66. package/dist/cli/serve.d.ts.map +1 -0
  67. package/dist/cli/serve.js +19 -0
  68. package/dist/cli/serve.js.map +1 -0
  69. package/dist/cli/skill-install.d.ts +39 -0
  70. package/dist/cli/skill-install.d.ts.map +1 -0
  71. package/dist/cli/skill-install.js +57 -0
  72. package/dist/cli/skill-install.js.map +1 -0
  73. package/dist/cli/status.d.ts +35 -0
  74. package/dist/cli/status.d.ts.map +1 -0
  75. package/dist/cli/status.js +117 -0
  76. package/dist/cli/status.js.map +1 -0
  77. package/dist/cli/upgrade-cmd.d.ts +48 -0
  78. package/dist/cli/upgrade-cmd.d.ts.map +1 -0
  79. package/dist/cli/upgrade-cmd.js +129 -0
  80. package/dist/cli/upgrade-cmd.js.map +1 -0
  81. package/dist/cli/version.d.ts +3 -0
  82. package/dist/cli/version.d.ts.map +1 -0
  83. package/dist/cli/version.js +19 -0
  84. package/dist/cli/version.js.map +1 -0
  85. package/dist/env.d.ts +17 -0
  86. package/dist/env.d.ts.map +1 -0
  87. package/dist/env.js +17 -0
  88. package/dist/env.js.map +1 -0
  89. package/dist/graph/checker-resolver.d.ts +156 -0
  90. package/dist/graph/checker-resolver.d.ts.map +1 -0
  91. package/dist/graph/checker-resolver.js +458 -0
  92. package/dist/graph/checker-resolver.js.map +1 -0
  93. package/dist/graph/db.d.ts +318 -0
  94. package/dist/graph/db.d.ts.map +1 -0
  95. package/dist/graph/db.js +266 -0
  96. package/dist/graph/db.js.map +1 -0
  97. package/dist/graph/index.d.ts +4 -0
  98. package/dist/graph/index.d.ts.map +1 -0
  99. package/dist/graph/index.js +5 -0
  100. package/dist/graph/index.js.map +1 -0
  101. package/dist/graph/local-type-env.d.ts +45 -0
  102. package/dist/graph/local-type-env.d.ts.map +1 -0
  103. package/dist/graph/local-type-env.js +59 -0
  104. package/dist/graph/local-type-env.js.map +1 -0
  105. package/dist/graph/path-range.d.ts +40 -0
  106. package/dist/graph/path-range.d.ts.map +1 -0
  107. package/dist/graph/path-range.js +42 -0
  108. package/dist/graph/path-range.js.map +1 -0
  109. package/dist/graph/populate.d.ts +263 -0
  110. package/dist/graph/populate.d.ts.map +1 -0
  111. package/dist/graph/populate.js +984 -0
  112. package/dist/graph/populate.js.map +1 -0
  113. package/dist/graph/queries.d.ts +114 -0
  114. package/dist/graph/queries.d.ts.map +1 -0
  115. package/dist/graph/queries.js +415 -0
  116. package/dist/graph/queries.js.map +1 -0
  117. package/dist/graph/sqliteBatch.d.ts +48 -0
  118. package/dist/graph/sqliteBatch.d.ts.map +1 -0
  119. package/dist/graph/sqliteBatch.js +93 -0
  120. package/dist/graph/sqliteBatch.js.map +1 -0
  121. package/dist/indexer/freshness.d.ts +48 -0
  122. package/dist/indexer/freshness.d.ts.map +1 -0
  123. package/dist/indexer/freshness.js +72 -0
  124. package/dist/indexer/freshness.js.map +1 -0
  125. package/dist/indexer/import-resolver.d.ts +50 -0
  126. package/dist/indexer/import-resolver.d.ts.map +1 -0
  127. package/dist/indexer/import-resolver.js +318 -0
  128. package/dist/indexer/import-resolver.js.map +1 -0
  129. package/dist/indexer/index.d.ts +249 -0
  130. package/dist/indexer/index.d.ts.map +1 -0
  131. package/dist/indexer/index.js +560 -0
  132. package/dist/indexer/index.js.map +1 -0
  133. package/dist/indexer/walker.d.ts +60 -0
  134. package/dist/indexer/walker.d.ts.map +1 -0
  135. package/dist/indexer/walker.js +128 -0
  136. package/dist/indexer/walker.js.map +1 -0
  137. package/dist/indexer/watcher.d.ts +80 -0
  138. package/dist/indexer/watcher.d.ts.map +1 -0
  139. package/dist/indexer/watcher.js +151 -0
  140. package/dist/indexer/watcher.js.map +1 -0
  141. package/dist/mcp/context.d.ts +23 -0
  142. package/dist/mcp/context.d.ts.map +1 -0
  143. package/dist/mcp/context.js +2 -0
  144. package/dist/mcp/context.js.map +1 -0
  145. package/dist/mcp/register-tools.d.ts +21 -0
  146. package/dist/mcp/register-tools.d.ts.map +1 -0
  147. package/dist/mcp/register-tools.js +42 -0
  148. package/dist/mcp/register-tools.js.map +1 -0
  149. package/dist/mcp/server.d.ts +58 -0
  150. package/dist/mcp/server.d.ts.map +1 -0
  151. package/dist/mcp/server.js +177 -0
  152. package/dist/mcp/server.js.map +1 -0
  153. package/dist/mcp/staleness.d.ts +72 -0
  154. package/dist/mcp/staleness.d.ts.map +1 -0
  155. package/dist/mcp/staleness.js +213 -0
  156. package/dist/mcp/staleness.js.map +1 -0
  157. package/dist/mcp/startup.d.ts +40 -0
  158. package/dist/mcp/startup.d.ts.map +1 -0
  159. package/dist/mcp/startup.js +114 -0
  160. package/dist/mcp/startup.js.map +1 -0
  161. package/dist/mcp/tools/_helpers.d.ts +26 -0
  162. package/dist/mcp/tools/_helpers.d.ts.map +1 -0
  163. package/dist/mcp/tools/_helpers.js +54 -0
  164. package/dist/mcp/tools/_helpers.js.map +1 -0
  165. package/dist/mcp/tools/callers.d.ts +4 -0
  166. package/dist/mcp/tools/callers.d.ts.map +1 -0
  167. package/dist/mcp/tools/callers.js +112 -0
  168. package/dist/mcp/tools/callers.js.map +1 -0
  169. package/dist/mcp/tools/dependencies.d.ts +4 -0
  170. package/dist/mcp/tools/dependencies.d.ts.map +1 -0
  171. package/dist/mcp/tools/dependencies.js +36 -0
  172. package/dist/mcp/tools/dependencies.js.map +1 -0
  173. package/dist/mcp/tools/efficiency.d.ts +4 -0
  174. package/dist/mcp/tools/efficiency.d.ts.map +1 -0
  175. package/dist/mcp/tools/efficiency.js +39 -0
  176. package/dist/mcp/tools/efficiency.js.map +1 -0
  177. package/dist/mcp/tools/exports.d.ts +4 -0
  178. package/dist/mcp/tools/exports.d.ts.map +1 -0
  179. package/dist/mcp/tools/exports.js +56 -0
  180. package/dist/mcp/tools/exports.js.map +1 -0
  181. package/dist/mcp/tools/implementors.d.ts +4 -0
  182. package/dist/mcp/tools/implementors.d.ts.map +1 -0
  183. package/dist/mcp/tools/implementors.js +47 -0
  184. package/dist/mcp/tools/implementors.js.map +1 -0
  185. package/dist/mcp/tools/project-skeleton.d.ts +4 -0
  186. package/dist/mcp/tools/project-skeleton.d.ts.map +1 -0
  187. package/dist/mcp/tools/project-skeleton.js +61 -0
  188. package/dist/mcp/tools/project-skeleton.js.map +1 -0
  189. package/dist/mcp/tools/reindex.d.ts +14 -0
  190. package/dist/mcp/tools/reindex.d.ts.map +1 -0
  191. package/dist/mcp/tools/reindex.js +32 -0
  192. package/dist/mcp/tools/reindex.js.map +1 -0
  193. package/dist/mcp/tools/rename-impact.d.ts +4 -0
  194. package/dist/mcp/tools/rename-impact.d.ts.map +1 -0
  195. package/dist/mcp/tools/rename-impact.js +124 -0
  196. package/dist/mcp/tools/rename-impact.js.map +1 -0
  197. package/dist/mcp/tools/search.d.ts +4 -0
  198. package/dist/mcp/tools/search.d.ts.map +1 -0
  199. package/dist/mcp/tools/search.js +69 -0
  200. package/dist/mcp/tools/search.js.map +1 -0
  201. package/dist/mcp/tools/signature.d.ts +4 -0
  202. package/dist/mcp/tools/signature.d.ts.map +1 -0
  203. package/dist/mcp/tools/signature.js +127 -0
  204. package/dist/mcp/tools/signature.js.map +1 -0
  205. package/dist/mcp/tools/status.d.ts +4 -0
  206. package/dist/mcp/tools/status.d.ts.map +1 -0
  207. package/dist/mcp/tools/status.js +30 -0
  208. package/dist/mcp/tools/status.js.map +1 -0
  209. package/dist/search/declex.d.ts +109 -0
  210. package/dist/search/declex.d.ts.map +1 -0
  211. package/dist/search/declex.js +242 -0
  212. package/dist/search/declex.js.map +1 -0
  213. package/dist/search/fts.d.ts +77 -0
  214. package/dist/search/fts.d.ts.map +1 -0
  215. package/dist/search/fts.js +223 -0
  216. package/dist/search/fts.js.map +1 -0
  217. package/dist/search/fused.d.ts +110 -0
  218. package/dist/search/fused.d.ts.map +1 -0
  219. package/dist/search/fused.js +323 -0
  220. package/dist/search/fused.js.map +1 -0
  221. package/dist/search/potential-matches.d.ts +88 -0
  222. package/dist/search/potential-matches.d.ts.map +1 -0
  223. package/dist/search/potential-matches.js +83 -0
  224. package/dist/search/potential-matches.js.map +1 -0
  225. package/dist/search/scope.d.ts +66 -0
  226. package/dist/search/scope.d.ts.map +1 -0
  227. package/dist/search/scope.js +71 -0
  228. package/dist/search/scope.js.map +1 -0
  229. package/dist/store/config.d.ts +57 -0
  230. package/dist/store/config.d.ts.map +1 -0
  231. package/dist/store/config.js +208 -0
  232. package/dist/store/config.js.map +1 -0
  233. package/dist/store/lock.d.ts +101 -0
  234. package/dist/store/lock.d.ts.map +1 -0
  235. package/dist/store/lock.js +182 -0
  236. package/dist/store/lock.js.map +1 -0
  237. package/dist/store/lockMetrics.d.ts +52 -0
  238. package/dist/store/lockMetrics.d.ts.map +1 -0
  239. package/dist/store/lockMetrics.js +29 -0
  240. package/dist/store/lockMetrics.js.map +1 -0
  241. package/dist/store/sqliteChunkStore.d.ts +44 -0
  242. package/dist/store/sqliteChunkStore.d.ts.map +1 -0
  243. package/dist/store/sqliteChunkStore.js +135 -0
  244. package/dist/store/sqliteChunkStore.js.map +1 -0
  245. package/dist/telemetry/lockMetricsSummary.d.ts +42 -0
  246. package/dist/telemetry/lockMetricsSummary.d.ts.map +1 -0
  247. package/dist/telemetry/lockMetricsSummary.js +139 -0
  248. package/dist/telemetry/lockMetricsSummary.js.map +1 -0
  249. package/dist/telemetry/metrics.d.ts +140 -0
  250. package/dist/telemetry/metrics.d.ts.map +1 -0
  251. package/dist/telemetry/metrics.js +312 -0
  252. package/dist/telemetry/metrics.js.map +1 -0
  253. package/dist/telemetry/tokenizer.d.ts +125 -0
  254. package/dist/telemetry/tokenizer.d.ts.map +1 -0
  255. package/dist/telemetry/tokenizer.js +174 -0
  256. package/dist/telemetry/tokenizer.js.map +1 -0
  257. package/package.json +81 -0
@@ -0,0 +1,984 @@
1
+ import { sql } from './db.js';
2
+ import { chunkRowsForSqlite, chunkValuesForSqlite } from './sqliteBatch.js';
3
+ import { pathPrefixUpperBound } from './path-range.js';
4
+ /** A zeroed {@link WriteSpansMs} accumulator. */
5
+ export function newWriteSpans() {
6
+ return { fts_del: 0, fts_ins: 0, commit: 0, rest: 0, txn: 0, lock: 0 };
7
+ }
8
+ /**
9
+ * Charge one region's elapsed wall-clock to `key`.
10
+ *
11
+ * `performance.now()` rather than `Date.now()`: the registration costed the
12
+ * timers against `Date.now()`, but measured on this machine that clock yields
13
+ * only 33 distinct values across a 200,000-call burst (~1 ms granularity) while
14
+ * costing 65.3 ns/call, against 34.8 ns/call and full sub-microsecond
15
+ * resolution for `performance.now()`. At T1 a per-file FTS delete runs well
16
+ * under a millisecond, so `Date.now()` would round each one to 0 or 1 — turning
17
+ * the cheapest rung, which anchors the growth exponent being measured, into a
18
+ * coin flip. The higher-resolution clock is cheaper AND less biased, so the
19
+ * deviation from the registered clock is in the direction of a harder test.
20
+ *
21
+ * Returns `fn()` untouched when no accumulator was supplied, so the production
22
+ * path — which never passes one — pays nothing at all.
23
+ */
24
+ async function timed(spans, key, fn) {
25
+ if (spans === undefined)
26
+ return fn();
27
+ const started = performance.now();
28
+ try {
29
+ return await fn();
30
+ }
31
+ finally {
32
+ spans[key] += performance.now() - started;
33
+ }
34
+ }
35
+ /**
36
+ * Dedicated `busy_timeout` (ms) for {@link populateFile}'s own transaction —
37
+ * distinct from `graph.db`'s shared 5000ms connection default
38
+ * (`openDatabase`, `graph/db.ts`).
39
+ *
40
+ * F11 (`IMPLEMENTATION_PLAN.md` "Replace fail-fast advisory locking") moves
41
+ * this transaction from Kysely's deferred `BEGIN` to `BEGIN IMMEDIATE` (see
42
+ * `populateFile`'s doc comment for why) so it takes the write reservation up
43
+ * front instead of discovering contention via `SQLITE_BUSY_SNAPSHOT` on its
44
+ * own commit (F13). That makes the busy_timeout wait live for the first time
45
+ * on this path — under the inherited 5000ms default, ANY genuine contention
46
+ * would block better-sqlite3's synchronous busy-wait for up to 5 seconds,
47
+ * freezing the ENTIRE `mast serve` process (its native busy-wait blocks the
48
+ * whole event loop, not just the calling promise chain — measured directly in
49
+ * `eval/eventloop-probe.json`, see IMPLEMENTATION_PLAN.md's "HARD CONSTRAINT
50
+ * ON F11"). 200ms keeps that freeze window in the same neighbourhood as the
51
+ * 3x100ms `structure.lock` retry budget the JIT path used to pay instead of
52
+ * ever reaching SQLite's own wait (`mcp/staleness.ts`, pre-F11), rather than
53
+ * inheriting the 25x-longer 5000ms shared default. Set and restored only
54
+ * around this transaction's own exclusive connection window (see
55
+ * `populateFile`) so no unrelated statement on the shared connection ever
56
+ * inherits the short value.
57
+ */
58
+ export const IMMEDIATE_WRITE_BUSY_TIMEOUT_MS = 200;
59
+ /** `graph.db`'s shared connection-wide default (`openDatabase`, `graph/db.ts`) — restored after {@link populateFile}'s short window closes. */
60
+ const DEFAULT_BUSY_TIMEOUT_MS = 5_000;
61
+ /**
62
+ * Delete all rows for `filePath` from files, symbols, edges, imports,
63
+ * re_export_files (cascaded via FK), chunks, and FTS5 tables, then re-insert
64
+ * everything from `data` — all within a single SQLite transaction.
65
+ *
66
+ * M1 (`eval/GITNEXUS_COMPARISON.md` §15.1): chunk rows join this SAME
67
+ * transaction instead of being written by a separate chunk-store call before
68
+ * this function runs. That closes the consistency seam the spike deliberately
69
+ * left open — a chunk-store write succeeding while the graph write then fails
70
+ * (or vice versa) can no longer leave the two out of sync, because there is
71
+ * only one commit/rollback boundary for both.
72
+ *
73
+ * The two-pass structure (insert all symbols first, then insert all edges
74
+ * via `insertEdges`) is required for cross-file POTENTIAL_CALL resolution.
75
+ *
76
+ * **Monotonic write-guard (F12, `GITNEXUS_COMPARISON.md` Stage 1)**: refuses
77
+ * to replace a row whose stored `mtime` already exceeds `data.mtime`. Two
78
+ * writers can legitimately race to write the same file — a reindex batch and
79
+ * a concurrent JIT refresh (`mcp/staleness.ts`) both call this function.
80
+ * Without this guard, whichever writer commits LAST wins even if it parsed
81
+ * OLDER content — silently regressing the row. With it, the write carrying
82
+ * the NEWER stamp always wins, independent of arrival order, which is what
83
+ * actually makes the ordering guarantee in `runIndex`'s WHY-comment
84
+ * (`indexer/index.ts`) hold. This is strictly subject to mtime-granularity
85
+ * blindness (see that WHY-comment) — two writes landing in the same tick
86
+ * compare equal, not ordered, and whichever call happens second wins; that is
87
+ * a known, documented limitation, not something this guard claims to solve.
88
+ *
89
+ * **`BEGIN IMMEDIATE`, not a plain `db.transaction()` (F11)**: Kysely's
90
+ * better-sqlite3 driver only ever issues a deferred `BEGIN`
91
+ * (`sqlite-driver.js`'s `beginTransaction` — `CompiledQuery.raw('begin')`,
92
+ * hardcoded), and there is no config knob to change that. A deferred-BEGIN
93
+ * read-then-write (this function's own monotonic-guard SELECT, followed by
94
+ * its writes) can fail `SQLITE_BUSY_SNAPSHOT` in 1-2ms against ANY competing
95
+ * holder — even one that never commits — which `busy_timeout` cannot wait
96
+ * out, because the snapshot is already stale, not merely locked (F13,
97
+ * `eval/e7-round2.json`, 52 real occurrences). `BEGIN IMMEDIATE` takes the
98
+ * write reservation up front instead, eliminating that failure class and
99
+ * falling back to an honest bounded `busy_timeout` wait when genuinely
100
+ * contended (`eval/eventloop-probe.json` Phase 2/3). Since Kysely cannot be
101
+ * asked for `BEGIN IMMEDIATE` via `db.transaction()`, this function instead
102
+ * checks out the underlying connection exclusively via `db.connection()` and
103
+ * issues `begin immediate` / `commit` / `rollback` as raw statements around
104
+ * the same statement sequence a `db.transaction()` callback would have run.
105
+ * Kysely's SQLite adapter reports `supportsMultipleConnections: false`, so
106
+ * `RuntimeDriver` (`runtime-driver.js`) guards every connection acquisition
107
+ * on a given `Db` instance with one `ConnectionMutex` — verified by reading
108
+ * that source AND empirically (20 independently-staggered concurrent
109
+ * `db.connection().execute()` calls against one shared `Db`: zero
110
+ * interleaving errors, all 20 rows landed, in submission order). That means
111
+ * no OTHER statement issued through the SAME `Db` instance — chiefly a
112
+ * same-process concurrent JIT refresh of a different file, now that F11
113
+ * removes `structure.lock` from that path — can interleave into this
114
+ * transaction's raw `begin immediate` / ... / `commit` window. A genuinely
115
+ * different connection (reindex's own `openDatabase()` call in
116
+ * `indexer/index.ts`, or another `mast serve` process) is real SQLite-level
117
+ * concurrency, correctly governed by `BEGIN IMMEDIATE`'s write-reservation
118
+ * semantics and this transaction's own short `busy_timeout`
119
+ * ({@link IMMEDIATE_WRITE_BUSY_TIMEOUT_MS}), not by this in-process mutex.
120
+ */
121
+ export async function populateFile(db, data, options = {}) {
122
+ // Stamped before `db.connection()` so the `txn` span includes the connection
123
+ // checkout itself — Kysely serialises every acquisition on one
124
+ // `ConnectionMutex` (see the doc comment above), so that wait is real.
125
+ const enteredAt = options.spans === undefined ? 0 : performance.now();
126
+ return db.connection().execute(async (conn) => {
127
+ if (options.spans !== undefined)
128
+ options.spans.txn += performance.now() - enteredAt;
129
+ // The busy_timeout toggle must happen INSIDE this exclusive connection
130
+ // window (see the doc comment above) so no unrelated statement on the
131
+ // shared connection ever runs with the short value — pragmas are cheap
132
+ // and synchronous, so bracketing the transaction with them costs nothing
133
+ // measurable.
134
+ await timed(options.spans, 'txn', () => sql.raw(`pragma busy_timeout = ${IMMEDIATE_WRITE_BUSY_TIMEOUT_MS}`).execute(conn));
135
+ try {
136
+ await timed(options.spans, 'txn', () => sql `begin immediate`.execute(conn));
137
+ }
138
+ catch (err) {
139
+ // BEGIN IMMEDIATE itself lost the busy_timeout wait — no transaction
140
+ // was ever opened, so there is nothing to roll back. Restore the
141
+ // shared default before propagating.
142
+ await sql.raw(`pragma busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS}`).execute(conn);
143
+ throw err;
144
+ }
145
+ try {
146
+ const result = await writePopulatedFileRows(conn, data, options);
147
+ // Timed as its own region because this is where FTS5 actually writes its
148
+ // segments — `fts5SyncMethod` runs at COMMIT (sqlite3.c:262278), not
149
+ // inside the INSERT statements above.
150
+ await timed(options.spans, 'commit', () => sql `commit`.execute(conn));
151
+ return result;
152
+ }
153
+ catch (err) {
154
+ await sql `rollback`.execute(conn);
155
+ throw err;
156
+ }
157
+ finally {
158
+ // Runs after both the commit and the rollback branches above — see the
159
+ // doc comment's "checks out the underlying connection exclusively"
160
+ // paragraph for why this must land before the connection is released.
161
+ await timed(options.spans, 'txn', () => sql.raw(`pragma busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS}`).execute(conn));
162
+ }
163
+ });
164
+ }
165
+ const EMPTY_FTS_BLOCK = { lo: null, hi: null };
166
+ /**
167
+ * Reserves the next `rowCount` rowids in `table`.
168
+ *
169
+ * SQLite assigns an unspecified rowid as `max(rowid) + 1`, so reserving is just
170
+ * reading that maximum — `SEARCH ... INDEX 192:`, measured at 0.0008 ms on a
171
+ * 73,359-row `chunk_fts`. Callers must reserve BEFORE deleting the file's old
172
+ * block, so that the reserved range cannot collide with rows still present.
173
+ * (Deleting first would lower the maximum and hand back rowids the old block
174
+ * still occupies.) Reserving early only ever leaves gaps, which cost nothing.
175
+ */
176
+ async function reserveFtsBlock(trx, table, rowCount) {
177
+ if (rowCount === 0)
178
+ return EMPTY_FTS_BLOCK;
179
+ const result = await sql `
180
+ SELECT max(rowid) AS m FROM ${sql.table(table)}
181
+ `.execute(trx);
182
+ const lo = (result.rows[0]?.m ?? 0) + 1;
183
+ return { lo, hi: lo + rowCount - 1 };
184
+ }
185
+ /**
186
+ * Removes one file's rows from an FTS5 table, using its recorded rowid block.
187
+ *
188
+ * Issued as one `WHERE rowid = ?` per row rather than a single
189
+ * `WHERE rowid BETWEEN ? AND ?`, which looks equivalent and is not: FTS5
190
+ * reports a rowid RANGE as `SCAN ... INDEX 0:=`, and the `SCAN` is literal —
191
+ * measured at 75.96 ms against 75.01 ms for an unconstrained scan on T9, i.e.
192
+ * no saving at all. Only exact equality is a seek. Same corpus, same file:
193
+ * 1.125 ms by per-rowid equality against 129.8 ms by `file_path`.
194
+ *
195
+ * That makes the cost O(rows in this file) where it was O(rows in the corpus),
196
+ * so it wins by more the larger the repository gets. The two curves do cross
197
+ * for a file holding a large fraction of the corpus — at T9 scale, measured on
198
+ * a synthetic corpus, a 3,000-chunk file (4% of all chunks) is still 1.3x
199
+ * faster this way, and a 5-chunk file 27x. No real file approaches the
200
+ * crossover, so there is deliberately no size heuristic here: an untested
201
+ * branch that fires on no real input is worse than the branch it replaces.
202
+ *
203
+ * Raw SQL because `rowid` is a column Kysely's schema models only as an
204
+ * insert-time hint; `sql.table` takes a literal from {@link FtsTable}, never
205
+ * caller input.
206
+ */
207
+ async function deleteFtsRowidBlock(trx, table, block, filePath) {
208
+ if (block.lo === null || block.hi === null) {
209
+ // No block recorded — a `files` row written before Stage 4.6 added the
210
+ // columns. Fall back to the scan this change exists to remove: slow, but
211
+ // correct, and self-healing because the row is about to be rewritten with
212
+ // a block. Never skipped — skipping would leave stale rows findable.
213
+ await sql `DELETE FROM ${sql.table(table)} WHERE file_path = ${filePath}`.execute(trx);
214
+ return;
215
+ }
216
+ for (let rowid = block.lo; rowid <= block.hi; rowid++) {
217
+ await sql `DELETE FROM ${sql.table(table)} WHERE rowid = ${rowid}`.execute(trx);
218
+ }
219
+ }
220
+ async function writePopulatedFileRows(trx, data, options) {
221
+ const { chunkWriter, spans } = options;
222
+ // Monotonic write-guard — see the F12 paragraph in populateFile's doc
223
+ // comment above. Reading the existing row's mtime and deciding whether to
224
+ // proceed inside the SAME transaction that performs the delete-and-replace
225
+ // keeps the check-then-act pair atomic relative to any other populateFile
226
+ // call, exactly as invariant 1's read-then-write pair is kept atomic
227
+ // relative to other `structure.lock` holders (indexer/index.ts).
228
+ // Selecting the FTS blocks here — rather than at the delete below — is what
229
+ // makes the whole scheme work: this SELECT runs BEFORE the `files` row is
230
+ // deleted and reinserted a few lines down, so it is the last point at which
231
+ // the OLD block is still readable.
232
+ const existing = await timed(spans, 'rest', () => trx
233
+ .selectFrom('files')
234
+ .select(['id', 'mtime', 'chunk_fts_lo', 'chunk_fts_hi', 'ident_fts_lo', 'ident_fts_hi'])
235
+ .where('path', '=', data.filePath)
236
+ .executeTakeFirst());
237
+ if (existing !== undefined && existing.mtime > data.mtime) {
238
+ // Logged at WARN, not ERROR — this is a correctly-refused stale write,
239
+ // not a failure (contrast the write-failure ERROR log below). Still
240
+ // never silent: a caller that ignored this row's `written: false`
241
+ // would see a normal-looking `PopulateFileResult` and never learn its
242
+ // parse was discarded.
243
+ process.stderr.write(`[mast] WARN: monotonic write-guard rejected a stale write for ${data.filePath} ` +
244
+ `(stored mtime ${existing.mtime} > incoming ${data.mtime}) — existing row left unchanged\n`);
245
+ return { fileId: existing.id, chunksRemoved: 0, written: false };
246
+ }
247
+ // Reserved before the old rows are deleted, so the new block cannot overlap
248
+ // rows that are still present — see `reserveFtsBlock`. The two tables get
249
+ // different counts for the same file (markdown chunks produce no identifier
250
+ // rows), so they are reserved independently.
251
+ const chunkBlock = await timed(spans, 'rest', () => reserveFtsBlock(trx, 'chunk_fts', data.chunks.length));
252
+ const identBlock = await timed(spans, 'rest', () => reserveFtsBlock(trx, 'identifier_fts', data.identifierRows.length));
253
+ // Delete-and-replace: FK cascades remove symbols, edges, imports.
254
+ const file = await timed(spans, 'rest', async () => {
255
+ await trx.deleteFrom('files').where('path', '=', data.filePath).execute();
256
+ const [row] = await trx
257
+ .insertInto('files')
258
+ .values({
259
+ path: data.filePath,
260
+ language: data.language,
261
+ mtime: data.mtime,
262
+ chunk_fts_lo: chunkBlock.lo,
263
+ chunk_fts_hi: chunkBlock.hi,
264
+ ident_fts_lo: identBlock.lo,
265
+ ident_fts_hi: identBlock.hi,
266
+ })
267
+ .returning('id')
268
+ .execute();
269
+ return row;
270
+ });
271
+ if (file === undefined)
272
+ throw new Error(`Insert into files returned no id for ${data.filePath}`);
273
+ const fileId = file.id;
274
+ // Chunks — same transaction as the rest of this file's derived state
275
+ // (§15.1). Default path writes the shared `chunks` table directly;
276
+ // `chunkWriter` (test-only) substitutes an injected implementation, see
277
+ // its docstring above for why that stays atomic too.
278
+ const chunksRemoved = await timed(spans, 'rest', () => chunkWriter !== undefined
279
+ ? chunkWriter(data.filePath, data.chunks)
280
+ : replaceChunksInline(trx, data.filePath, data.chunks));
281
+ // Insert symbols. Batched under SQLite's 32,766 bound-parameter ceiling
282
+ // (Stage 4.5 S1, IMPLEMENTATION_PLAN.md — see `replaceChunksInline`'s
283
+ // WHY-comment below for the full defect and why batching the statement
284
+ // rather than the transaction preserves atomicity).
285
+ if (data.symbols.length > 0) {
286
+ // Explicit row-type annotation (`is_exported: 0 | 1`, not `number`) —
287
+ // extracting this `.map()` into its own `const` (needed so the same
288
+ // array can be both batched and, in principle, inspected) loses the
289
+ // contextual typing `.values(data.symbols.map(...))` got for free when
290
+ // the ternary's result fed straight into Kysely's `InsertObject`;
291
+ // without this annotation, `s.isExported ? 1 : 0` widens to `number`
292
+ // and fails `symbols`'s `BoolCol` (`0 | 1`) column type.
293
+ const symbolRows = data.symbols.map((s) => ({
294
+ name: s.name,
295
+ kind: s.kind,
296
+ file_id: fileId,
297
+ line: s.line,
298
+ is_exported: s.isExported ? 1 : 0,
299
+ declaration_hash: s.declarationHash,
300
+ body_hash: s.bodyHash,
301
+ }));
302
+ await timed(spans, 'rest', async () => {
303
+ for (const batch of chunkRowsForSqlite(symbolRows)) {
304
+ await trx.insertInto('symbols').values(batch).execute();
305
+ }
306
+ });
307
+ }
308
+ // Insert imports. Same batching as symbols above.
309
+ if (data.imports.length > 0) {
310
+ // Same widening issue and fix as `symbolRows` above (`is_external` is
311
+ // also a `BoolCol`).
312
+ const importRows = data.imports.map((imp) => ({
313
+ file_id: fileId,
314
+ module: imp.module,
315
+ symbols: JSON.stringify(imp.symbols),
316
+ is_external: imp.isExternal ? 1 : 0,
317
+ resolved_path: imp.resolvedPath,
318
+ }));
319
+ await timed(spans, 'rest', async () => {
320
+ for (const batch of chunkRowsForSqlite(importRows)) {
321
+ await trx.insertInto('imports').values(batch).execute();
322
+ }
323
+ });
324
+ }
325
+ // FTS5 updates — same transaction as graph writes (§7.1 step 5).
326
+ //
327
+ // Delete existing rows by file_path, but ONLY when this file had a previous
328
+ // version. FTS5 supports the predicate on an UNINDEXED column and cannot use
329
+ // it: `xBestIndex` (sqlite3.c:260775-260860) will not consume an equality
330
+ // constraint on an ordinary column, so each statement is
331
+ // `SCAN <table> VIRTUAL TABLE INDEX 0:` — a full table scan of an index that
332
+ // grows with the whole corpus, giving the write phase a quadratic term.
333
+ //
334
+ // E1-FTS measured it (IMPLEMENTATION_PLAN.md § E1-FTS RESULT): at T9 the two
335
+ // deletes were **91.7% of the write phase**, growing with exponent 2.35, and
336
+ // on a cold build every one of them matched ZERO rows. Skipping them took the
337
+ // write phase's exponent from 1.94 to 1.10 and T9's cold build from 499 s to
338
+ // 59 s.
339
+ //
340
+ // `existing` is the monotonic write-guard's own SELECT, a few lines above —
341
+ // this reuses a read that already happened rather than adding one. Its safety
342
+ // rests on a single invariant:
343
+ //
344
+ // A file's FTS rows exist only if its `files` row exists.
345
+ //
346
+ // maintained by the only two writers of these tables, both in this file and
347
+ // both transactional: this function writes the `files` row and the FTS rows
348
+ // inside one `BEGIN IMMEDIATE`, and `removeDeletedFiles` deletes both inside
349
+ // one transaction. That second one is load-bearing and easy to lose:
350
+ // `chunk_fts` / `identifier_fts` are FTS5 VIRTUAL tables, so they do NOT
351
+ // participate in the foreign-key cascade that removes `symbols` / `edges` /
352
+ // `imports` when a `files` row goes — the deletes there are explicit and must
353
+ // stay. `__tests__/fts-delete-guard.test.ts` pins the invariant directly, so
354
+ // a future change that drops a `files` row without its FTS rows fails there
355
+ // rather than silently making this guard wrong.
356
+ //
357
+ // The SELECT and these DELETEs share one transaction, so no concurrent writer
358
+ // can insert FTS rows between them — the same atomicity argument F12 already
359
+ // relies on for the monotonic guard.
360
+ //
361
+ // `skipFtsDeletes` is E1-FTS's arm G, retained because it is the instrument of
362
+ // a completed experiment. It is unconditional and unsafe outside a cold build.
363
+ // Tests `existing` directly rather than via a named boolean so that
364
+ // TypeScript narrows it — the recorded block is read from it below.
365
+ if (options.skipFtsDeletes !== true && existing !== undefined) {
366
+ await timed(spans, 'fts_del', async () => {
367
+ await deleteFtsRowidBlock(trx, 'chunk_fts', { lo: existing.chunk_fts_lo, hi: existing.chunk_fts_hi }, data.filePath);
368
+ await deleteFtsRowidBlock(trx, 'identifier_fts', { lo: existing.ident_fts_lo, hi: existing.ident_fts_hi }, data.filePath);
369
+ });
370
+ }
371
+ // Batch-insert all chunks in one statement instead of one INSERT per chunk
372
+ // — further batched under the parameter ceiling, same as above.
373
+ await timed(spans, 'fts_ins', async () => {
374
+ if (data.chunks.length > 0) {
375
+ // Explicit rowids, so the block recorded on `files` above is true by
376
+ // construction rather than inferred from SQLite's assignment order.
377
+ const chunkFtsRows = data.chunks.map((chunk, i) => ({
378
+ rowid: (chunkBlock.lo ?? 0) + i,
379
+ content: chunk.content,
380
+ symbol_name: chunk.symbol_name,
381
+ chunk_id: chunk.chunk_id,
382
+ file_path: data.filePath,
383
+ }));
384
+ for (const batch of chunkRowsForSqlite(chunkFtsRows)) {
385
+ await trx.insertInto('chunk_fts').values(batch).execute();
386
+ }
387
+ }
388
+ if (data.identifierRows.length > 0) {
389
+ const identifierFtsRows = data.identifierRows.map((row, i) => ({
390
+ rowid: (identBlock.lo ?? 0) + i,
391
+ identifiers: row.identifiers,
392
+ chunk_id: row.chunk_id,
393
+ file_path: data.filePath,
394
+ }));
395
+ for (const batch of chunkRowsForSqlite(identifierFtsRows)) {
396
+ await trx.insertInto('identifier_fts').values(batch).execute();
397
+ }
398
+ }
399
+ });
400
+ return { fileId, chunksRemoved, written: true };
401
+ }
402
+ /**
403
+ * Default (production) chunk write, inline in `trx` — delete-then-insert by
404
+ * `file_path`, same shape as the `chunk_fts` block above. Returns the count
405
+ * of rows removed (§ `IndexResult.chunksRemoved`).
406
+ */
407
+ async function replaceChunksInline(trx, filePath, chunks) {
408
+ const row = await trx
409
+ .selectFrom('chunks')
410
+ .select((eb) => eb.fn.count('chunk_id').as('count'))
411
+ .where('file_path', '=', filePath)
412
+ .executeTakeFirst();
413
+ const removed = row?.count ?? 0;
414
+ await trx.deleteFrom('chunks').where('file_path', '=', filePath).execute();
415
+ if (chunks.length > 0) {
416
+ // Batched under SQLite's 32,766 bound-parameter ceiling (Stage 4.5 S1,
417
+ // IMPLEMENTATION_PLAN.md "batch `replaceChunksForFile`'s insert", added
418
+ // 2026-08-07). This is the PRODUCTION per-file chunk write (`populateFile`'s
419
+ // default path, no `chunkWriter` override) — an 11-column row shape caps a
420
+ // single unbatched INSERT at ~2,978 rows; a whale file's chunks (e.g.
421
+ // vscode's 146,620-line fixtures) otherwise throw `SqliteError: too many
422
+ // SQL variables`, rolling back this WHOLE transaction (symbols/edges/
423
+ // imports/FTS along with it) and silently dropping the file from the
424
+ // index for orchestration that gates only on exit code. `chunkRowsForSqlite`
425
+ // (graph/sqliteBatch.ts) computes a batch size that stays under the
426
+ // ceiling for any row shape. Every batch below runs INSIDE the SAME `trx`
427
+ // this function was handed — batching the STATEMENT, not the transaction,
428
+ // so a whale file's chunks still land atomically (all rows or none) with
429
+ // its symbols/edges/imports/FTS rows, exactly as before. This same pattern
430
+ // (batch inside the existing transaction) is applied at every other
431
+ // multi-row insert in this file and in `store/sqliteChunkStore.ts`'s
432
+ // `replaceChunksForFile` — see IMPLEMENTATION_PLAN.md's Stage 4.5 S1
433
+ // result block for the full class survey.
434
+ // Same widening issue and fix as `populateFile`'s `symbolRows` — an
435
+ // explicit row type keeps `is_exported` narrowed to `0 | 1`.
436
+ const chunkRows = chunks.map((c) => ({
437
+ chunk_id: c.chunk_id,
438
+ file_path: c.file_path,
439
+ start_line: c.start_line,
440
+ end_line: c.end_line,
441
+ content: c.content,
442
+ chunk_type: c.chunk_type,
443
+ symbol_name: c.symbol_name,
444
+ parent_symbol: c.parent_symbol,
445
+ is_exported: c.is_exported ? 1 : 0,
446
+ language: c.language,
447
+ file_mtime: c.file_mtime,
448
+ }));
449
+ for (const batch of chunkRowsForSqlite(chunkRows)) {
450
+ await trx.insertInto('chunks').values(batch).execute();
451
+ }
452
+ }
453
+ return removed;
454
+ }
455
+ /**
456
+ * Second-pass edge insertion. Run after ALL files' symbols have been inserted
457
+ * so cross-file references resolve correctly.
458
+ *
459
+ * Each `EdgeRecord` uses symbol names, which are resolved to IDs here.
460
+ * Unresolved names are silently skipped (external or not-yet-indexed).
461
+ */
462
+ export async function insertEdges(db, filePath, edges) {
463
+ if (edges.length === 0)
464
+ return;
465
+ const fromNames = [...new Set(edges.map((e) => e.fromName))];
466
+ // Batch-resolve "from" IDs — must belong to filePath. `fromNames` is
467
+ // deduped via `Set` above, so splitting it into `IN`-list-sized chunks
468
+ // (`chunkValuesForSqlite`, graph/sqliteBatch.ts — 1 bound parameter per
469
+ // name) and merging the results cannot introduce duplicate-name collisions:
470
+ // each name appears in exactly one chunk, so `fromMap` ends up identical to
471
+ // what the single unbatched query would have produced. A whale file's
472
+ // unique symbol-name list can sit close to the 32,766 parameter ceiling
473
+ // (§ Stage 4.5 S1's class survey site 8), so this stays correct at any size.
474
+ const fromRows = [];
475
+ for (const nameBatch of chunkValuesForSqlite(fromNames)) {
476
+ const rows = await db
477
+ .selectFrom('symbols as s')
478
+ .innerJoin('files as f', 'f.id', 's.file_id')
479
+ .select(['s.id', 's.name'])
480
+ .where('s.name', 'in', nameBatch)
481
+ .where('f.path', '=', filePath)
482
+ .execute();
483
+ fromRows.push(...rows);
484
+ }
485
+ const fromMap = new Map(fromRows.map((r) => [r.name, r.id]));
486
+ // Structural edges (IMPLEMENTS/EXTENDS/PARENT_OF) carry no file evidence at
487
+ // all — batch-resolve them exactly as before. POTENTIAL_CALL edges are
488
+ // resolved separately below, file-scoped per §10.3.1's resolution rules.
489
+ // RE_EXPORTS edges DO carry file evidence (`toResolvedPath`, Task 0) and are
490
+ // also resolved separately below — they must NOT fall into this bare-name
491
+ // batch, which is exactly the sibling false-green this fix closes.
492
+ const structuralEdges = edges.filter((e) => e.edgeType !== 'POTENTIAL_CALL' && e.edgeType !== 'RE_EXPORTS');
493
+ const structuralToNames = [...new Set(structuralEdges.map((e) => e.toName))];
494
+ // Same `IN`-list batching as `fromNames` above. Unlike `fromMap`,
495
+ // `structuralToMap` dedups on a real ambiguity — the SAME name can be
496
+ // declared in multiple files, so more than one row can come back for one
497
+ // `toName` even within a single query, and "first row wins" picks among
498
+ // them. `structuralToNames` is deduped (`Set`), so each name lands in
499
+ // exactly ONE batch; the dedup loop below therefore sees each name's
500
+ // candidate rows in the same relative order a single unbatched query would
501
+ // have returned them, batch-by-batch, preserving `if (!has(name))`'s
502
+ // first-row-wins semantics exactly.
503
+ const structuralToMap = new Map();
504
+ if (structuralToNames.length > 0) {
505
+ for (const nameBatch of chunkValuesForSqlite(structuralToNames)) {
506
+ const rows = await db
507
+ .selectFrom('symbols')
508
+ .select(['id', 'name'])
509
+ .where('name', 'in', nameBatch)
510
+ .where('kind', '!=', 'export')
511
+ .execute();
512
+ for (const row of rows) {
513
+ if (!structuralToMap.has(row.name))
514
+ structuralToMap.set(row.name, row.id);
515
+ }
516
+ }
517
+ }
518
+ // POTENTIAL_CALL edges: resolve each unique (toName) once, file-scoped by
519
+ // the resolution rule's own evidence (§10.3.1). A bare name has a single
520
+ // deterministic resolution per file (LocalTypeEnvironment's "first
521
+ // recorded wins" seeding — import beats same-file, and receiver bindings
522
+ // are keyed by receiver, not by callee name), so it is safe to resolve
523
+ // once per toName rather than once per edge.
524
+ const callEdgesByToName = new Map();
525
+ for (const e of edges) {
526
+ if (e.edgeType === 'POTENTIAL_CALL' && !callEdgesByToName.has(e.toName)) {
527
+ callEdgesByToName.set(e.toName, e);
528
+ }
529
+ }
530
+ const callToMap = new Map();
531
+ if (callEdgesByToName.size > 0) {
532
+ const fromFile = await db.selectFrom('files').select('id').where('path', '=', filePath).executeTakeFirst();
533
+ // No `files` row for the calling file is an invariant violation (pass 1
534
+ // always inserts it before pass 2 runs edges) — fromMap would be empty
535
+ // too in that case, so every edge is dropped downstream regardless.
536
+ if (fromFile !== undefined) {
537
+ // One import index per file, built lazily. `fromFile.id` is invariant
538
+ // across this whole loop, so the old per-call query re-read and re-parsed
539
+ // identical rows once per unique `toName`. LAZY rather than eager because
540
+ // most resolution rules never consult imports at all (`same_file` and
541
+ // `this_method` are ~76% of resolved call edges on the T8 corpus), and an
542
+ // eager build would add a query to every file instead of removing them.
543
+ const imports = fileImportIndexLoader(db, fromFile.id);
544
+ for (const [toName, edge] of callEdgesByToName) {
545
+ const targetId = await resolveCallTarget(db, fromFile.id, imports, edge.resolution, toName);
546
+ if (targetId !== null)
547
+ callToMap.set(toName, targetId);
548
+ }
549
+ }
550
+ }
551
+ // RE_EXPORTS edges: resolve each unique (toName, toResolvedPath) pair once,
552
+ // file-scoped by the re-export's own module specifier (Task 0 fix — the
553
+ // named-re-export sibling of the POTENTIAL_CALL false-green above). Keyed by
554
+ // toResolvedPath as well as toName because one barrel file can re-export
555
+ // same-named symbols from two different modules
556
+ // (`export { x } from './a'; export { x as xB } from './b';`).
557
+ const reExportKey = (e) => `${e.toName}::${e.toResolvedPath ?? ''}`;
558
+ const reExportEdgesByKey = new Map();
559
+ for (const e of edges) {
560
+ if (e.edgeType === 'RE_EXPORTS' && !reExportEdgesByKey.has(reExportKey(e))) {
561
+ reExportEdgesByKey.set(reExportKey(e), e);
562
+ }
563
+ }
564
+ const reExportToMap = new Map();
565
+ for (const [key, edge] of reExportEdgesByKey) {
566
+ // No resolved path (external module, or a relative specifier that didn't
567
+ // probe to a real file) — the honest result is no edge, not a name-only
568
+ // guess across the whole graph.
569
+ if (edge.toResolvedPath == null)
570
+ continue;
571
+ const targetId = await resolveInFileOrReExportChain(db, edge.toResolvedPath, edge.toName);
572
+ if (targetId !== null)
573
+ reExportToMap.set(key, targetId);
574
+ }
575
+ const edgeValues = edges.flatMap((edge) => {
576
+ const from_id = fromMap.get(edge.fromName);
577
+ if (from_id === undefined)
578
+ return [];
579
+ const to_id = edge.edgeType === 'POTENTIAL_CALL'
580
+ ? callToMap.get(edge.toName)
581
+ : edge.edgeType === 'RE_EXPORTS'
582
+ ? reExportToMap.get(reExportKey(edge))
583
+ : structuralToMap.get(edge.toName);
584
+ if (to_id === undefined)
585
+ return [];
586
+ return [{
587
+ from_id,
588
+ to_id,
589
+ edge_type: edge.edgeType,
590
+ resolution: edge.resolution ?? null,
591
+ call_line: edge.callLine ?? null,
592
+ context: edge.context ?? null,
593
+ }];
594
+ });
595
+ if (edgeValues.length === 0)
596
+ return;
597
+ // Composite PK on (from_id, to_id, edge_type) — ignore duplicates. Batched
598
+ // under the parameter ceiling (Stage 4.5 S1 class survey site 7); a
599
+ // 6-column row shape caps a single unbatched INSERT at ~5,461 rows.
600
+ // `.onConflict(doNothing())` is re-applied per batch — each batch is its
601
+ // own statement, so the conflict clause must be present on every one, not
602
+ // just the first.
603
+ for (const batch of chunkRowsForSqlite(edgeValues)) {
604
+ await db
605
+ .insertInto('edges')
606
+ .values(batch)
607
+ .onConflict((oc) => oc.doNothing())
608
+ .execute();
609
+ }
610
+ }
611
+ // ---------------------------------------------------------------------------
612
+ // POTENTIAL_CALL target resolution — file-scoped by resolution-rule evidence
613
+ // ---------------------------------------------------------------------------
614
+ /**
615
+ * Resolve a POTENTIAL_CALL edge's target symbol id using the file evidence
616
+ * the resolution rule (§10.3.1) actually carries, instead of matching the
617
+ * bare/qualified name against the *entire* graph.
618
+ *
619
+ * Without this, two files exporting a same-named symbol race on insertion
620
+ * order: `WHERE name = ? LIMIT 1` with no file filter deterministically
621
+ * returns whichever row SQLite happens to have inserted first, regardless of
622
+ * which file the call site's own import (or same-file declaration) actually
623
+ * names. That produced a wrong "verified" edge — see
624
+ * IMPLEMENTATION_PLAN_VEXP.md §P "Shipped-resolver finding" (2026-07-15) and
625
+ * eval/spikes/checker-edges/REPORT.md Q4b. `verified_callers` is documented
626
+ * as "safe to act on" (MAST_SPEC §9) precisely because ambiguity like this is
627
+ * not supposed to reach it — better no edge than a wrong one.
628
+ */
629
+ async function resolveCallTarget(db, fromFileId, imports, resolution, toName) {
630
+ switch (resolution) {
631
+ case 'same_file':
632
+ // The call target must be declared in this exact file — the file
633
+ // itself is the evidence, no lookup needed to establish it.
634
+ return resolveSameFileScoped(db, fromFileId, toName);
635
+ // F4: `this.foo()` — the enclosing class is declared IN the calling
636
+ // file by construction (`emitClassEdges` seeds the `this` binding from
637
+ // the class node it is currently walking), so this is the identical
638
+ // file-scoped lookup `same_file` uses, keyed on the qualified
639
+ // `ClassName.methodName` toName instead of a bare name.
640
+ case 'this_method':
641
+ return resolveSameFileScoped(db, fromFileId, toName);
642
+ case 'import': {
643
+ const lookup = importResolvedPathFor(await imports(), toName);
644
+ // An `import`-resolution edge is only emitted for a name the extractor
645
+ // saw in this file's own import_clause (local-type-env.ts
646
+ // recordImport), so an import row always exists; `lookup === null`
647
+ // is defensive, not an expected path.
648
+ const resolvedPath = lookup?.resolvedPath ?? null;
649
+ // Unresolved (external, or a relative specifier that didn't probe to
650
+ // a real file) — the honest result is no edge, not a name-only guess.
651
+ if (resolvedPath === null)
652
+ return null;
653
+ return resolveInFileOrReExportChain(db, resolvedPath, toName);
654
+ }
655
+ case 'field_type':
656
+ case 'parameter_type':
657
+ case 'new_expression':
658
+ // toName is `TypeName.methodName` — the receiver's type must be
659
+ // file-scoped first, then the qualified method name resolved within
660
+ // that file (or its re-export chain). Falls back to a global
661
+ // bare-name match when `typeName` has no file evidence at all (a
662
+ // known, narrow coverage gap — MAST_SPEC §10.3.1).
663
+ return resolveQualifiedNameScoped(db, fromFileId, imports, toName, legacyGlobalFirstMatch);
664
+ // F4: `super.foo()` — toName is `ParentName.methodName`, traced exactly
665
+ // like a field_type receiver's type (import first, then same-file
666
+ // declaration). Unlike field_type/parameter_type/new_expression, an
667
+ // unresolvable parent name produces NO edge rather than a global
668
+ // bare-name guess: `emitClassEdges` only seeds this binding when a real
669
+ // `extends` clause named a parent, so "no file evidence for the parent"
670
+ // here means the parent is an ambient/global/unresolvable type, not a
671
+ // missing binding — and a wrong "verified" super-call edge would poison
672
+ // `verified_callers`' safe-to-act-on contract more than a missing one.
673
+ case 'super_method':
674
+ return resolveQualifiedNameScoped(db, fromFileId, imports, toName, async () => null);
675
+ default:
676
+ // A POTENTIAL_CALL edge always carries a resolution (`emitCallEdges`
677
+ // sets it from `LocalTypeEnvironment.resolveCall`'s result); this
678
+ // branch only guards an unexpected shape defensively.
679
+ return legacyGlobalFirstMatch(db, toName);
680
+ }
681
+ }
682
+ /**
683
+ * The call target must be declared in exactly `fromFileId` — the file
684
+ * itself is the evidence, no lookup needed to establish it. Shared by
685
+ * `same_file` (bare name) and F4's `this_method` (qualified
686
+ * `ClassName.methodName` name) — both resolve identically once the toName
687
+ * is fixed, since the enclosing class is always declared in the same file
688
+ * as the `this`-call site that names it.
689
+ */
690
+ async function resolveSameFileScoped(db, fromFileId, toName) {
691
+ const row = await db
692
+ .selectFrom('symbols')
693
+ .select('id')
694
+ .where('name', '=', toName)
695
+ .where('file_id', '=', fromFileId)
696
+ .where('kind', '!=', 'export')
697
+ .executeTakeFirst();
698
+ return row?.id ?? null;
699
+ }
700
+ /**
701
+ * Resolve a `TypeName.methodName` toName using the receiver type's own file
702
+ * evidence: `typeName` against this file's own imports first, then its
703
+ * same-file declarations, following the re-export chain into a barrel when
704
+ * needed (§10.3.1). `onUnresolved` is invoked only when NEITHER source names
705
+ * `typeName` at all — callers choose whether that falls back to a global
706
+ * bare-name match (the historical field_type/parameter_type/new_expression
707
+ * behaviour) or drops the edge (super_method, which has no legacy fallback
708
+ * to preserve).
709
+ */
710
+ async function resolveQualifiedNameScoped(db, fromFileId, imports, toName, onUnresolved) {
711
+ const dot = toName.indexOf('.');
712
+ const typeName = dot === -1 ? toName : toName.slice(0, dot);
713
+ const lookup = importResolvedPathFor(await imports(), typeName);
714
+ if (lookup !== null) {
715
+ if (lookup.resolvedPath === null)
716
+ return null; // imported but unresolved — no edge
717
+ return resolveInFileOrReExportChain(db, lookup.resolvedPath, toName);
718
+ }
719
+ const sameFileType = await db
720
+ .selectFrom('symbols')
721
+ .select('id')
722
+ .where('name', '=', typeName)
723
+ .where('file_id', '=', fromFileId)
724
+ .where('kind', '!=', 'export')
725
+ .executeTakeFirst();
726
+ if (sameFileType !== undefined) {
727
+ return resolveSameFileScoped(db, fromFileId, toName);
728
+ }
729
+ // Neither an import nor a same-file declaration names `typeName` — e.g. a
730
+ // default/namespace import (not tracked as a named import, see
731
+ // `extractEdges`' `importedNames` collection) or an ambient/global type.
732
+ // No file evidence exists to scope this edge.
733
+ return onUnresolved(db, toName);
734
+ }
735
+ /**
736
+ * Every symbol this file imports, mapped to the module's resolved path.
737
+ *
738
+ * `null` values are meaningful and distinct from absence: the name IS imported,
739
+ * from a module that did not resolve to a file we index. Absence means the file
740
+ * does not import the name at all. Callers must keep the two apart — see
741
+ * `importResolvedPathFor`.
742
+ *
743
+ * FIRST WRITE WINS, which preserves the row-scan order this replaced: the old
744
+ * code returned the first `imports` row naming the symbol, and both read rows in
745
+ * `idx_imports_file` order. A later duplicate import of the same name is
746
+ * therefore ignored exactly as before.
747
+ */
748
+ async function buildFileImportIndex(db, fromFileId) {
749
+ const rows = await db
750
+ .selectFrom('imports')
751
+ .select(['symbols', 'resolved_path'])
752
+ .where('file_id', '=', fromFileId)
753
+ .execute();
754
+ const index = new Map();
755
+ for (const row of rows) {
756
+ let importedSymbols;
757
+ try {
758
+ importedSymbols = JSON.parse(row.symbols);
759
+ }
760
+ catch {
761
+ continue; // malformed row — treat as naming nothing
762
+ }
763
+ for (const symbol of importedSymbols) {
764
+ if (!index.has(symbol))
765
+ index.set(symbol, row.resolved_path);
766
+ }
767
+ }
768
+ return index;
769
+ }
770
+ function fileImportIndexLoader(db, fromFileId) {
771
+ let pending = null;
772
+ return () => (pending ??= buildFileImportIndex(db, fromFileId));
773
+ }
774
+ function importResolvedPathFor(index, name) {
775
+ const resolvedPath = index.get(name);
776
+ // `undefined` can only mean absent — the map never stores it, only `null`.
777
+ if (resolvedPath === undefined)
778
+ return null;
779
+ return { resolvedPath };
780
+ }
781
+ /**
782
+ * Resolve `toName` within `resolvedPath`, following the barrel re-export
783
+ * machinery (§6.3) when the resolved file doesn't declare it directly:
784
+ * a named re-export leaves an `export`-kind marker symbol with a RE_EXPORTS
785
+ * edge to the real declaration; a star re-export (`export * from`) leaves a
786
+ * `re_export_files` row. Both are walked before giving up.
787
+ */
788
+ async function resolveInFileOrReExportChain(db, resolvedPath, toName) {
789
+ // The import resolver (`src/indexer/import-resolver.ts`) always returns an
790
+ // extension-inclusive path, but prefix matching mirrors the existing
791
+ // precedent (`resolveTypeContext`, `insertReExportFiles`) defensively.
792
+ const targetFile = await db
793
+ .selectFrom('files')
794
+ .select('id')
795
+ .where('path', '>=', resolvedPath)
796
+ .where('path', '<', pathPrefixUpperBound(resolvedPath))
797
+ .orderBy('path', 'asc')
798
+ .executeTakeFirst();
799
+ if (targetFile === undefined)
800
+ return null;
801
+ const direct = await db
802
+ .selectFrom('symbols')
803
+ .select('id')
804
+ .where('name', '=', toName)
805
+ .where('file_id', '=', targetFile.id)
806
+ .where('kind', '!=', 'export')
807
+ .executeTakeFirst();
808
+ if (direct !== undefined)
809
+ return direct.id;
810
+ // Named re-export: a marker symbol (kind 'export') anchors a RE_EXPORTS
811
+ // edge to the real declaration (§10.1).
812
+ const marker = await db
813
+ .selectFrom('symbols')
814
+ .select('id')
815
+ .where('name', '=', toName)
816
+ .where('file_id', '=', targetFile.id)
817
+ .where('kind', '=', 'export')
818
+ .executeTakeFirst();
819
+ if (marker !== undefined) {
820
+ const declared = await followReExportEdgeChain(db, marker.id);
821
+ if (declared !== null)
822
+ return declared;
823
+ }
824
+ // Star re-export: no per-symbol marker exists, only a file-level
825
+ // `re_export_files` row (§10.3). Walk the chain forward to the file that
826
+ // actually declares `toName` — the recursive CTE from MAST_SPEC §6.3.
827
+ return resolveThroughStarChain(db, targetFile.id, toName);
828
+ }
829
+ /** Bounded hop count for chained named re-exports (barrel re-exporting a barrel). */
830
+ const MAX_RE_EXPORT_HOPS = 5;
831
+ /** Follow RE_EXPORTS edges from a marker symbol to the real (non-marker) declaration. */
832
+ async function followReExportEdgeChain(db, markerId) {
833
+ let currentId = markerId;
834
+ for (let hop = 0; hop < MAX_RE_EXPORT_HOPS; hop++) {
835
+ const edge = await db
836
+ .selectFrom('edges')
837
+ .select('to_id')
838
+ .where('from_id', '=', currentId)
839
+ .where('edge_type', '=', 'RE_EXPORTS')
840
+ .executeTakeFirst();
841
+ if (edge === undefined)
842
+ return null;
843
+ const target = await db
844
+ .selectFrom('symbols')
845
+ .select(['id', 'kind'])
846
+ .where('id', '=', edge.to_id)
847
+ .executeTakeFirst();
848
+ if (target === undefined)
849
+ return null;
850
+ if (target.kind !== 'export')
851
+ return target.id;
852
+ currentId = target.id;
853
+ }
854
+ return null;
855
+ }
856
+ /**
857
+ * Walk `re_export_files` forward from `startFileId` (a barrel doing
858
+ * `export * from '...'`) to find the file that actually declares `toName`.
859
+ * Mirrors the `re_export_chain` recursive CTE documented in MAST_SPEC §6.3.
860
+ */
861
+ async function resolveThroughStarChain(db, startFileId, toName) {
862
+ const row = await db
863
+ .withRecursive('re_export_chain', (qb) => qb
864
+ .selectFrom('re_export_files')
865
+ .select('to_file_id as file_id')
866
+ .where('from_file_id', '=', startFileId)
867
+ .union(qb
868
+ .selectFrom('re_export_files as rf')
869
+ .innerJoin('re_export_chain', 're_export_chain.file_id', 'rf.from_file_id')
870
+ .select('rf.to_file_id as file_id')))
871
+ .selectFrom('symbols as s')
872
+ .innerJoin('re_export_chain as rec', 'rec.file_id', 's.file_id')
873
+ .select('s.id')
874
+ .where('s.name', '=', toName)
875
+ .where('s.kind', '!=', 'export')
876
+ .orderBy('s.file_id', 'asc')
877
+ .executeTakeFirst();
878
+ return row?.id ?? null;
879
+ }
880
+ /**
881
+ * Pre-fix behaviour: match `toName` against any indexed symbol, first match
882
+ * wins (excluding re-export markers). Only reached when a resolution rule
883
+ * has no file evidence available at all (see `resolveCallTarget`'s
884
+ * `field_type`/`parameter_type`/`new_expression` default-import/ambient-type
885
+ * fallback) — a known, documented coverage gap, not a silent regression.
886
+ */
887
+ async function legacyGlobalFirstMatch(db, toName) {
888
+ const row = await db
889
+ .selectFrom('symbols')
890
+ .select('id')
891
+ .where('name', '=', toName)
892
+ .where('kind', '!=', 'export')
893
+ .executeTakeFirst();
894
+ return row?.id ?? null;
895
+ }
896
+ /**
897
+ * Second-pass star re-export insertion (`export * from './x'` → one
898
+ * `re_export_files` row per resolved target). Runs after all files' rows
899
+ * exist, like `insertEdges`, because the target file may be indexed later in
900
+ * the same run. Unresolved or unindexed targets are silently skipped.
901
+ */
902
+ export async function insertReExportFiles(db, filePath, stars) {
903
+ if (stars.length === 0)
904
+ return;
905
+ const fromFile = await db
906
+ .selectFrom('files')
907
+ .select('id')
908
+ .where('path', '=', filePath)
909
+ .executeTakeFirst();
910
+ if (fromFile === undefined)
911
+ return;
912
+ for (const star of stars) {
913
+ if (star.resolvedPath === null)
914
+ continue;
915
+ // resolved_path may lack an extension — the prefix range matches `x.ts`,
916
+ // `x/index.ts`, etc. (same convention as resolveTypeContext, §13.7).
917
+ const target = await db
918
+ .selectFrom('files')
919
+ .select('id')
920
+ .where('path', '>=', star.resolvedPath)
921
+ .where('path', '<', pathPrefixUpperBound(star.resolvedPath))
922
+ .orderBy('path', 'asc')
923
+ .executeTakeFirst();
924
+ if (target === undefined || target.id === fromFile.id)
925
+ continue;
926
+ await db
927
+ .insertInto('re_export_files')
928
+ .values({ from_file_id: fromFile.id, to_file_id: target.id })
929
+ .onConflict((oc) => oc.doNothing())
930
+ .execute();
931
+ }
932
+ }
933
+ /**
934
+ * Remove all data for files that were present in the previous manifest but
935
+ * are absent from the current filesystem scan (deleted files). Returns the
936
+ * number of `chunks` rows removed (§ `IndexResult.chunksRemoved`).
937
+ *
938
+ * `chunks` (M1, §15.1) and the FTS5 virtual tables (chunk_fts, identifier_fts)
939
+ * do not participate in SQLite FK cascades, so all three must be cleaned up
940
+ * explicitly before the files row is deleted. Wrapped in one transaction so a
941
+ * deleted-file cleanup is atomic the same way `populateFile` is — a failure
942
+ * partway through cannot leave chunks/FTS rows orphaned from a `files` row
943
+ * that was (or wasn't) removed.
944
+ */
945
+ export async function removeDeletedFiles(db, deletedPaths) {
946
+ if (deletedPaths.length === 0)
947
+ return 0;
948
+ return db.transaction().execute(async (trx) => {
949
+ let chunksRemoved = 0;
950
+ for (const filePath of deletedPaths) {
951
+ const row = await trx
952
+ .selectFrom('chunks')
953
+ .select((eb) => eb.fn.count('chunk_id').as('count'))
954
+ .where('file_path', '=', filePath)
955
+ .executeTakeFirst();
956
+ chunksRemoved += row?.count ?? 0;
957
+ // Read before `files` is deleted below — the row is the only record of
958
+ // which rowids this file owns. A path with no `files` row yields no
959
+ // block, and `deleteFtsRowidBlock` then falls back to the scan, so a
960
+ // caller passing an unknown path still gets correct (if slow) cleanup.
961
+ const block = await trx
962
+ .selectFrom('files')
963
+ .select(['chunk_fts_lo', 'chunk_fts_hi', 'ident_fts_lo', 'ident_fts_hi'])
964
+ .where('path', '=', filePath)
965
+ .executeTakeFirst();
966
+ await trx.deleteFrom('chunks').where('file_path', '=', filePath).execute();
967
+ await deleteFtsRowidBlock(trx, 'chunk_fts', { lo: block?.chunk_fts_lo ?? null, hi: block?.chunk_fts_hi ?? null }, filePath);
968
+ await deleteFtsRowidBlock(trx, 'identifier_fts', { lo: block?.ident_fts_lo ?? null, hi: block?.ident_fts_hi ?? null }, filePath);
969
+ }
970
+ // Batched for the same reason every other IN list in this file is: the
971
+ // caller supplies `deletedPaths` and nothing bounds it — deleting a
972
+ // vendored directory, or re-indexing after an `exclude_patterns` change,
973
+ // hands this whatever the manifest diff produced. Over the ceiling the
974
+ // statement throws `too many SQL variables`, and because this runs inside
975
+ // the transaction above, the throw rolls back every chunk and FTS row the
976
+ // loop already deleted. D001, this ledger's founding S0, is that same
977
+ // ceiling breached from the insert side.
978
+ for (const batch of chunkValuesForSqlite(deletedPaths)) {
979
+ await trx.deleteFrom('files').where('path', 'in', [...batch]).execute();
980
+ }
981
+ return chunksRemoved;
982
+ });
983
+ }
984
+ //# sourceMappingURL=populate.js.map