simt-dev 0.7.1 → 0.8.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 (186) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +169 -80
  3. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  4. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  5. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  7. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  8. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  9. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  10. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  12. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  13. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  14. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  15. package/ecosystems/bun.json +6 -0
  16. package/ecosystems/cargo.json +25 -0
  17. package/ecosystems/composer.json +6 -0
  18. package/ecosystems/dart.json +24 -0
  19. package/ecosystems/deno.json +21 -0
  20. package/ecosystems/devcontainers.json +6 -0
  21. package/ecosystems/docker-compose.json +6 -0
  22. package/ecosystems/docker.json +6 -0
  23. package/ecosystems/dotnet.json +25 -0
  24. package/ecosystems/elixir.json +23 -0
  25. package/ecosystems/gitsubmodule.json +6 -0
  26. package/ecosystems/go.json +25 -0
  27. package/ecosystems/gradle.json +13 -0
  28. package/ecosystems/maven.json +23 -0
  29. package/ecosystems/node.json +25 -0
  30. package/ecosystems/python.json +20 -0
  31. package/ecosystems/ruby.json +23 -0
  32. package/ecosystems/swift.json +25 -0
  33. package/ecosystems/terraform.json +6 -0
  34. package/ecosystems/uv.json +6 -0
  35. package/harnesses/antigravity.json +162 -0
  36. package/harnesses/claude.json +206 -0
  37. package/harnesses/codex.json +136 -0
  38. package/harnesses/copilot.json +88 -0
  39. package/harnesses/cursor.json +140 -0
  40. package/harnesses/deepseek.json +99 -0
  41. package/harnesses/gemini.json +122 -0
  42. package/harnesses/opencode.json +173 -0
  43. package/package.json +3 -1
  44. package/rules/mdc/intake-questions.mdc +8 -0
  45. package/src/advice/advice.mjs +3 -2
  46. package/src/agenthooks/render.mjs +86 -180
  47. package/src/agents/render.mjs +15 -18
  48. package/src/api.mjs +7 -5
  49. package/src/argv.mjs +19 -0
  50. package/src/bench/ablate.mjs +3 -4
  51. package/src/bench/adapters.json +155 -113
  52. package/src/bench/adapters.mjs +466 -0
  53. package/src/bench/arms.mjs +333 -0
  54. package/src/bench/grade.mjs +12 -26
  55. package/src/bench/graders.mjs +42 -0
  56. package/src/bench/mine.mjs +17 -535
  57. package/src/bench/probe-ledger.mjs +149 -0
  58. package/src/bench/probes.catalog.mjs +918 -0
  59. package/src/bench/probes.mjs +15 -1037
  60. package/src/bench/render.mjs +111 -0
  61. package/src/bench/report.mjs +116 -971
  62. package/src/bench/run.mjs +164 -881
  63. package/src/bench/variants.mjs +4 -5
  64. package/src/bench/workspace.mjs +160 -0
  65. package/src/brief/assemble.mjs +11 -13
  66. package/src/ci/dependabot.mjs +25 -55
  67. package/src/commands/agent.mjs +1 -9
  68. package/src/commands/agenthook.mjs +66 -35
  69. package/src/commands/bench.mjs +2 -2
  70. package/src/commands/ci.mjs +11 -13
  71. package/src/commands/docs.mjs +88 -0
  72. package/src/commands/gate.mjs +10 -13
  73. package/src/commands/harness.mjs +3 -3
  74. package/src/commands/help.mjs +44 -3
  75. package/src/commands/hooks.mjs +20 -20
  76. package/src/commands/ignore.mjs +11 -7
  77. package/src/commands/impact.mjs +4 -8
  78. package/src/commands/index.mjs +49 -9
  79. package/src/commands/init-scan.mjs +90 -0
  80. package/src/commands/init.mjs +122 -104
  81. package/src/commands/lease.mjs +67 -18
  82. package/src/commands/map.mjs +10 -5
  83. package/src/commands/mcp.mjs +2 -17
  84. package/src/commands/repoindex.mjs +8 -8
  85. package/src/commands/rules.mjs +8 -4
  86. package/src/commands/shared.mjs +12 -16
  87. package/src/commands/skills.mjs +5 -6
  88. package/src/commands/skillscompile.mjs +3 -3
  89. package/src/commands/skillsinstall.mjs +5 -4
  90. package/src/commands/slice.mjs +199 -64
  91. package/src/commands/spec.mjs +78 -62
  92. package/src/commands/status.mjs +24 -12
  93. package/src/commands/sync.mjs +14 -14
  94. package/src/commands/tests.mjs +12 -12
  95. package/src/commands/tokens.mjs +19 -24
  96. package/src/commands/upgrade.mjs +8 -4
  97. package/src/commands/why.mjs +6 -13
  98. package/src/compare/hooksstate.mjs +2 -6
  99. package/src/compare/learn.mjs +3 -1
  100. package/src/decisions/index.mjs +3 -2
  101. package/src/docs/commands.mjs +65 -0
  102. package/src/ecosystem/records.mjs +219 -0
  103. package/src/explain.mjs +31 -0
  104. package/src/gates/index.mjs +385 -0
  105. package/src/graph/compile.mjs +8 -4
  106. package/src/graph/load.mjs +2 -1
  107. package/src/graph/spec.mjs +10 -8
  108. package/src/harness/records.mjs +143 -0
  109. package/src/hooks/capability.mjs +5 -3
  110. package/src/hooks/dispatcher.mjs +26 -32
  111. package/src/hooks/gates.mjs +12 -2
  112. package/src/hooks/resolve.mjs +2 -7
  113. package/src/hooks/where.mjs +2 -7
  114. package/src/identity.mjs +2 -11
  115. package/src/index/coverage.mjs +58 -0
  116. package/src/index/extract.mjs +6 -16
  117. package/src/index/gate.mjs +1 -1
  118. package/src/index/govern.mjs +26 -9
  119. package/src/index/impact.mjs +4 -8
  120. package/src/index/map.mjs +14 -15
  121. package/src/index/open.mjs +173 -0
  122. package/src/index/persist.mjs +224 -57
  123. package/src/index/store.mjs +82 -39
  124. package/src/index/tests.mjs +2 -6
  125. package/src/journal.mjs +16 -11
  126. package/src/kernel/card.mjs +37 -63
  127. package/src/kernel/checks.mjs +4 -10
  128. package/src/lease.mjs +102 -7
  129. package/src/ledger/index.mjs +124 -17
  130. package/src/mcp/paginate.mjs +33 -0
  131. package/src/mcp/schemas.mjs +512 -0
  132. package/src/mcp/server.mjs +49 -9
  133. package/src/mcp/tools-facts.mjs +326 -0
  134. package/src/mcp/tools-read.mjs +221 -0
  135. package/src/mcp/tools.mjs +4 -1005
  136. package/src/{bench → measure}/corpus.mjs +1 -1
  137. package/src/measure/metrics.mjs +236 -0
  138. package/src/measure/records.mjs +211 -0
  139. package/src/measure/stats.mjs +538 -0
  140. package/src/measure/task-schema.mjs +125 -0
  141. package/src/measure/tasks.mjs +69 -0
  142. package/src/memory/decide.mjs +6 -15
  143. package/src/memory/lesson-text.mjs +61 -0
  144. package/src/memory/lessons.mjs +29 -82
  145. package/src/name.mjs +7 -0
  146. package/src/output/index.mjs +122 -31
  147. package/src/packs/manage.mjs +15 -9
  148. package/src/plan/index.mjs +1 -1
  149. package/src/plugins.mjs +22 -16
  150. package/src/prbody.mjs +7 -11
  151. package/src/projection/loaders.mjs +7 -692
  152. package/src/projection/manifest.mjs +10 -435
  153. package/src/projection/mdc.mjs +7 -8
  154. package/src/projection/project.mjs +126 -221
  155. package/src/projection/region.mjs +561 -0
  156. package/src/projection/rules.mjs +16 -547
  157. package/src/projection/surfaces.mjs +43 -210
  158. package/src/projection/targets.mjs +5 -5
  159. package/src/repo-root.mjs +13 -1
  160. package/src/resolve/eval.mjs +2 -1
  161. package/src/resolve/fingerprint.mjs +0 -7
  162. package/src/resolve/resolve.mjs +1 -41
  163. package/src/review/critic.mjs +8 -12
  164. package/src/run.mjs +98 -0
  165. package/src/secrets.mjs +14 -22
  166. package/src/state/events.mjs +129 -0
  167. package/src/status-snapshot.mjs +233 -0
  168. package/src/status.mjs +2 -6
  169. package/src/sys/git.mjs +82 -0
  170. package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
  171. package/src/testing/parsers.mjs +265 -0
  172. package/src/testing/patches.mjs +23 -0
  173. package/src/testing/paths.mjs +49 -0
  174. package/src/testing/runners.mjs +186 -0
  175. package/src/testing/vault.mjs +68 -0
  176. package/src/text/frontmatter.mjs +49 -0
  177. package/src/text/tokens.mjs +52 -0
  178. package/src/toolwords.mjs +1 -1
  179. package/src/util/fs.mjs +20 -0
  180. package/src/util/sort.mjs +12 -0
  181. package/src/workspace.mjs +3 -1
  182. package/src/write/plan.mjs +82 -0
  183. package/templates/plan/tracker.json +1 -0
  184. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  185. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  186. package/src/bench/tasks.mjs +0 -36
