@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.
- package/LICENSE +21 -0
- package/MAST_SPEC.md +2926 -0
- package/README.md +842 -0
- package/assets/skill.md +45 -0
- package/dist/ast/extract.d.ts +65 -0
- package/dist/ast/extract.d.ts.map +1 -0
- package/dist/ast/extract.js +147 -0
- package/dist/ast/extract.js.map +1 -0
- package/dist/ast/extractor.d.ts +85 -0
- package/dist/ast/extractor.d.ts.map +1 -0
- package/dist/ast/extractor.js +2 -0
- package/dist/ast/extractor.js.map +1 -0
- package/dist/ast/extractors/markdown.d.ts +40 -0
- package/dist/ast/extractors/markdown.d.ts.map +1 -0
- package/dist/ast/extractors/markdown.js +175 -0
- package/dist/ast/extractors/markdown.js.map +1 -0
- package/dist/ast/extractors/typescript.d.ts +179 -0
- package/dist/ast/extractors/typescript.d.ts.map +1 -0
- package/dist/ast/extractors/typescript.js +1439 -0
- package/dist/ast/extractors/typescript.js.map +1 -0
- package/dist/ast/parser.d.ts +5 -0
- package/dist/ast/parser.d.ts.map +1 -0
- package/dist/ast/parser.js +21 -0
- package/dist/ast/parser.js.map +1 -0
- package/dist/ast/types.d.ts +586 -0
- package/dist/ast/types.d.ts.map +1 -0
- package/dist/ast/types.js +3 -0
- package/dist/ast/types.js.map +1 -0
- package/dist/cli/docs-cmd.d.ts +22 -0
- package/dist/cli/docs-cmd.d.ts.map +1 -0
- package/dist/cli/docs-cmd.js +100 -0
- package/dist/cli/docs-cmd.js.map +1 -0
- package/dist/cli/index-cmd.d.ts +72 -0
- package/dist/cli/index-cmd.d.ts.map +1 -0
- package/dist/cli/index-cmd.js +263 -0
- package/dist/cli/index-cmd.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init.d.ts +14 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +64 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/install-hooks.d.ts +3 -0
- package/dist/cli/install-hooks.d.ts.map +1 -0
- package/dist/cli/install-hooks.js +34 -0
- package/dist/cli/install-hooks.js.map +1 -0
- package/dist/cli/metrics-cmd.d.ts +3 -0
- package/dist/cli/metrics-cmd.d.ts.map +1 -0
- package/dist/cli/metrics-cmd.js +164 -0
- package/dist/cli/metrics-cmd.js.map +1 -0
- package/dist/cli/program.d.ts +14 -0
- package/dist/cli/program.d.ts.map +1 -0
- package/dist/cli/program.js +44 -0
- package/dist/cli/program.js.map +1 -0
- package/dist/cli/query.d.ts +46 -0
- package/dist/cli/query.d.ts.map +1 -0
- package/dist/cli/query.js +153 -0
- package/dist/cli/query.js.map +1 -0
- package/dist/cli/search-cmd.d.ts +33 -0
- package/dist/cli/search-cmd.d.ts.map +1 -0
- package/dist/cli/search-cmd.js +114 -0
- package/dist/cli/search-cmd.js.map +1 -0
- package/dist/cli/serve.d.ts +3 -0
- package/dist/cli/serve.d.ts.map +1 -0
- package/dist/cli/serve.js +19 -0
- package/dist/cli/serve.js.map +1 -0
- package/dist/cli/skill-install.d.ts +39 -0
- package/dist/cli/skill-install.d.ts.map +1 -0
- package/dist/cli/skill-install.js +57 -0
- package/dist/cli/skill-install.js.map +1 -0
- package/dist/cli/status.d.ts +35 -0
- package/dist/cli/status.d.ts.map +1 -0
- package/dist/cli/status.js +117 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/upgrade-cmd.d.ts +48 -0
- package/dist/cli/upgrade-cmd.d.ts.map +1 -0
- package/dist/cli/upgrade-cmd.js +129 -0
- package/dist/cli/upgrade-cmd.js.map +1 -0
- package/dist/cli/version.d.ts +3 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/cli/version.js +19 -0
- package/dist/cli/version.js.map +1 -0
- package/dist/env.d.ts +17 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +17 -0
- package/dist/env.js.map +1 -0
- package/dist/graph/checker-resolver.d.ts +156 -0
- package/dist/graph/checker-resolver.d.ts.map +1 -0
- package/dist/graph/checker-resolver.js +458 -0
- package/dist/graph/checker-resolver.js.map +1 -0
- package/dist/graph/db.d.ts +318 -0
- package/dist/graph/db.d.ts.map +1 -0
- package/dist/graph/db.js +266 -0
- package/dist/graph/db.js.map +1 -0
- package/dist/graph/index.d.ts +4 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +5 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/local-type-env.d.ts +45 -0
- package/dist/graph/local-type-env.d.ts.map +1 -0
- package/dist/graph/local-type-env.js +59 -0
- package/dist/graph/local-type-env.js.map +1 -0
- package/dist/graph/path-range.d.ts +40 -0
- package/dist/graph/path-range.d.ts.map +1 -0
- package/dist/graph/path-range.js +42 -0
- package/dist/graph/path-range.js.map +1 -0
- package/dist/graph/populate.d.ts +263 -0
- package/dist/graph/populate.d.ts.map +1 -0
- package/dist/graph/populate.js +984 -0
- package/dist/graph/populate.js.map +1 -0
- package/dist/graph/queries.d.ts +114 -0
- package/dist/graph/queries.d.ts.map +1 -0
- package/dist/graph/queries.js +415 -0
- package/dist/graph/queries.js.map +1 -0
- package/dist/graph/sqliteBatch.d.ts +48 -0
- package/dist/graph/sqliteBatch.d.ts.map +1 -0
- package/dist/graph/sqliteBatch.js +93 -0
- package/dist/graph/sqliteBatch.js.map +1 -0
- package/dist/indexer/freshness.d.ts +48 -0
- package/dist/indexer/freshness.d.ts.map +1 -0
- package/dist/indexer/freshness.js +72 -0
- package/dist/indexer/freshness.js.map +1 -0
- package/dist/indexer/import-resolver.d.ts +50 -0
- package/dist/indexer/import-resolver.d.ts.map +1 -0
- package/dist/indexer/import-resolver.js +318 -0
- package/dist/indexer/import-resolver.js.map +1 -0
- package/dist/indexer/index.d.ts +249 -0
- package/dist/indexer/index.d.ts.map +1 -0
- package/dist/indexer/index.js +560 -0
- package/dist/indexer/index.js.map +1 -0
- package/dist/indexer/walker.d.ts +60 -0
- package/dist/indexer/walker.d.ts.map +1 -0
- package/dist/indexer/walker.js +128 -0
- package/dist/indexer/walker.js.map +1 -0
- package/dist/indexer/watcher.d.ts +80 -0
- package/dist/indexer/watcher.d.ts.map +1 -0
- package/dist/indexer/watcher.js +151 -0
- package/dist/indexer/watcher.js.map +1 -0
- package/dist/mcp/context.d.ts +23 -0
- package/dist/mcp/context.d.ts.map +1 -0
- package/dist/mcp/context.js +2 -0
- package/dist/mcp/context.js.map +1 -0
- package/dist/mcp/register-tools.d.ts +21 -0
- package/dist/mcp/register-tools.d.ts.map +1 -0
- package/dist/mcp/register-tools.js +42 -0
- package/dist/mcp/register-tools.js.map +1 -0
- package/dist/mcp/server.d.ts +58 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +177 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/staleness.d.ts +72 -0
- package/dist/mcp/staleness.d.ts.map +1 -0
- package/dist/mcp/staleness.js +213 -0
- package/dist/mcp/staleness.js.map +1 -0
- package/dist/mcp/startup.d.ts +40 -0
- package/dist/mcp/startup.d.ts.map +1 -0
- package/dist/mcp/startup.js +114 -0
- package/dist/mcp/startup.js.map +1 -0
- package/dist/mcp/tools/_helpers.d.ts +26 -0
- package/dist/mcp/tools/_helpers.d.ts.map +1 -0
- package/dist/mcp/tools/_helpers.js +54 -0
- package/dist/mcp/tools/_helpers.js.map +1 -0
- package/dist/mcp/tools/callers.d.ts +4 -0
- package/dist/mcp/tools/callers.d.ts.map +1 -0
- package/dist/mcp/tools/callers.js +112 -0
- package/dist/mcp/tools/callers.js.map +1 -0
- package/dist/mcp/tools/dependencies.d.ts +4 -0
- package/dist/mcp/tools/dependencies.d.ts.map +1 -0
- package/dist/mcp/tools/dependencies.js +36 -0
- package/dist/mcp/tools/dependencies.js.map +1 -0
- package/dist/mcp/tools/efficiency.d.ts +4 -0
- package/dist/mcp/tools/efficiency.d.ts.map +1 -0
- package/dist/mcp/tools/efficiency.js +39 -0
- package/dist/mcp/tools/efficiency.js.map +1 -0
- package/dist/mcp/tools/exports.d.ts +4 -0
- package/dist/mcp/tools/exports.d.ts.map +1 -0
- package/dist/mcp/tools/exports.js +56 -0
- package/dist/mcp/tools/exports.js.map +1 -0
- package/dist/mcp/tools/implementors.d.ts +4 -0
- package/dist/mcp/tools/implementors.d.ts.map +1 -0
- package/dist/mcp/tools/implementors.js +47 -0
- package/dist/mcp/tools/implementors.js.map +1 -0
- package/dist/mcp/tools/project-skeleton.d.ts +4 -0
- package/dist/mcp/tools/project-skeleton.d.ts.map +1 -0
- package/dist/mcp/tools/project-skeleton.js +61 -0
- package/dist/mcp/tools/project-skeleton.js.map +1 -0
- package/dist/mcp/tools/reindex.d.ts +14 -0
- package/dist/mcp/tools/reindex.d.ts.map +1 -0
- package/dist/mcp/tools/reindex.js +32 -0
- package/dist/mcp/tools/reindex.js.map +1 -0
- package/dist/mcp/tools/rename-impact.d.ts +4 -0
- package/dist/mcp/tools/rename-impact.d.ts.map +1 -0
- package/dist/mcp/tools/rename-impact.js +124 -0
- package/dist/mcp/tools/rename-impact.js.map +1 -0
- package/dist/mcp/tools/search.d.ts +4 -0
- package/dist/mcp/tools/search.d.ts.map +1 -0
- package/dist/mcp/tools/search.js +69 -0
- package/dist/mcp/tools/search.js.map +1 -0
- package/dist/mcp/tools/signature.d.ts +4 -0
- package/dist/mcp/tools/signature.d.ts.map +1 -0
- package/dist/mcp/tools/signature.js +127 -0
- package/dist/mcp/tools/signature.js.map +1 -0
- package/dist/mcp/tools/status.d.ts +4 -0
- package/dist/mcp/tools/status.d.ts.map +1 -0
- package/dist/mcp/tools/status.js +30 -0
- package/dist/mcp/tools/status.js.map +1 -0
- package/dist/search/declex.d.ts +109 -0
- package/dist/search/declex.d.ts.map +1 -0
- package/dist/search/declex.js +242 -0
- package/dist/search/declex.js.map +1 -0
- package/dist/search/fts.d.ts +77 -0
- package/dist/search/fts.d.ts.map +1 -0
- package/dist/search/fts.js +223 -0
- package/dist/search/fts.js.map +1 -0
- package/dist/search/fused.d.ts +110 -0
- package/dist/search/fused.d.ts.map +1 -0
- package/dist/search/fused.js +323 -0
- package/dist/search/fused.js.map +1 -0
- package/dist/search/potential-matches.d.ts +88 -0
- package/dist/search/potential-matches.d.ts.map +1 -0
- package/dist/search/potential-matches.js +83 -0
- package/dist/search/potential-matches.js.map +1 -0
- package/dist/search/scope.d.ts +66 -0
- package/dist/search/scope.d.ts.map +1 -0
- package/dist/search/scope.js +71 -0
- package/dist/search/scope.js.map +1 -0
- package/dist/store/config.d.ts +57 -0
- package/dist/store/config.d.ts.map +1 -0
- package/dist/store/config.js +208 -0
- package/dist/store/config.js.map +1 -0
- package/dist/store/lock.d.ts +101 -0
- package/dist/store/lock.d.ts.map +1 -0
- package/dist/store/lock.js +182 -0
- package/dist/store/lock.js.map +1 -0
- package/dist/store/lockMetrics.d.ts +52 -0
- package/dist/store/lockMetrics.d.ts.map +1 -0
- package/dist/store/lockMetrics.js +29 -0
- package/dist/store/lockMetrics.js.map +1 -0
- package/dist/store/sqliteChunkStore.d.ts +44 -0
- package/dist/store/sqliteChunkStore.d.ts.map +1 -0
- package/dist/store/sqliteChunkStore.js +135 -0
- package/dist/store/sqliteChunkStore.js.map +1 -0
- package/dist/telemetry/lockMetricsSummary.d.ts +42 -0
- package/dist/telemetry/lockMetricsSummary.d.ts.map +1 -0
- package/dist/telemetry/lockMetricsSummary.js +139 -0
- package/dist/telemetry/lockMetricsSummary.js.map +1 -0
- package/dist/telemetry/metrics.d.ts +140 -0
- package/dist/telemetry/metrics.d.ts.map +1 -0
- package/dist/telemetry/metrics.js +312 -0
- package/dist/telemetry/metrics.js.map +1 -0
- package/dist/telemetry/tokenizer.d.ts +125 -0
- package/dist/telemetry/tokenizer.d.ts.map +1 -0
- package/dist/telemetry/tokenizer.js +174 -0
- package/dist/telemetry/tokenizer.js.map +1 -0
- 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
|