simt-dev 0.7.0 → 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 (235) hide show
  1. package/CHANGELOG.md +191 -870
  2. package/README.md +188 -213
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
  5. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  7. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  8. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  9. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  10. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  11. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  12. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  13. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  14. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  15. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  16. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  17. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  18. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  19. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  20. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  21. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  22. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  23. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  24. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  25. package/ecosystems/bun.json +6 -0
  26. package/ecosystems/cargo.json +25 -0
  27. package/ecosystems/composer.json +6 -0
  28. package/ecosystems/dart.json +24 -0
  29. package/ecosystems/deno.json +21 -0
  30. package/ecosystems/devcontainers.json +6 -0
  31. package/ecosystems/docker-compose.json +6 -0
  32. package/ecosystems/docker.json +6 -0
  33. package/ecosystems/dotnet.json +25 -0
  34. package/ecosystems/elixir.json +23 -0
  35. package/ecosystems/gitsubmodule.json +6 -0
  36. package/ecosystems/go.json +25 -0
  37. package/ecosystems/gradle.json +13 -0
  38. package/ecosystems/maven.json +23 -0
  39. package/ecosystems/node.json +25 -0
  40. package/ecosystems/python.json +20 -0
  41. package/ecosystems/ruby.json +23 -0
  42. package/ecosystems/swift.json +25 -0
  43. package/ecosystems/terraform.json +6 -0
  44. package/ecosystems/uv.json +6 -0
  45. package/harnesses/antigravity.json +162 -0
  46. package/harnesses/claude.json +206 -0
  47. package/harnesses/codex.json +136 -0
  48. package/harnesses/copilot.json +88 -0
  49. package/harnesses/cursor.json +140 -0
  50. package/harnesses/deepseek.json +99 -0
  51. package/harnesses/gemini.json +122 -0
  52. package/harnesses/opencode.json +173 -0
  53. package/package.json +12 -2
  54. package/rules/core.md +7 -0
  55. package/rules/mdc/intake-questions.mdc +8 -0
  56. package/rules/mdc/slice-commit.mdc +57 -4
  57. package/src/advice/advice.mjs +3 -2
  58. package/src/agenthooks/prompt.mjs +5 -2
  59. package/src/agenthooks/render.mjs +86 -180
  60. package/src/agenthooks/shim.mjs +1 -1
  61. package/src/agents/render.mjs +15 -18
  62. package/src/api.mjs +31 -0
  63. package/src/argv.mjs +48 -3
  64. package/src/bench/ablate.mjs +3 -4
  65. package/src/bench/adapters.json +163 -105
  66. package/src/bench/adapters.mjs +466 -0
  67. package/src/bench/arms.mjs +333 -0
  68. package/src/bench/grade.mjs +33 -73
  69. package/src/bench/graders.mjs +42 -0
  70. package/src/bench/mine.mjs +121 -475
  71. package/src/bench/probe-ledger.mjs +149 -0
  72. package/src/bench/probes.catalog.mjs +918 -0
  73. package/src/bench/probes.mjs +79 -1064
  74. package/src/bench/render.mjs +111 -0
  75. package/src/bench/report.mjs +120 -962
  76. package/src/bench/run.mjs +185 -802
  77. package/src/bench/variants.mjs +4 -5
  78. package/src/bench/workspace.mjs +160 -0
  79. package/src/brief/assemble.mjs +48 -15
  80. package/src/ci/dependabot.mjs +25 -55
  81. package/src/ci/workflow.mjs +30 -7
  82. package/src/cli.mjs +45 -0
  83. package/src/commands/agent.mjs +4 -13
  84. package/src/commands/agenthook.mjs +70 -40
  85. package/src/commands/bench.mjs +191 -18
  86. package/src/commands/brief.mjs +5 -4
  87. package/src/commands/ci.mjs +13 -14
  88. package/src/commands/compare.mjs +42 -42
  89. package/src/commands/completions.mjs +2 -6
  90. package/src/commands/decide.mjs +4 -4
  91. package/src/commands/distill.mjs +4 -3
  92. package/src/commands/docs.mjs +88 -0
  93. package/src/commands/doctor.mjs +11 -7
  94. package/src/commands/evidence.mjs +6 -9
  95. package/src/commands/explain.mjs +0 -1
  96. package/src/commands/gate.mjs +26 -25
  97. package/src/commands/graph.mjs +2 -3
  98. package/src/commands/harness.mjs +6 -5
  99. package/src/commands/help.mjs +168 -19
  100. package/src/commands/hooks.mjs +119 -49
  101. package/src/commands/ignore.mjs +13 -8
  102. package/src/commands/impact.mjs +8 -15
  103. package/src/commands/index.mjs +318 -193
  104. package/src/commands/init-scan.mjs +90 -0
  105. package/src/commands/init.mjs +124 -105
  106. package/src/commands/lease.mjs +73 -25
  107. package/src/commands/lessons.mjs +10 -9
  108. package/src/commands/local.mjs +3 -3
  109. package/src/commands/map.mjs +24 -14
  110. package/src/commands/mcp.mjs +2 -18
  111. package/src/commands/pack.mjs +9 -6
  112. package/src/commands/plan.mjs +6 -5
  113. package/src/commands/plugin.mjs +2 -2
  114. package/src/commands/prbody.mjs +3 -3
  115. package/src/commands/repoindex.mjs +12 -11
  116. package/src/commands/review.mjs +2 -2
  117. package/src/commands/rules.mjs +37 -16
  118. package/src/commands/shared.mjs +14 -19
  119. package/src/commands/skills.mjs +33 -32
  120. package/src/commands/skillscompile.mjs +7 -8
  121. package/src/commands/skillsinstall.mjs +7 -6
  122. package/src/commands/slice.mjs +220 -74
  123. package/src/commands/spec.mjs +167 -0
  124. package/src/commands/status.mjs +27 -18
  125. package/src/commands/sync.mjs +27 -25
  126. package/src/commands/tests.mjs +18 -21
  127. package/src/commands/tokens.mjs +21 -26
  128. package/src/commands/undo.mjs +5 -15
  129. package/src/commands/upgrade.mjs +15 -7
  130. package/src/commands/why.mjs +15 -21
  131. package/src/compare/hooksstate.mjs +2 -6
  132. package/src/compare/learn.mjs +3 -1
  133. package/src/compare/row.mjs +0 -2
  134. package/src/context.mjs +64 -0
  135. package/src/decisions/index-doc.mjs +46 -0
  136. package/src/decisions/index.mjs +3 -2
  137. package/src/diff.mjs +1 -1
  138. package/src/distill/distill.mjs +14 -1
  139. package/src/distill/engine.mjs +5 -1
  140. package/src/docs/commands.mjs +65 -0
  141. package/src/ecosystem/records.mjs +219 -0
  142. package/src/explain.mjs +31 -0
  143. package/src/gates/index.mjs +385 -0
  144. package/src/graph/compile.mjs +8 -4
  145. package/src/graph/load.mjs +6 -2
  146. package/src/graph/spec.mjs +10 -8
  147. package/src/harness/records.mjs +143 -0
  148. package/src/hooks/capability.mjs +7 -5
  149. package/src/hooks/dispatcher.mjs +209 -60
  150. package/src/hooks/gates.mjs +10 -43
  151. package/src/hooks/resolve.mjs +4 -14
  152. package/src/hooks/shim.mjs +5 -2
  153. package/src/hooks/where.mjs +2 -7
  154. package/src/identity.mjs +31 -12
  155. package/src/index/coverage.mjs +58 -0
  156. package/src/index/extract.mjs +6 -16
  157. package/src/index/gate.mjs +1 -1
  158. package/src/index/govern.mjs +26 -9
  159. package/src/index/impact.mjs +4 -8
  160. package/src/index/map.mjs +14 -15
  161. package/src/index/open.mjs +173 -0
  162. package/src/index/persist.mjs +224 -57
  163. package/src/index/store.mjs +82 -39
  164. package/src/index/tests.mjs +2 -6
  165. package/src/journal.mjs +94 -20
  166. package/src/kernel/card.mjs +37 -63
  167. package/src/kernel/checks.mjs +4 -10
  168. package/src/lease.mjs +102 -7
  169. package/src/ledger/index.mjs +150 -14
  170. package/src/local/client.mjs +4 -6
  171. package/src/locate.mjs +33 -9
  172. package/src/manifest/schema.mjs +213 -0
  173. package/src/mcp/paginate.mjs +33 -0
  174. package/src/mcp/resources.mjs +7 -10
  175. package/src/mcp/schemas.mjs +512 -0
  176. package/src/mcp/server.mjs +119 -25
  177. package/src/mcp/tools-facts.mjs +326 -0
  178. package/src/mcp/tools-read.mjs +221 -0
  179. package/src/mcp/tools.mjs +4 -967
  180. package/src/measure/corpus.mjs +150 -0
  181. package/src/measure/metrics.mjs +236 -0
  182. package/src/measure/records.mjs +211 -0
  183. package/src/measure/session.mjs +3 -2
  184. package/src/measure/stats.mjs +538 -0
  185. package/src/measure/task-schema.mjs +125 -0
  186. package/src/measure/tasks.mjs +69 -0
  187. package/src/memory/decide.mjs +6 -15
  188. package/src/memory/lesson-text.mjs +61 -0
  189. package/src/memory/lessons.mjs +29 -82
  190. package/src/name.mjs +7 -0
  191. package/src/node-version.mjs +37 -0
  192. package/src/output/index.mjs +209 -10
  193. package/src/packs/manage.mjs +32 -12
  194. package/src/packs.mjs +20 -2
  195. package/src/plan/index.mjs +1 -1
  196. package/src/plugins.mjs +22 -16
  197. package/src/prbody.mjs +7 -11
  198. package/src/projection/loaders.mjs +7 -692
  199. package/src/projection/lock.mjs +11 -1
  200. package/src/projection/manifest.mjs +29 -468
  201. package/src/projection/mdc.mjs +7 -8
  202. package/src/projection/project.mjs +126 -221
  203. package/src/projection/region.mjs +561 -0
  204. package/src/projection/rules.mjs +20 -550
  205. package/src/projection/surfaces.mjs +43 -210
  206. package/src/projection/targets.mjs +5 -5
  207. package/src/repo-root.mjs +31 -0
  208. package/src/resolve/eval.mjs +2 -1
  209. package/src/resolve/fingerprint.mjs +19 -22
  210. package/src/resolve/resolve.mjs +1 -41
  211. package/src/review/critic.mjs +10 -13
  212. package/src/run.mjs +98 -0
  213. package/src/secrets.mjs +14 -22
  214. package/src/state/events.mjs +129 -0
  215. package/src/status-snapshot.mjs +233 -0
  216. package/src/status.mjs +2 -6
  217. package/src/sys/git.mjs +82 -0
  218. package/src/sys/proc.mjs +151 -0
  219. package/src/testing/parsers.mjs +265 -0
  220. package/src/testing/patches.mjs +23 -0
  221. package/src/testing/paths.mjs +49 -0
  222. package/src/testing/runners.mjs +186 -0
  223. package/src/testing/vault.mjs +68 -0
  224. package/src/text/frontmatter.mjs +49 -0
  225. package/src/text/tokens.mjs +52 -0
  226. package/src/toolwords.mjs +3 -0
  227. package/src/util/fs.mjs +20 -0
  228. package/src/util/sort.mjs +12 -0
  229. package/src/workspace.mjs +58 -11
  230. package/src/write/plan.mjs +82 -0
  231. package/templates/plan/tracker.json +1 -0
  232. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  233. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  234. package/docs/decisions/README.md +0 -600
  235. package/src/bench/tasks.mjs +0 -36