@@ -0,0 +1,173 @@
1
+ // The index as a handle (review 0003 Q76).
2
+ //
3
+ // `openIndex` returns a handle whose methods answer with pure data — rows and
4
+ // counts, no exit codes, no formatted lines — and with a structured error,
5
+ // `{ ok: false, error: { code, message } }`, when they cannot. The methods are
6
+ // thin over the same primitives the commands use, so a handle and a command
7
+ // cannot disagree about the same index.
8
+ //
9
+ // The graph loads once at open: files, symbols, edges. FTS bodies run in
10
+ // SQLite (`search` delegates to `searchFtsBm25`) and stored vectors are not
11
+ // loaded at all: a map without `--embed` does not touch them (Q76).
12
+
13
+ import { coverage } from './coverage.mjs';
14
+ import { governs } from './govern.mjs';
15
+ import { dependents } from './impact.mjs';
16
+ import { rankFiles } from './map.mjs';
17
+ import { isIndexStale, readEdges, readFiles, readSymbols, reindex, searchFtsBm25 } from './store.mjs';
18
+
19
+ const INDEX_CODES = {
20
+ BUILD: 'E_BUILD',
21
+ CLOSED: 'E_CLOSED',
22
+ NO_INDEX: 'E_NO_INDEX',
23
+ READ: 'E_READ',
24
+ };
25
+
26
+ function ok(data) {
27
+ return { ok: true, data };
28
+ }
29
+
30
+ function fail(code, message) {
31
+ return { ok: false, error: { code, message } };
32
+ }
33
+
34
+ /**
35
+ * The files, symbols and edges the index holds. The lazy projection of
36
+ * `loadGraph`: no FTS bodies, no vectors, because nothing on this handle
37
+ * scores them.
38
+ */
39
+ async function loadGraphData(root) {
40
+ const [files, symbols, edges] = await Promise.all([
41
+ readFiles(root),
42
+ readSymbols(root),
43
+ readEdges(root),
44
+ ]);
45
+ return { files, symbols, edges };
46
+ }
47
+
48
+ /**
49
+ * Open the repository's index.
50
+ *
51
+ * @param {string} root
52
+ * @param {{build?: boolean, refresh?: boolean}} [options]
53
+ * `build` (default true) refreshes the index from the git index before the
54
+ * first read — incrementally, so a current repository pays for one
55
+ * `git ls-files -s`, not a walk of the tree. `refresh` re-reads the graph on
56
+ * every call instead of caching it at open.
57
+ * @returns {Promise<IndexHandle | {ok: false, error: {code: string, message: string}}>}
58
+ */
59
+ export async function openIndex(root, { build = true, refresh = false } = {}) {
60
+ if (build) {
61
+ try {
62
+ await reindex(root);
63
+ } catch (error) {
64
+ return fail(INDEX_CODES.BUILD, `could not build the index (${error.message})`);
65
+ }
66
+ }
67
+
68
+ let closed = false;
69
+ let cached = null;
70
+
71
+ async function graph() {
72
+ if (closed) throw new Error(INDEX_CODES.CLOSED);
73
+ if (cached === null || refresh) cached = await loadGraphData(root);
74
+ return cached;
75
+ }
76
+
77
+ async function answered(fn) {
78
+ try {
79
+ return await fn();
80
+ } catch (error) {
81
+ if (error.message === INDEX_CODES.CLOSED) return fail(INDEX_CODES.CLOSED, 'the index is closed');
82
+ return fail(INDEX_CODES.READ, error.message);
83
+ }
84
+ }
85
+
86
+ const handle = {
87
+ ok: true,
88
+
89
+ /** Freshness and row counts of the open index. */
90
+ status: () =>
91
+ answered(async () => {
92
+ const g = await graph();
93
+ if (g.files.length === 0) return fail(INDEX_CODES.NO_INDEX, 'the index is empty');
94
+ const stale = await isIndexStale(root);
95
+ return ok({ stale, counts: { files: g.files.length, symbols: g.symbols.length, edges: g.edges.length } });
96
+ }),
97
+
98
+ /**
99
+ * BM25 rows for a task string, best first.
100
+ * @param {string} task
101
+ * @param {{limit?: number}} [options]
102
+ * @returns {Promise<{ok: true, data: {path: string, kind: string, score: number}[]}|{ok: false, error: {code: string, message: string}}>}
103
+ */
104
+ search: (task, { limit } = {}) =>
105
+ answered(async () => {
106
+ if (closed) throw new Error(INDEX_CODES.CLOSED);
107
+ const rows = await searchFtsBm25(root, task);
108
+ if (rows.error) return fail(INDEX_CODES.READ, rows.error);
109
+ return ok(limit === undefined ? rows : rows.slice(0, limit));
110
+ }),
111
+
112
+ /**
113
+ * Rank the repository's files for a task string (R22): graph proximity
114
+ * fused with the BM25 rows.
115
+ * @param {string} task
116
+ * @param {{limit?: number, embed?: boolean, allowRemote?: boolean}} [options]
117
+ * @returns {Promise<{ok: true, data: {path: string, rank: number, score: number}[]}|{ok: false, error: {code: string, message: string}}>}
118
+ */
119
+ rank: (task, { limit, embed = false, allowRemote = false } = {}) =>
120
+ answered(async () => {
121
+ const g = await graph();
122
+ if (g.files.length === 0) return fail(INDEX_CODES.NO_INDEX, 'the index is empty');
123
+ const bm25 = String(task ?? '').trim().length > 0 ? await searchFtsBm25(root, task) : [];
124
+ if (bm25.error) return fail(INDEX_CODES.READ, bm25.error);
125
+ const ranked = rankFiles({ ...g, bm25, task });
126
+ return ok(limit === undefined ? ranked : ranked.slice(0, limit));
127
+ }),
128
+
129
+ /**
130
+ * The tests that exercise `paths` (Q77): the import closure when
131
+ * `transitive` (default true), the stored test edges otherwise.
132
+ * @param {Iterable<string>} paths
133
+ * @param {{transitive?: boolean, cochange?: boolean}} [options]
134
+ * @returns {Promise<{ok: true, data: string[]}|{ok: false, error: {code: string, message: string}}>}
135
+ */
136
+ coverage: (paths, options) =>
137
+ answered(async () => {
138
+ const g = await graph();
139
+ return ok(coverage({ files: g.files, imports: g.edges }, paths, options));
140
+ }),
141
+
142
+ /**
143
+ * Who a change to `file` reaches (Q77): the reverse import closure and
144
+ * the tests that import it.
145
+ * @param {string} file
146
+ * @param {{limit?: number}} [options]
147
+ * @returns {Promise<{ok: true, data: {importers: string[], tests: string[], truncated: boolean}}|{ok: false, error: {code: string, message: string}}>}
148
+ */
149
+ impact: (file, { limit } = {}) =>
150
+ answered(async () => {
151
+ const g = await graph();
152
+ return ok(dependents(file, g.edges, limit));
153
+ }),
154
+
155
+ /**
156
+ * What governs `file` (Q77): the records in `corpus` whose Affects line
157
+ * names it, plus the rules, skills, gates and lessons whose path lists
158
+ * match.
159
+ * @param {string} file
160
+ * @param {{records?: {id: string, title: string, text: string}[], ctx?: {files: Set<string>, commands: Set<string>, roots: string[]}}} [corpus]
161
+ * @returns {Promise<{ok: true, data: {kind: string, id: string, detail?: string}[]}|{ok: false, error: {code: string, message: string}}>}
162
+ */
163
+ governs: (file, corpus) => answered(() => ok(governs(file, corpus ?? {}))),
164
+
165
+ /** Release the handle. Calls after it answer `E_CLOSED`. */
166
+ close() {
167
+ closed = true;
168
+ cached = null;
169
+ },
170
+ };
171
+
172
+ return handle;
173
+ }
@@ -9,19 +9,27 @@ import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
9
9
  import { dirname, join } from 'node:path';