@@ -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
- }
@@ -8,10 +8,16 @@ import { execFile, spawn } from 'node:child_process';
8
8
  import { stat } from 'node:fs/promises';
9
9
  import { dirname, join } from 'node:path';
10
10
 
11
+ import { git as gitRaw } from '../sys/git.mjs';
12
+
11
13
  import { readManifest } from '../projection/manifest.mjs';
12
14
  import { readLocalConfig } from '../local/config.mjs';
13
15
  import { localEmbed } from '../local/client.mjs';
14
16
  import { extract, resolveImports } from './extract.mjs';
17
+ // Q70: the extension-to-language map is the records' `languages[]` (ADR-013). `canonical`
18
+ // names the extensions that RESOLVE to a language (JavaScript claims `.ts` for extraction
19
+ // but not for resolution), so a record's extraction and resolution cannot desynchronize.
20
+ import { languageResolver } from '../ecosystem/records.mjs';
15
21
  import {
16
22
  FTS_SCHEMA,
17
23
  FTS_TRI_SCHEMA,
@@ -34,24 +40,32 @@ export { JSONL_FILE, SQLITE_FILE, decodeVectorBlob, encodeVectorBlob };
34
40
 
35
41
  const MARKERS = ['package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml'];
36
42
 
37
- const LANG = {
38
- js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
39
- ts: 'typescript', tsx: 'typescript', kt: 'kotlin', kts: 'kotlin', java: 'java',
40
- py: 'python', go: 'go', rs: 'rust', swift: 'swift', dart: 'dart', rb: 'ruby', cs: 'csharp',
41
- };
43
+ // A tracked blob larger than this is recorded with its size and counted in
44
+ // `skipped`, not streamed and tokenized: a 65 MiB file is indexed as a row, and
45
+ // tokenizing it would buy nothing (Q76). The cap sits under that file on purpose
46
+ // so the 65 MiB test exercises the skip, not the tokenize path.
47
+ const BLOB_CAP = 16 * 1024 * 1024;
48
+
49
+ const LANG = languageResolver();
42
50
 
43
51
  /** `sqlite` when this process can open a database and create an FTS5 table. */
52
+ // Probed once per process: the check builds both FTS schemas in a memory
53
+ // database, and every read path used to pay for that (Q76).
54
+ let backendProbe;
44
55
  async function probeBackend() {
45
- try {
46
- const { DatabaseSync } = await import('node:sqlite');
47
- const db = new DatabaseSync(':memory:');
48
- db.exec(FTS_SCHEMA);
49
- db.exec(FTS_TRI_SCHEMA);
50
- db.close();
51
- return 'sqlite';
52
- } catch {
53
- return 'jsonl';
54
- }
56
+ backendProbe ??= (async () => {
57
+ try {
58
+ const { DatabaseSync } = await import('node:sqlite');
59
+ const db = new DatabaseSync(':memory:');
60
+ db.exec(FTS_SCHEMA);
61
+ db.exec(FTS_TRI_SCHEMA);
62
+ db.close();
63
+ return 'sqlite';
64
+ } catch {
65
+ return 'jsonl';
66
+ }
67
+ })();
68
+ return backendProbe;
55
69
  }
56
70
 
57
71
  export function ftsKindOf(filePath, lang = '') {
@@ -224,7 +238,10 @@ export async function reindex(root, opts = {}) {
224
238
  changed.push(entry);
225
239
  }
226
240
  const sizes = await blobSizes(root, changed.map((entry) => entry.blob));
227
- const texts = await blobTexts(root, changed.map((entry) => entry.blob));
241
+ // Over-cap blobs are recorded with their size and counted in `skipped`, not
242
+ // streamed and tokenized: their row is written, their text is never read.
243
+ const textBlobs = changed.map((entry) => entry.blob).filter((blob) => (sizes.get(blob) ?? 0) <= BLOB_CAP);
244
+ const texts = await blobTexts(root, textBlobs);
228
245
  const adapter = await resolveAdapter(root, opts);
229
246
  const paths = new Set(listed.map((entry) => entry.path));
230
247
  const next = [];
@@ -383,7 +400,14 @@ function withReadError(rows, error) {
383
400
  export async function readFiles(root, opts = {}) {
384
401
  const backend = opts.backend ?? await probeBackend();
385
402
  if (backend === 'sqlite') {
386
- const { rows, error } = await readSqliteRows(root, 'select path, blob, lang, bytes, pkg from files order by path');
403
+ // The v5 layout keys files as `sources` rows; the git blob sha is `revision`
404
+ // and the package directory rides in `props`, which the v4 `files` table
405
+ // named `pkg`.
406
+ const { rows, error } = await readSqliteRows(
407
+ root,
408
+ `select s.path as path, s.revision as blob, s.lang as lang, s.bytes as bytes,
409
+ json_extract(s.props, '$.pkg') as pkg from sources s where s.kind = 'blob' order by s.path`,
410
+ );
387
411
  return withReadError(rows, error);
388
412
  }
389
413
  const { rows, error } = await readJsonl(root, 'files');
@@ -394,7 +418,14 @@ export async function readFiles(root, opts = {}) {
394
418
  export async function readSymbols(root, opts = {}) {
395
419
  const backend = opts.backend ?? await probeBackend();
396
420
  if (backend === 'sqlite') {
397
- const { rows, error } = await readSqliteRows(root, 'select path, name, kind, line, sig, exported from symbols order by path, line, name, kind');
421
+ const { rows, error } = await readSqliteRows(
422
+ root,
423
+ `select s.path as path, n.name as name, json_extract(n.props, '$.kind') as kind,
424
+ n.line as line, json_extract(n.props, '$.sig') as sig,
425
+ json_extract(n.props, '$.exported') as exported
426
+ from nodes n join sources s on s.id = n.source_id and s.kind = 'blob'
427
+ where n.kind = 'symbol' order by s.path, n.line, n.name, n.kind`,
428
+ );
398
429
  return withReadError(rows, error);
399
430
  }
400
431
  const { rows, error } = await readJsonl(root, 'symbols');
@@ -405,7 +436,14 @@ export async function readSymbols(root, opts = {}) {
405
436
  export async function readEdges(root, opts = {}) {
406
437
  const backend = opts.backend ?? await probeBackend();
407
438
  if (backend === 'sqlite') {
408
- const { rows, error } = await readSqliteRows(root, 'select src, dst, kind, weight from edges order by src, dst, kind');
439
+ // Paths come back from the node key (`file:<path>`) and the stable `dst_key`:
440
+ // node ids are regenerated when a file changes, so a path is the only key
441
+ // that survives a reindex of one endpoint.
442
+ const { rows, error } = await readSqliteRows(
443
+ root,
444
+ `select substr(ns.key, 6) as src, e.dst_key as dst, e.kind as kind, e.weight as weight
445
+ from edges e join nodes ns on ns.id = e.src_id order by src, dst, kind`,
446
+ );
409
447
  return withReadError(rows, error);
410
448
  }
411
449
  const { rows, error } = await readJsonl(root, 'edges');
@@ -416,7 +454,11 @@ export async function readEdges(root, opts = {}) {
416
454
  export async function readFts(root, opts = {}) {
417
455
  const backend = opts.backend ?? await probeBackend();
418
456
  if (backend === 'sqlite') {
419
- const { rows, error } = await readSqliteRows(root, 'select kind, ref, body from fts order by ref, kind, rowid');
457
+ const { rows, error } = await readSqliteRows(
458
+ root,
459
+ `select f.kind as kind, substr(n.key, 6) as ref, f.body as body
460
+ from fts f join nodes n on n.id = f.node_id order by ref, kind, f.rowid`,
461
+ );
420
462
  return withReadError(rows, error);
421
463
  }
422
464
  const { rows, error } = await readJsonl(root, 'fts');
@@ -427,7 +469,12 @@ export async function readFts(root, opts = {}) {
427
469
  export async function readEmbeddings(root, opts = {}) {
428
470
  const backend = opts.backend ?? await probeBackend();
429
471
  if (backend === 'sqlite') {
430
- const { rows: raw, error } = await readSqliteRows(root, 'select path, blob, model, dims, vec from embeddings order by path');
472
+ const { rows: raw, error } = await readSqliteRows(
473
+ root,
474
+ `select substr(n.key, 6) as path, s.revision as blob, v.model as model, v.dims as dims, v.vec as vec
475
+ from vectors v join nodes n on n.id = v.node_id
476
+ join sources s on s.id = n.source_id and s.kind = 'blob' order by path`,
477
+ );
431
478
  const rows = raw.map((row) => {
432
479
  const vec = decodeVectorBlob(row.vec);
433
480
  return {
@@ -500,11 +547,15 @@ export async function searchFtsBm25(root, task, opts = {}) {
500
547
  async function ftsBranch(root, table, terms) {
501
548
  if (terms.length === 0) return { rows: [], error: null };
502
549
  const matchExpr = terms.map((t) => `"${t}"`).join(' OR ');
503
- const { rows, error } = await readSqliteRows(
504
- root,
505
- `select kind, ref as path, -bm25(${table}) as score from ${table} where ${table} match ? order by score desc, path asc limit ?`,
506
- [matchExpr, FTS_LIMIT],
507
- );
550
+ // `fts` is the v5 node-keyed table: the path comes back through the node key.
551
+ // `fts_tri` keeps its v4 (kind, ref, body) shape for the CJK trigram path (Q43),
552
+ // so it is queried the old way.
553
+ const sql = table === 'fts'
554
+ ? `select f.kind as kind, substr(n.key, 6) as path, -bm25(f) as score
555
+ from fts f join nodes n on n.id = f.node_id
556
+ where f match ? order by score desc, path asc limit ?`
557
+ : `select kind, ref as path, -bm25(${table}) as score from ${table} where ${table} match ? order by score desc, path asc limit ?`;
558
+ const { rows, error } = await readSqliteRows(root, sql, [matchExpr, FTS_LIMIT]);
508
559
  return {
509
560
  rows: rows.map((row) => ({ kind: row.kind, path: row.path, score: Number(row.score) || 0 })),
510
561
  error,
@@ -590,20 +641,10 @@ export async function packageOf(root, filePath, cache) {
590
641
  }
591
642
  }
592
643
 
593
- function git(root, args, input) {
594
- return new Promise((resolve, reject) => {
595
- const child = execFile('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
596
- if (error) reject(Object.assign(error, { stdout, stderr }));
597
- else resolve(stdout);
598
- });
599
- if (input !== undefined) {
600
- child.stdin.end(input);
601
- }
602
- });
603
- }
644
+
604
645
 
605
646
  async function lsFiles(root) {
606
- const stdout = await git(root, ['ls-files', '-s', '-z']);
647
+ const stdout = await gitRaw(root, ['ls-files', '-s', '-z']);
607
648
  const rows = [];
608
649
  for (const rec of stdout.split('\0')) {
609
650
  if (rec === '') continue;
@@ -738,7 +779,9 @@ async function blobSizes(root, blobs) {
738
779
  const unique = [...new Set(blobs)];
739
780
  const sizes = new Map();
740
781
  if (unique.length === 0) return sizes;
741
- const stdout = await git(root, ['cat-file', '--batch-check=%(objectname) %(objectsize)'], `${unique.join('\n')}\n`);
782
+ const stdout = await gitRaw(root, ['cat-file', '--batch-check=%(objectname) %(objectsize)'], {
783
+ input: `${unique.join('\n')}\n`,
784
+ });
742
785
  for (const line of stdout.split('\n')) {
743
786
  if (line === '') continue;
744
787
  const sp = line.indexOf(' ');
@@ -6,8 +6,9 @@
6
6
  // the tree is not a guess. Co-change is a different kind: files that land in
7
7
  // the same commit are not, by that fact, a test.
8
8
 
9
- import { isTestPath } from '../bench/mine.mjs';
9
+ import { isTestPath } from '../testing/paths.mjs';
10
10
  import { extract, resolveImports } from './extract.mjs';
11
+ import { cmp } from '../util/sort.mjs';
11
12
 
12
13
  export { scriptSpecifiers } from './extract.mjs';
13
14
 
@@ -251,11 +252,6 @@ function byEdge(a, b) {
251
252
  return cmp(a.kind, b.kind) || cmp(a.src, b.src) || cmp(a.dst, b.dst);
252
253
  }
253
254
 
254
- function cmp(a, b) {
255
- if (a < b) return -1;
256
- if (a > b) return 1;
257
- return 0;
258
- }
259
255
 
260
256
  /** Imports in a file, resolved against the tracked tree. One path now: `extract`
261
257
  * reads scripts with `scriptSpecifiers`, so gate, impact and the index agree on