10
10
 
11
11
  import { LOCAL_DIR } from '../state.mjs';
12
+ import { cmp } from '../util/sort.mjs';
12
13
 
13
14
  export const SQLITE_FILE = join(LOCAL_DIR, 'index.sqlite');
14
15
  export const JSONL_FILE = join(LOCAL_DIR, 'index.jsonl');
15
16
  const SQLITE_FILE_LEGACY = '.simt/index.sqlite';
16
17
  const JSONL_FILE_LEGACY = '.simt/index.jsonl';
17
- export const SCHEMA_V = '4';
18
+ export const SCHEMA_V = '5';
18
19
 
19
20
  // The FTS tables carry their tokenizer in their definition, so `if not exists`
20
21
  // cannot migrate them: a v3 database would keep tokenizing new rows the v3 way
21
- // (ASCII fold, kind and ref searchable). The 3 -> 4 migration below recreates
22
- // them, which is the versioned replacement for the per-write wipe (Q44).
23
- export const FTS_SCHEMA = 'create virtual table fts using fts5(kind UNINDEXED, ref UNINDEXED, body, tokenize = "unicode61 remove_diacritics 2")';
24
- export const FTS_TRI_SCHEMA = 'create virtual table fts_tri using fts5(kind UNINDEXED, ref UNINDEXED, body, tokenize = "trigram")';
22
+ // (ASCII fold, kind and ref searchable), and a v4 `fts` table cannot grow the
23
+ // node_id column the v5 layout needs. The preamble creates them only when they
24
+ // are missing (a cold build); the 3 -> 4 and 4 -> 5 migrations below drop and
25
+ // recreate them, which is the versioned replacement for the per-write wipe (Q44).
26
+ export const FTS_SCHEMA = 'create virtual table if not exists fts using fts5(body, node_id UNINDEXED, kind UNINDEXED, tokenize = "unicode61 remove_diacritics 2")';
27
+ export const FTS_TRI_SCHEMA = 'create virtual table if not exists fts_tri using fts5(kind UNINDEXED, ref UNINDEXED, body, tokenize = "trigram")';
28
+
29
+ // The v4 `fts` shape. The 3 -> 4 step recreates the table with THIS shape so a
30
+ // v3 database emerges looking exactly like a genuine v4 one - the 4 -> 5 step
31
+ // then copies its `ref` column, which the v5 shape no longer has.
32
+ const FTS_V4_SCHEMA = 'create virtual table fts using fts5(kind UNINDEXED, ref UNINDEXED, body, tokenize = "unicode61 remove_diacritics 2")';
25
33
 
26
34
  // A database written by a *newer* simt is never opened for write, and never
27
35
  // read blind (review 0003 Q44): the mismatched canonical file stays with its
@@ -53,11 +61,58 @@ const MIGRATIONS = [
53
61
  to: 4,
54
62
  apply: (db) => {
55
63
  db.exec('drop table if exists fts');
56
- db.exec(FTS_SCHEMA);
64
+ db.exec(FTS_V4_SCHEMA);
57
65
  db.exec('drop table if exists fts_tri');
58
66
  db.exec(FTS_TRI_SCHEMA);
59
67
  },
60
68
  },
69
+ // v4 is per-kind tables (files/symbols/edges/embeddings); v5 is the entity
70
+ // layout (sources/nodes/edges/vectors) with the read shapes restored as SQL
71
+ // in the store. `edges` and `fts` are built under `_v5` names and swapped in
72
+ // after the copy, because the v4 tables of those names still hold data.
73
+ // fts_tri keeps its (kind, ref, body) shape - the CJK trigram path is
74
+ // untouched (Q43).
75
+ {
76
+ from: 4,
77
+ to: 5,
78
+ apply: (db) => {
79
+ db.exec(TABLES);
80
+ db.exec(EDGES_DDL.replaceAll('edges(', 'edges_v5('));
81
+ db.exec(FTS_SCHEMA.replaceAll('exists fts ', 'exists fts_v5 '));
82
+ db.exec(`insert into sources(kind, path, revision, lang, bytes, status, props)
83
+ select 'blob', path, blob, lang, bytes, 'indexed', json_object('pkg', coalesce(pkg, '')) from files`);
84
+ db.exec(`insert into nodes(key, kind, source_id)
85
+ select 'file:' || path, 'file', id from sources where kind = 'blob'`);
86
+ db.exec(`insert into nodes(key, kind, source_id, name, line, props)
87
+ select 'sym:' || s.path || '#' || s.name || '@' || coalesce(s.line, 0), 'symbol', src.id,
88
+ s.name, s.line, json_object('kind', coalesce(s.kind, 'symbol'), 'sig', coalesce(s.sig, ''), 'exported', coalesce(s.exported, 0))
89
+ from symbols s join sources src on src.kind = 'blob' and src.path = s.path`);
90
+ db.exec(`insert into edges_v5(src_id, kind, dst_key, dst_id, weight, owner_source_id, trust)
91
+ select sn.id, e.kind, e.dst, dn.id, e.weight, src.id, 'observed'
92
+ from edges e
93
+ join sources src on src.kind = 'blob' and src.path = e.src
94
+ join nodes sn on sn.source_id = src.id and sn.kind = 'file'
95
+ left join sources dst on dst.kind = 'blob' and dst.path = e.dst
96
+ left join nodes dn on dn.source_id = dst.id and dn.kind = 'file'`);
97
+ db.exec(`insert into vectors(node_id, model, dims, vec)
98
+ select n.id, em.model, em.dims, em.vec
99
+ from embeddings em
100
+ join sources s on s.kind = 'blob' and s.path = em.path
101
+ join nodes n on n.source_id = s.id and n.kind = 'file'`);
102
+ db.exec(`insert into fts_v5(body, node_id, kind)
103
+ select f.body, n.id, f.kind
104
+ from fts f
105
+ join sources s on s.kind = 'blob' and s.path = f.ref
106
+ join nodes n on n.source_id = s.id and n.kind = 'file'`);
107
+ db.exec('drop table files');
108
+ db.exec('drop table symbols');
109
+ db.exec('drop table embeddings');
110
+ db.exec('drop table edges');
111
+ db.exec('drop table fts');
112
+ db.exec('alter table edges_v5 rename to edges');
113
+ db.exec('alter table fts_v5 rename to fts');
114
+ },
115
+ },
61
116
  ];
62
117
 
63
118
  /**
@@ -67,21 +122,89 @@ const MIGRATIONS = [
67
122
  * each is idempotent on an already-migrated table set.
68
123
  */
69
124
  function migrateDb(db) {
70
- const at = Number(db.prepare('pragma user_version').get()?.user_version ?? 0);
71
- for (const step of MIGRATIONS) {
72
- if (at === 0 || (at >= step.from && at < step.to)) step.apply(db);
125
+ let at = Number(db.prepare('pragma user_version').get()?.user_version ?? 0);
126
+ // `user_version 0` is ambiguous: a cold build has no legacy tables at all,
127
+ // and a pre-stamp database still holds the v3 shape. The 4 -> 5 step copies
128
+ // FROM the v4 tables (`files`, `symbols`, ...), so a cold build must not run
129
+ // it - the 3 -> 4 step would leave its `fts` in the v4 shape behind.
130
+ const legacy =
131
+ at !== 0 ||
132
+ db.prepare(`select count(*) as n from sqlite_master where type = 'table' and name in ('files', 'symbols', 'embeddings')`).get().n > 0;
133
+ if (legacy) {
134
+ for (const step of MIGRATIONS) {
135
+ if (at === 0 || (at >= step.from && at < step.to)) {
136
+ step.apply(db);
137
+ at = step.to; // the next step sees the version this one left behind
138
+ }
139
+ }
73
140
  }
74
141
  db.exec(`pragma user_version = ${Number(SCHEMA_V)}`);
75
142
  }
76
143
 
77
- const SCHEMA = `
78
- create table if not exists files(path text primary key, blob text, lang text, bytes int, pkg text);
79
- create table if not exists symbols(id integer primary key, path text, name text, kind text, line int, sig text, exported int);
80
- create table if not exists edges(src text, dst text, kind text, weight real);
81
- create table if not exists embeddings(path text primary key, blob text, model text, dims int, vec blob);
144
+ const TABLES = `
145
+ create table if not exists sources(
146
+ id integer primary key,
147
+ kind text not null default 'blob',
148
+ path text not null,
149
+ revision text,
150
+ lang text,
151
+ bytes integer,
152
+ status text not null default 'indexed',
153
+ props text,
154
+ unique(kind, path)
155
+ );
156
+ create table if not exists nodes(
157
+ id integer primary key,
158
+ key text not null unique,
159
+ kind text,
160
+ source_id integer,
161
+ name text,
162
+ line integer,
163
+ span_start integer,
164
+ span_end integer,
165
+ digest text,
166
+ trust text not null default 'observed',
167
+ props text
168
+ );
169
+ create table if not exists vectors(
170
+ node_id integer primary key,
171
+ model text,
172
+ dims integer,
173
+ vec blob
174
+ );
82
175
  create table if not exists meta(key text primary key, value text);
83
176
  `;
84
177
 
178
+ // The v5 edges table. Named `edges_v5` in the 4 -> 5 migration, because the
179
+ // v4 database still holds an `edges` table of a different shape until the
180
+ // copy below is done.
181
+ const EDGES_DDL = `create table if not exists edges(
182
+ src_id integer,
183
+ kind text,
184
+ dst_key text,
185
+ dst_id integer,
186
+ weight real,
187
+ owner_source_id integer,
188
+ trust text,
189
+ props text,
190
+ primary key(src_id, kind, dst_key)
191
+ );`;
192
+
193
+ // Q76: the v4 layout had no index on the edge endpoints or the node owners -
194
+ // the dependent, impact and coverage walks each paid a full scan per file.
195
+ // Executed after `migrateDb`, not in the preamble: on a v3 database the v3
196
+ // `edges` table survives `if not exists` until the 4 -> 5 step swaps in the
197
+ // v5 shape, and `idx_edges_dst` needs the `dst_id` column that only the v5
198
+ // table has.
199
+ const INDEXES = `
200
+ create index if not exists idx_edges_dst on edges(dst_id, kind);
201
+ create index if not exists idx_edges_owner on edges(owner_source_id);
202
+ create index if not exists idx_nodes_source on nodes(source_id);
203
+ create index if not exists idx_nodes_kind_name on nodes(kind, name);
204
+ `;
205
+
206
+ const SCHEMA = `${TABLES}\n${EDGES_DDL}\n`;
207
+
85
208
  export function encodeVectorBlob(vec) {
86
209
  if (!Array.isArray(vec) && !(vec instanceof Float32Array)) return Buffer.alloc(0);
87
210
  const f32 = vec instanceof Float32Array ? vec : Float32Array.from(vec, (v) => Number(v) || 0);
@@ -186,55 +309,95 @@ export async function writeSqlite(root, next, removed, symbols, edges, fts, embe
186
309
  db.exec('pragma busy_timeout = 5000');
187
310
  db.exec('pragma synchronous = normal');
188
311
  db.exec(SCHEMA);
312
+ db.exec(FTS_SCHEMA);
313
+ db.exec(FTS_TRI_SCHEMA);
189
314
  db.exec('begin immediate');
190
315
  migrateDb(db);
191
- const upsert = db.prepare(`insert into files(path, blob, lang, bytes, pkg) values (?, ?, ?, ?, ?)
192
- on conflict(path) do update set blob = excluded.blob, lang = excluded.lang, bytes = excluded.bytes, pkg = excluded.pkg`);
193
- const dropSymbols = db.prepare('delete from symbols where path = ?');
194
- // An importer owns its outgoing edges: re-extracting a changed file drops
195
- // only `src = ?`, so the unchanged importers' edges pointing into it survive
196
- // (Q07). A removed file's incoming edges dangle, so they drop with it.
197
- const dropEdges = db.prepare('delete from edges where src = ?');
198
- const dropIncoming = db.prepare('delete from edges where dst = ?');
199
- const dropFts = db.prepare('delete from fts where ref = ?');
200
- const dropTri = db.prepare('delete from fts_tri where ref = ?');
201
- const dropEmbed = db.prepare('delete from embeddings where path = ?');
202
- const dropFile = db.prepare('delete from files where path = ?');
203
- const addSymbol = db.prepare('insert into symbols(path, name, kind, line, sig, exported) values (?, ?, ?, ?, ?, ?)');
204
- const addEdge = db.prepare('insert into edges(src, dst, kind, weight) values (?, ?, ?, ?)');
205
- const addFts = db.prepare('insert into fts(kind, ref, body) values (?, ?, ?)');
316
+ // The `edges` table is v5-shaped now on every path: the preamble created
317
+ // it on a cold build, the migration swapped it in on a legacy one.
318
+ db.exec(INDEXES);
319
+ // One source row per file, keyed (kind, path): the revision is the git blob
320
+ // sha, so an unchanged blob keeps its row, and the file's node, vectors,
321
+ // edges and fts rows all hang off it. `props` carries what the v5 columns
322
+ // do not name - the package directory, which the files read shape serves.
323
+ const upsertSource = db.prepare(`insert into sources(kind, path, revision, lang, bytes, status, props)
324
+ values('blob', ?, ?, ?, ?, 'indexed', ?)
325
+ on conflict(kind, path) do update set revision = excluded.revision, lang = excluded.lang,
326
+ bytes = excluded.bytes, props = excluded.props`);
327
+ const sourceIdOf = db.prepare(`select id from sources where kind = 'blob' and path = ?`);
328
+ // `prepare` compiles only the FIRST statement in a string (Q76): a
329
+ // multi-statement delete prepared as one ran only its head, and the
330
+ // surviving `file:` node collided with the next insert on `nodes.key`.
331
+ const dropDependents = [
332
+ db.prepare('delete from vectors where node_id in (select id from nodes where source_id = ?)'),
333
+ db.prepare('delete from fts where node_id in (select id from nodes where source_id = ?)'),
334
+ db.prepare('delete from edges where owner_source_id = ?'),
335
+ db.prepare('delete from nodes where source_id = ?'),
336
+ ];
337
+ const addFileNode = db.prepare(`insert into nodes(key, kind, source_id, trust) values('file:' || ?, 'file', ?, 'observed')`);
338
+ const addSymbol = db.prepare(`insert into nodes(key, kind, source_id, name, line, props)
339
+ values('sym:' || ? || '#' || ? || '@' || ?, 'symbol', ?, ?, ?, ?)`);
340
+ const addEdge = db.prepare(`insert into edges(src_id, kind, dst_key, dst_id, weight, owner_source_id, trust)
341
+ values (?, ?, ?, ?, ?, ?, 'observed')`);
342
+ const addFts = db.prepare('insert into fts(body, node_id, kind) values (?, ?, ?)');
206
343
  const addTri = db.prepare('insert into fts_tri(kind, ref, body) values (?, ?, ?)');
207
- const upsertEmbed = db.prepare(`insert into embeddings(path, blob, model, dims, vec) values (?, ?, ?, ?, ?)
208
- on conflict(path) do update set blob = excluded.blob, model = excluded.model, dims = excluded.dims, vec = excluded.vec`);
344
+ const upsertVector = db.prepare(`insert into vectors(node_id, model, dims, vec) values (?, ?, ?, ?)
345
+ on conflict(node_id) do update set model = excluded.model, dims = excluded.dims, vec = excluded.vec`);
346
+ const dropSource = [
347
+ db.prepare(`delete from vectors where node_id in (select id from nodes where source_id in (select id from sources where kind = 'blob' and path = ?))`),
348
+ db.prepare(`delete from fts where node_id in (select id from nodes where source_id in (select id from sources where kind = 'blob' and path = ?))`),
349
+ db.prepare(`delete from edges where owner_source_id in (select id from sources where kind = 'blob' and path = ?)`),
350
+ db.prepare('delete from edges where dst_key = ?'),
351
+ db.prepare(`delete from nodes where source_id in (select id from sources where kind = 'blob' and path = ?)`),
352
+ db.prepare(`delete from sources where kind = 'blob' and path = ?`),
353
+ ];
209
354
  for (const row of next) {
210
- upsert.run(row.path, row.blob, row.lang, row.bytes, row.pkg);
211
- dropSymbols.run(row.path);
212
- dropEdges.run(row.path);
213
- dropFts.run(row.path);
214
- dropTri.run(row.path);
215
- dropEmbed.run(row.path);
355
+ const sourceId = sourceIdOf.get(row.path)?.id;
356
+ if (sourceId !== undefined) for (const st of dropDependents) st.run(sourceId);
357
+ upsertSource.run(row.path, row.blob, row.lang, row.bytes, JSON.stringify({ pkg: row.pkg ?? '' }));
216
358
  }
217
359
  for (const path of removed) {
218
- dropFile.run(path);
219
- dropSymbols.run(path);
220
- dropEdges.run(path);
221
- dropIncoming.run(path);
222
- dropFts.run(path);
223
- dropTri.run(path);
224
- dropEmbed.run(path);
360
+ for (const st of dropSource) st.run(path);
361
+ // fts_tri is still path-keyed: the trigram table keeps its v4 shape (Q43).
362
+ db.prepare('delete from fts_tri where ref = ?').run(path);
363
+ }
364
+ for (const row of next) {
365
+ addFileNode.run(row.path, sourceIdOf.get(row.path).id);
366
+ }
367
+ // The ids a changed file's rows hang off were just rebuilt; an unchanged
368
+ // file's rows keep their old ids. One query maps every path to both.
369
+ const ids = new Map();
370
+ for (const r of db.prepare(`select s.path, s.id as source_id, n.id as node_id
371
+ from sources s join nodes n on n.source_id = s.id and n.kind = 'file'
372
+ where s.kind = 'blob'`).all()) {
373
+ ids.set(r.path, { sourceId: r.source_id, nodeId: r.node_id });
374
+ }
375
+ for (const row of symbols) {
376
+ const entry = ids.get(row.path);
377
+ if (!entry) continue;
378
+ addSymbol.run(row.path, row.name, row.line, entry.sourceId, row.name, row.line,
379
+ JSON.stringify({ kind: row.kind ?? 'symbol', sig: row.sig ?? '', exported: row.exported ? 1 : 0 }));
380
+ }
381
+ for (const row of edges) {
382
+ const src = ids.get(row.src);
383
+ if (!src) continue;
384
+ const dst = ids.get(row.dst);
385
+ addEdge.run(src.nodeId, row.kind, row.dst, dst ? dst.nodeId : null, row.weight, src.sourceId);
225
386
  }
226
- for (const row of symbols) addSymbol.run(row.path, row.name, row.kind, row.line, row.sig, row.exported);
227
- for (const row of edges) addEdge.run(row.src, row.dst, row.kind, row.weight);
228
387
  for (const row of fts) {
229
- addFts.run(row.kind, row.ref, row.body);
388
+ const entry = ids.get(row.ref);
389
+ if (!entry) continue;
390
+ addFts.run(row.body, entry.nodeId, row.kind);
230
391
  // The trigram table carries folded raw text for CJK-bearing sections
231
392
  // only; an ASCII-only section is already reachable through `fts`.
232
393
  if (row.raw) addTri.run(row.kind, row.ref, row.raw);
233
394
  }
234
395
  for (const row of embeddings) {
396
+ const entry = ids.get(row.path);
397
+ if (!entry) continue;
235
398
  const vecBlob = Buffer.isBuffer(row.vec) || row.vec instanceof Uint8Array ? row.vec : encodeVectorBlob(row.vec);
236
399
  const dims = Number.isInteger(row.dims) ? row.dims : Math.floor(vecBlob.byteLength / 4);
237
- upsertEmbed.run(row.path, row.blob ?? '', row.model ?? '', dims, vecBlob);
400
+ upsertVector.run(entry.nodeId, row.model ?? '', dims, vecBlob);
238
401
  }
239
402
  const upsertMeta = db.prepare(
240
403
  'insert into meta(key, value) values (?, ?) on conflict(key) do update set value = excluded.value',
@@ -529,13 +692,20 @@ export async function readTreeDigest(root, backend) {
529
692
  }
530
693
 
531
694
  export async function countSqlite(root) {
532
- const symbols = await readSqliteRows(root, 'select count(*) as n from symbols');
533
- const edges = await readSqliteRows(root, 'select count(*) as n from edges');
534
- const embeddings = await readSqliteRows(root, 'select count(*) as n from embeddings');
695
+ // One open, three counts: the v4 layout paid a full open/close per metric,
696
+ // and a read that only wants the counts was buying three connections (Q76).
697
+ const { rows } = await readSqliteRows(
698
+ root,
699
+ `select
700
+ (select count(*) from nodes where kind = 'symbol') as symbols,
701
+ (select count(*) from edges) as edges,
702
+ (select count(*) from vectors) as embeddings`,
703
+ );
704
+ const row = rows[0] ?? {};
535
705
  return {
536
- symbols: Number(symbols.rows[0]?.n ?? 0),
537
- edges: Number(edges.rows[0]?.n ?? 0),
538
- embeddings: Number(embeddings.rows[0]?.n ?? 0),
706
+ symbols: Number(row.symbols ?? 0),
707
+ edges: Number(row.edges ?? 0),
708
+ embeddings: Number(row.embeddings ?? 0),
539
709
  };
540
710
  }
541
711
 
@@ -551,6 +721,3 @@ function byEdge(a, b) {
551
721
  return cmp(a.src, b.src) || cmp(a.dst, b.dst) || cmp(a.kind, b.kind);
552
722
  }
553
723
 
554
- function cmp(a, b) {
555
- return a < b ? -1 : a > b ? 1 : 0;
556
- }