simt-dev 0.6.3 → 0.7.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 (137) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/agents/simt-agent.md +84 -0
  4. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  5. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  6. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  7. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  8. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  9. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  10. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  11. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  12. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  13. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  14. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  15. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  16. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  17. package/docs/decisions/README.md +9 -0
  18. package/package.json +4 -2
  19. package/rules/core.md +2 -2
  20. package/rules/mdc/test-gate.mdc +2 -0
  21. package/rules/stacks/android.md +3 -3
  22. package/rules/stacks/connectiq.md +2 -2
  23. package/rules/stacks/dart.md +1 -1
  24. package/rules/stacks/deno.md +1 -1
  25. package/rules/stacks/dotnet.md +1 -1
  26. package/rules/stacks/elixir.md +1 -1
  27. package/rules/stacks/go.md +1 -1
  28. package/rules/stacks/jvm.md +3 -2
  29. package/rules/stacks/node.md +2 -2
  30. package/rules/stacks/python.md +7 -6
  31. package/rules/stacks/ruby.md +1 -1
  32. package/rules/stacks/rust.md +1 -1
  33. package/rules/stacks/swift.md +1 -1
  34. package/rules/stacks/typescript.md +2 -2
  35. package/rules/stacks/uv.md +3 -2
  36. package/skills/simt-commands/SKILL.md +70 -0
  37. package/src/advice/advice.mjs +7 -3
  38. package/src/agenthooks/decide.mjs +141 -9
  39. package/src/agenthooks/opencode.mjs +72 -0
  40. package/src/agenthooks/prompt.mjs +6 -2
  41. package/src/agenthooks/render.mjs +186 -19
  42. package/src/agenthooks/shim.mjs +55 -0
  43. package/src/agents/render.mjs +277 -0
  44. package/src/argv.mjs +8 -0
  45. package/src/bench/ablate.mjs +31 -14
  46. package/src/bench/mine.mjs +48 -1
  47. package/src/bench/probes.mjs +5 -6
  48. package/src/bench/report.mjs +243 -80
  49. package/src/bench/run.mjs +12 -11
  50. package/src/bench/variants.mjs +112 -40
  51. package/src/brief/assemble.mjs +6 -0
  52. package/src/ci/workflow.mjs +16 -7
  53. package/src/commands/agent.mjs +206 -0
  54. package/src/commands/agenthook.mjs +109 -15
  55. package/src/commands/bench.mjs +2 -1
  56. package/src/commands/compare.mjs +8 -7
  57. package/src/commands/decide.mjs +6 -5
  58. package/src/commands/evidence.mjs +13 -3
  59. package/src/commands/gate.mjs +85 -19
  60. package/src/commands/graph.mjs +18 -5
  61. package/src/commands/help.mjs +35 -14
  62. package/src/commands/hooks.mjs +68 -25
  63. package/src/commands/impact.mjs +4 -2
  64. package/src/commands/index.mjs +17 -4
  65. package/src/commands/init.mjs +169 -37
  66. package/src/commands/lease.mjs +203 -35
  67. package/src/commands/lessons.mjs +65 -9
  68. package/src/commands/map.mjs +33 -18
  69. package/src/commands/plugin.mjs +1 -1
  70. package/src/commands/repoindex.mjs +1 -0
  71. package/src/commands/rules.mjs +92 -18
  72. package/src/commands/skills.mjs +60 -25
  73. package/src/commands/skillscompile.mjs +8 -7
  74. package/src/commands/skillsinstall.mjs +12 -0
  75. package/src/commands/slice.mjs +377 -18
  76. package/src/commands/sync.mjs +37 -22
  77. package/src/commands/tests.mjs +5 -2
  78. package/src/commands/undo.mjs +24 -16
  79. package/src/commands/upgrade.mjs +33 -1
  80. package/src/commands/why.mjs +10 -4
  81. package/src/compare/hooksstate.mjs +23 -1
  82. package/src/compare/learn.mjs +82 -57
  83. package/src/compare/parallel.mjs +50 -0
  84. package/src/compare/row.mjs +26 -1
  85. package/src/completions.mjs +5 -0
  86. package/src/decisions/dir.mjs +84 -0
  87. package/src/decisions/index.mjs +109 -11
  88. package/src/distill/distill.mjs +3 -1
  89. package/src/ecosystem/node-runner.mjs +144 -0
  90. package/src/explain.mjs +18 -6
  91. package/src/graph/load.mjs +38 -19
  92. package/src/hooks/capability.mjs +14 -1
  93. package/src/hooks/dispatcher.mjs +335 -13
  94. package/src/hooks/resolve.mjs +9 -3
  95. package/src/hooks/shim.mjs +12 -7
  96. package/src/identity.mjs +67 -14
  97. package/src/index/extract.mjs +192 -11
  98. package/src/index/persist.mjs +307 -47
  99. package/src/index/store.mjs +263 -60
  100. package/src/index/tests.mjs +6 -35
  101. package/src/journal.mjs +296 -77
  102. package/src/kernel/card.mjs +37 -12
  103. package/src/kernel/checks.mjs +44 -9
  104. package/src/kernel/variants.mjs +27 -16
  105. package/src/lease.mjs +97 -15
  106. package/src/ledger/index.mjs +91 -25
  107. package/src/mcp/resources.mjs +5 -3
  108. package/src/mcp/server.mjs +81 -22
  109. package/src/mcp/tools.mjs +45 -9
  110. package/src/memory/decide.mjs +98 -43
  111. package/src/memory/lessons.mjs +168 -33
  112. package/src/name.mjs +9 -0
  113. package/src/plan/index.mjs +31 -8
  114. package/src/plan/template.mjs +27 -5
  115. package/src/plugins.mjs +6 -2
  116. package/src/prbody.mjs +7 -5
  117. package/src/projection/baselines.mjs +1 -1
  118. package/src/projection/disclosure.mjs +2 -2
  119. package/src/projection/loaders.mjs +41 -1
  120. package/src/projection/lock.mjs +21 -14
  121. package/src/projection/manifest.mjs +69 -19
  122. package/src/projection/markers.mjs +43 -3
  123. package/src/projection/mdc.mjs +34 -7
  124. package/src/projection/project.mjs +162 -18
  125. package/src/projection/rules.mjs +54 -3
  126. package/src/projection/surfaces.mjs +9 -1
  127. package/src/repo-root.mjs +69 -0
  128. package/src/resolve/body.mjs +4 -0
  129. package/src/resolve/fingerprint.mjs +5 -0
  130. package/src/resolve/local.mjs +58 -0
  131. package/src/review/critic.mjs +4 -2
  132. package/src/secrets.mjs +165 -22
  133. package/src/state.mjs +76 -0
  134. package/src/status.mjs +9 -4
  135. package/src/toolwords.mjs +5 -0
  136. package/src/tsconfig.mjs +39 -99
  137. package/templates/plan/tracker.json +1 -1
@@ -13,6 +13,8 @@ import { readLocalConfig } from '../local/config.mjs';
13
13
  import { localEmbed } from '../local/client.mjs';
14
14
  import { extract, resolveImports } from './extract.mjs';
15
15
  import {
16
+ FTS_SCHEMA,
17
+ FTS_TRI_SCHEMA,
16
18
  JSONL_FILE,
17
19
  SCHEMA_V,
18
20
  SQLITE_FILE,
@@ -22,6 +24,8 @@ import {
22
24
  readJsonl,
23
25
  readSchema,
24
26
  readSqliteRows,
27
+ readTreeDigest,
28
+ treeDigest,
25
29
  writeJsonl,
26
30
  writeSqlite,
27
31
  } from './persist.mjs';
@@ -41,7 +45,8 @@ async function probeBackend() {
41
45
  try {
42
46
  const { DatabaseSync } = await import('node:sqlite');
43
47
  const db = new DatabaseSync(':memory:');
44
- db.exec('create virtual table fts using fts5(kind, ref, body)');
48
+ db.exec(FTS_SCHEMA);
49
+ db.exec(FTS_TRI_SCHEMA);
45
50
  db.close();
46
51
  return 'sqlite';
47
52
  } catch {
@@ -57,29 +62,123 @@ export function ftsKindOf(filePath, lang = '') {
57
62
  return 'docs';
58
63
  }
59
64
 
60
- export function buildFtsBody(filePath, symbolsForPath = [], text = null) {
61
- const tokens = [];
62
- for (const part of String(filePath ?? '').toLowerCase().split(/[^a-z0-9]+/)) {
63
- if (part.length >= 2) tokens.push(part);
65
+ /**
66
+ * The one fold for the FTS pipeline (review 0003 Q43; sharing it everywhere is
67
+ * Q72's slice, which also owns the trigram decision recorded below).
68
+ *
69
+ * NFKC before NFKD so compatibility variants fold first (half-width katakana
70
+ * to full-width) and combining marks strip after (München -> munchen), then
71
+ * lowercase. That is what FTS5 `unicode61 remove_diacritics 2` does to the
72
+ * same text on the sqlite side: ß stays ß on both (this SQLite build does
73
+ * not fold it), and a run the fold keeps whole matches a run it keeps whole.
74
+ *
75
+ * The strip spares the combining dakuten and handakuten (U+3099 U+309A): NFKD
76
+ * splits ド into ト plus a mark, and dropping that mark would voice every
77
+ * がぎぐげご to かきくけこ. unicode61 never strips them either, so the final
78
+ * NFKC recomposes what the strip spared.
79
+ */
80
+ function foldText(text) {
81
+ return String(text ?? '')
82
+ .normalize('NFKC')
83
+ .normalize('NFKD')
84
+ .replace(/\p{M}/gu, (mark) => (mark === '゙' || mark === '゚' ? mark : ''))
85
+ .normalize('NFKC')
86
+ .toLowerCase();
87
+ }
88
+
89
+ const FOLD_RUN = /[\p{L}\p{N}]+/gu;
90
+ const CJK_SEG = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+|[^\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+/gu;
91
+ const CJK_CHAR = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
92
+
93
+ /** Whether a token is a single CJK character (kept at any length). */
94
+ function isCjkToken(token) {
95
+ return typeof token === 'string' && [...token].length === 1 && CJK_CHAR.test(token);
96
+ }
97
+
98
+ /** Maximal CJK runs in raw text: the substrings the trigram table resolves. */
99
+ function cjkRuns(text) {
100
+ return [...new Set(String(text ?? '').match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+/gu) ?? [])];
101
+ }
102
+
103
+ /**
104
+ * The one tokenizer for the FTS pipeline: index side, task side, both backends.
105
+ *
106
+ * Non-CJK runs keep the caller's floor; CJK runs emit single characters
107
+ * because unicode61 has no CJK segmentation - a run kept whole here would
108
+ * never match there. CamelCase is deliberately not split: `magictoken` in a
109
+ * task matches `magicToken` in a body because both sides only lowercase.
110
+ */
111
+ export function tokenizeText(text, { min = 2 } = {}) {
112
+ const out = [];
113
+ for (const run of foldText(text).match(FOLD_RUN) ?? []) {
114
+ for (const seg of run.match(CJK_SEG) ?? []) {
115
+ if (CJK_CHAR.test(seg[0])) {
116
+ for (const ch of seg) out.push(ch);
117
+ } else if (seg.length >= min) {
118
+ out.push(seg);
119
+ }
120
+ }
64
121
  }
122
+ return out;
123
+ }
124
+
125
+ /** Line-aligned sections: whole documents stay indexed, and a term deep in a
126
+ * long file matches its own section instead of falling past a byte cap. */
127
+ const SECTION_CHARS = 8000;
128
+
129
+ function splitSections(text) {
130
+ const out = [];
131
+ let cur = '';
132
+ for (const line of String(text).split('\n')) {
133
+ if (cur.length > 0 && cur.length + 1 + line.length > SECTION_CHARS) {
134
+ out.push(cur);
135
+ cur = '';
136
+ }
137
+ cur = cur.length === 0 ? line : `${cur}\n${line}`;
138
+ }
139
+ if (cur.length > 0) out.push(cur);
140
+ return out;
141
+ }
142
+
143
+ function hasCjk(text) {
144
+ return CJK_CHAR.test(String(text ?? ''));
145
+ }
146
+
147
+ /**
148
+ * One FTS row per section, `{ kind, ref, body }`, with `raw` (folded section
149
+ * text for the trigram table) on CJK-bearing sections only. Symbol names and
150
+ * signatures ride on the first section: they describe the file, not the
151
+ * section, and repeating them would weight long files by section count. Path
152
+ * tokens are gone on purpose - kind and ref are UNINDEXED now, and file-name
153
+ * matching is the resolver signal in scoreFiles, not a body term that matches
154
+ * every row of that kind.
155
+ */
156
+ export function buildFtsBodies(filePath, symbolsForPath = [], text = null) {
157
+ if (typeof text !== 'string' || text.length === 0) return [];
158
+ const kind = ftsKindOf(filePath, langOf(filePath));
159
+ const symTokens = [];
65
160
  for (const s of symbolsForPath) {
66
- if (s?.name) tokens.push(s.name, s.name);
67
- if (s?.sig) tokens.push(s.sig);
161
+ if (s?.name) symTokens.push(s.name, s.name);
162
+ if (s?.sig) symTokens.push(s.sig);
68
163
  }
69
- if (typeof text === 'string' && text.length > 0) {
70
- const head = text.slice(0, 24000);
71
- const seen = new Map();
72
- for (const m of head.matchAll(/[A-Za-z][A-Za-z0-9_-]{2,}/g)) {
73
- const w = m[0].toLowerCase();
74
- const c = (seen.get(w) ?? 0) + 1;
75
- if (c <= 6) {
76
- seen.set(w, c);
77
- tokens.push(w);
78
- }
79
- if (seen.size >= 600) break;
80
- }
164
+ const head = tokenizeText(symTokens.join('\n'), { min: 1 }).join(' ');
165
+ const rows = [];
166
+ for (const section of splitSections(text)) {
167
+ const body = (rows.length === 0 && head.length > 0
168
+ ? `${head} ${tokenizeText(section).join(' ')}`
169
+ : tokenizeText(section).join(' ')
170
+ ).trim();
171
+ if (body.length === 0) continue;
172
+ const row = { kind, ref: filePath, body };
173
+ if (hasCjk(section)) row.raw = foldText(section);
174
+ rows.push(row);
81
175
  }
82
- return tokens.join(' ');
176
+ return rows;
177
+ }
178
+
179
+ export function buildFtsBody(filePath, symbolsForPath = [], text = null) {
180
+ const rows = buildFtsBodies(filePath, symbolsForPath, text);
181
+ return rows.length > 0 ? rows[0].body : '';
83
182
  }
84
183
 
85
184
  const FTS_STOP = new Set([
@@ -92,14 +191,13 @@ const FTS_STOP = new Set([
92
191
  ]);
93
192
 
94
193
  export function ftsQueryTerms(task) {
95
- return [
96
- ...new Set(
97
- String(task ?? '')
98
- .toLowerCase()
99
- .split(/[^a-z0-9]+/)
100
- .filter((w) => w.length >= 3 && !FTS_STOP.has(w)),
101
- ),
102
- ].slice(0, 48);
194
+ const seen = new Set();
195
+ for (const tok of tokenizeText(task, { min: 1 })) {
196
+ if (seen.has(tok)) continue;
197
+ if (!isCjkToken(tok) && (tok.length < 3 || FTS_STOP.has(tok))) continue;
198
+ seen.add(tok);
199
+ }
200
+ return [...seen].slice(0, 48);
103
201
  }
104
202
 
105
203
  /**
@@ -133,6 +231,7 @@ export async function reindex(root, opts = {}) {
133
231
  const symbols = [];
134
232
  const edges = [];
135
233
  const fts = [];
234
+ let skipped = 0;
136
235
  for (const entry of changed) {
137
236
  const lang = langOf(entry.path);
138
237
  next.push({
@@ -148,12 +247,16 @@ export async function reindex(root, opts = {}) {
148
247
  : await runExtract(entry.path, text, lang, adapter.module);
149
248
  symbols.push(...pulled.symbols);
150
249
  edges.push(...(pulled.edges ?? resolveImports(entry.path, pulled.imports, paths, lang)));
151
- const body = buildFtsBody(entry.path, pulled.symbols, text);
152
- if (body.length > 0) {
153
- fts.push({ kind: ftsKindOf(entry.path, lang), ref: entry.path, body });
250
+ // A null blob is binary: recorded in `skipped`, never tokenized.
251
+ if (text == null) {
252
+ skipped += 1;
253
+ continue;
154
254
  }
255
+ fts.push(...buildFtsBodies(entry.path, pulled.symbols, text));
155
256
  }
156
257
  const removed = previous.filter((row) => !paths.has(row.path)).map((row) => row.path);
258
+ // The tree this build vouches for, stored beside the rows so a later read
259
+ // can compare digests instead of re-reading blobs (review 0003 Q42).
157
260
  const embedBuild = await buildEmbeddings(root, listed, fts, {
158
261
  backend,
159
262
  force,
@@ -161,12 +264,13 @@ export async function reindex(root, opts = {}) {
161
264
  allowRemote: opts.allowRemote === true,
162
265
  fetchImpl: opts.fetchImpl,
163
266
  });
267
+ const tree = treeDigest(listed);
164
268
  let stored;
165
269
  if (backend === 'sqlite') {
166
- await writeSqlite(root, next, removed, symbols, edges, fts, embedBuild.rows);
270
+ await writeSqlite(root, next, removed, symbols, edges, fts, embedBuild.rows, tree);
167
271
  stored = await countSqlite(root);
168
272
  } else {
169
- stored = await writeJsonl(root, previous, next, removed, symbols, edges, fts, force, embedBuild.rows);
273
+ stored = await writeJsonl(root, previous, next, removed, symbols, edges, fts, force, embedBuild.rows, tree);
170
274
  }
171
275
  return {
172
276
  backend,
@@ -178,10 +282,35 @@ export async function reindex(root, opts = {}) {
178
282
  embeddings: stored.embeddings ?? 0,
179
283
  embedState: embedBuild.state,
180
284
  parsers: adapter.state,
285
+ skipped,
181
286
  ms: Date.now() - started,
182
287
  };
183
288
  }
184
289
 
290
+ /**
291
+ * Whether the stored index predates the current tree (review 0003 Q42).
292
+ *
293
+ * One `git ls-files -s` and one meta read: no blob is re-read, so the check
294
+ * costs ~100 ms and every read path can afford it. False when no build has
295
+ * vouched for a tree (that is cold, not stale) and when git cannot answer -
296
+ * a cache of blobs is still a cache when the worktree tool is missing, and
297
+ * refusing to serve it would turn every offline read into an error.
298
+ *
299
+ * @returns {Promise<boolean>}
300
+ */
301
+ export async function isIndexStale(root, opts = {}) {
302
+ const backend = opts.backend ?? await probeBackend();
303
+ const stored = await readTreeDigest(root, backend);
304
+ if (!stored) return false;
305
+ let listed;
306
+ try {
307
+ listed = await lsFiles(root);
308
+ } catch {
309
+ return false;
310
+ }
311
+ return treeDigest(listed) !== stored;
312
+ }
313
+
185
314
  async function buildEmbeddings(root, listed, changedFts, { backend, force, embed, allowRemote, fetchImpl }) {
186
315
  if (!embed) return { state: 'off', rows: [] };
187
316
  const localCfg = await readLocalConfig(root, { allowRemote });
@@ -190,7 +319,11 @@ async function buildEmbeddings(root, listed, changedFts, { backend, force, embed
190
319
 
191
320
  const existingEmbeds = force ? [] : await readEmbeddings(root, { backend });
192
321
  const embedByPath = new Map(existingEmbeds.map((row) => [row.path, row]));
193
- const ftsByPath = new Map(changedFts.map((row) => [row.ref, row.body]));
322
+ // Sections share a ref; the embedding input is the file's first section.
323
+ const ftsByPath = new Map();
324
+ for (const row of changedFts) {
325
+ if (!ftsByPath.has(row.ref)) ftsByPath.set(row.ref, row.body);
326
+ }
194
327
  const missingEntries = [];
195
328
  for (const entry of listed) {
196
329
  const prev = embedByPath.get(entry.path);
@@ -236,40 +369,66 @@ async function buildEmbeddings(root, listed, changedFts, { backend, force, embed
236
369
  return { state: 'embedded', rows };
237
370
  }
238
371
 
372
+ /**
373
+ * Read wrappers return arrays with the read error attached (review 0003 Q44):
374
+ * callers that iterate are untouched, and callers that report - map among
375
+ * them - can tell a broken index from an empty one.
376
+ */
377
+ function withReadError(rows, error) {
378
+ if (error) rows.error = error;
379
+ return rows;
380
+ }
381
+
239
382
  /** File rows, sorted by path. The same blobs read back as the same rows. */
240
383
  export async function readFiles(root, opts = {}) {
241
384
  const backend = opts.backend ?? await probeBackend();
242
- if (backend === 'sqlite') return readSqliteRows(root, 'select path, blob, lang, bytes, pkg from files order by path');
243
- return readJsonl(root, 'files');
385
+ if (backend === 'sqlite') {
386
+ const { rows, error } = await readSqliteRows(root, 'select path, blob, lang, bytes, pkg from files order by path');
387
+ return withReadError(rows, error);
388
+ }
389
+ const { rows, error } = await readJsonl(root, 'files');
390
+ return withReadError(rows, error);
244
391
  }
245
392
 
246
393
  /** Symbol rows for the blobs currently indexed. */
247
394
  export async function readSymbols(root, opts = {}) {
248
395
  const backend = opts.backend ?? await probeBackend();
249
- if (backend === 'sqlite') return readSqliteRows(root, 'select path, name, kind, line, sig, exported from symbols order by path, line, name, kind');
250
- return readJsonl(root, 'symbols');
396
+ 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');
398
+ return withReadError(rows, error);
399
+ }
400
+ const { rows, error } = await readJsonl(root, 'symbols');
401
+ return withReadError(rows, error);
251
402
  }
252
403
 
253
404
  /** Edges currently indexed. An import edge's src is the file that imports. */
254
405
  export async function readEdges(root, opts = {}) {
255
406
  const backend = opts.backend ?? await probeBackend();
256
- if (backend === 'sqlite') return readSqliteRows(root, 'select src, dst, kind, weight from edges order by src, dst, kind');
257
- return readJsonl(root, 'edges');
407
+ if (backend === 'sqlite') {
408
+ const { rows, error } = await readSqliteRows(root, 'select src, dst, kind, weight from edges order by src, dst, kind');
409
+ return withReadError(rows, error);
410
+ }
411
+ const { rows, error } = await readJsonl(root, 'edges');
412
+ return withReadError(rows, error);
258
413
  }
259
414
 
260
- /** FTS rows currently indexed. */
415
+ /** FTS rows currently indexed: one row per section, first section first. */
261
416
  export async function readFts(root, opts = {}) {
262
417
  const backend = opts.backend ?? await probeBackend();
263
- if (backend === 'sqlite') return readSqliteRows(root, 'select kind, ref, body from fts order by ref, kind');
264
- return readJsonl(root, 'fts');
418
+ if (backend === 'sqlite') {
419
+ const { rows, error } = await readSqliteRows(root, 'select kind, ref, body from fts order by ref, kind, rowid');
420
+ return withReadError(rows, error);
421
+ }
422
+ const { rows, error } = await readJsonl(root, 'fts');
423
+ return withReadError(rows, error);
265
424
  }
266
425
 
267
426
  /** Stored vector embeddings, decoded as Float32Array per path. */
268
427
  export async function readEmbeddings(root, opts = {}) {
269
428
  const backend = opts.backend ?? await probeBackend();
270
429
  if (backend === 'sqlite') {
271
- const raw = await readSqliteRows(root, 'select path, blob, model, dims, vec from embeddings order by path');
272
- return raw.map((row) => {
430
+ const { rows: raw, error } = await readSqliteRows(root, 'select path, blob, model, dims, vec from embeddings order by path');
431
+ const rows = raw.map((row) => {
273
432
  const vec = decodeVectorBlob(row.vec);
274
433
  return {
275
434
  path: row.path,
@@ -279,8 +438,22 @@ export async function readEmbeddings(root, opts = {}) {
279
438
  vec,
280
439
  };
281
440
  });
441
+ return withReadError(rows, error);
442
+ }
443
+ const { rows, error } = await readJsonl(root, 'embeddings');
444
+ return withReadError(rows, error);
445
+ }
446
+
447
+ /** Result rows per query branch: many sections share a path. */
448
+ const FTS_LIMIT = 200;
449
+
450
+ function dedupePaths(rows) {
451
+ const best = new Map();
452
+ for (const row of rows) {
453
+ const prev = best.get(row.path);
454
+ if (!prev || row.score > prev.score) best.set(row.path, row);
282
455
  }
283
- return readJsonl(root, 'embeddings');
456
+ return [...best.values()].sort((a, b) => b.score - a.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
284
457
  }
285
458
 
286
459
  /**
@@ -288,24 +461,54 @@ export async function readEmbeddings(root, opts = {}) {
288
461
  *
289
462
  * Uses SQLite FTS5 `bm25(fts)` when the sqlite backend is active on disk, and
290
463
  * falls back to an identical Okapi BM25 computation over `fts` rows otherwise.
464
+ * A query with CJK also consults the trigram table - unicode61 has no CJK
465
+ * segmentation, so those runs are unqueryable there - and a mixed query
466
+ * unions both tables, trigram hits first, because an ASCII-only table cannot
467
+ * hold a CJK run and a trigram table cannot hold an ASCII-only file.
291
468
  */
292
469
  export async function searchFtsBm25(root, task, opts = {}) {
293
470
  const terms = ftsQueryTerms(task);
294
471
  if (terms.length === 0) return [];
295
472
  const backend = opts.backend ?? await probeBackend();
296
473
  if (backend === 'sqlite') {
297
- const matchExpr = terms.map((t) => `"${t}"`).join(' OR ');
298
- const rows = await readSqliteRows(
299
- root,
300
- 'select kind, ref as path, -bm25(fts) as score from fts where fts match ? order by score desc, path asc',
301
- [matchExpr],
302
- );
303
- if (rows.length > 0) {
304
- return rows.map((row) => ({ kind: row.kind, path: row.path, score: Number(row.score) || 0 }));
474
+ const ascii = terms.filter((t) => !isCjkToken(t));
475
+ const runs = [...new Set(cjkRuns(foldText(task)))];
476
+ const hits = [];
477
+ let error = null;
478
+ if (runs.length > 0) {
479
+ const tri = await ftsBranch(root, 'fts_tri', [...runs, ...ascii]);
480
+ hits.push(...tri.rows);
481
+ error = tri.error;
482
+ }
483
+ if (ascii.length > 0) {
484
+ const seen = new Set(hits.map((row) => row.path));
485
+ const fts = await ftsBranch(root, 'fts', ascii);
486
+ for (const row of fts.rows) {
487
+ if (!seen.has(row.path)) {
488
+ seen.add(row.path);
489
+ hits.push(row);
490
+ }
491
+ }
492
+ error = error ?? fts.error;
305
493
  }
494
+ if (hits.length > 0) return withReadError(dedupePaths(hits).slice(0, FTS_LIMIT), error);
306
495
  }
307
496
  const ftsRows = opts.fts ?? await readFts(root, { backend });
308
- return scoreFtsBm25(ftsRows, task);
497
+ return withReadError(scoreFtsBm25(ftsRows, task).slice(0, FTS_LIMIT), ftsRows.error);
498
+ }
499
+
500
+ async function ftsBranch(root, table, terms) {
501
+ if (terms.length === 0) return { rows: [], error: null };
502
+ 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
+ );
508
+ return {
509
+ rows: rows.map((row) => ({ kind: row.kind, path: row.path, score: Number(row.score) || 0 })),
510
+ error,
511
+ };
309
512
  }
310
513
 
311
514
  /**
@@ -319,10 +522,9 @@ export function scoreFtsBm25(ftsRows, task) {
319
522
  const N = ftsRows.length;
320
523
  const docs = ftsRows.map((row) => {
321
524
  const path = row.ref ?? row.path ?? '';
322
- const words = `${row.kind ?? ''} ${path} ${row.body ?? ''}`
323
- .toLowerCase()
324
- .split(/[^a-z0-9_]+/)
325
- .filter(Boolean);
525
+ // Body only: kind and ref are UNINDEXED on the sqlite side, so scoring
526
+ // them here would rank what the other backend cannot see.
527
+ const words = tokenizeText(row.body ?? '', { min: 1 });
326
528
  const tf = new Map();
327
529
  for (const w of words) tf.set(w, (tf.get(w) ?? 0) + 1);
328
530
  return { kind: row.kind ?? 'code', path, len: words.length, tf };
@@ -346,7 +548,8 @@ export function scoreFtsBm25(ftsRows, task) {
346
548
  }
347
549
  if (score > 0) out.push({ kind: d.kind, path: d.path, score });
348
550
  }
349
- return out.sort((a, b) => b.score - a.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
551
+ // Sections share a path; the file keeps its best section.
552
+ return dedupePaths(out);
350
553
  }
351
554
 
352
555
  export function langOf(filePath) {
@@ -9,6 +9,8 @@
9
9
  import { isTestPath } from '../bench/mine.mjs';
10
10
  import { extract, resolveImports } from './extract.mjs';
11
11
 
12
+ export { scriptSpecifiers } from './extract.mjs';
13
+
12
14
  const HISTORY = 200;
13
15
  const COCHANGE_MIN = 2;
14
16
  const COCHANGE_CAP = 8;
@@ -59,38 +61,6 @@ export function testsFor(source, edges, { cochange = false } = {}) {
59
61
  return found;
60
62
  }
61
63
 
62
- /** Import specifiers in a script, including an import that spans lines. */
63
- export function scriptSpecifiers(text) {
64
- const specs = [];
65
- const lines = String(text).split('\n');
66
- let buf = '';
67
- let open = false;
68
- for (const line of lines) {
69
- const trimmed = line.trim();
70
- if (!open) {
71
- if (!/^(?:import|export)\b/.test(trimmed)) continue;
72
- // A declaration is not an import. Capturing it would swallow the next
73
- // import's `from` and report a specifier the file does not have.
74
- if (/^export\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\b/.test(trimmed) && !trimmed.includes(' from ')) continue;
75
- buf = trimmed;
76
- open = true;
77
- } else {
78
- buf += `\n${trimmed}`;
79
- }
80
- const done = /\bfrom\s+['"][^'"]+['"]/.test(buf) || /^import\s+['"][^'"]+['"]/.test(buf) || buf.includes(';') || buf.length > 2000;
81
- if (!done) continue;
82
- const from = buf.match(/\bfrom\s+['"]([^'"]+)['"]/);
83
- const side = buf.match(/^import\s+['"]([^'"]+)['"]/);
84
- if (from) specs.push(from[1]);
85
- else if (side) specs.push(side[1]);
86
- buf = '';
87
- open = false;
88
- }
89
- for (const match of String(text).matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g)) specs.push(match[1]);
90
- for (const match of String(text).matchAll(/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g)) specs.push(match[1]);
91
- return [...new Set(specs)];
92
- }
93
-
94
64
  export function parseCoverage(text) {
95
65
  const body = String(text).replace(/^\uFEFF/, '');
96
66
  const start = body.trimStart();
@@ -287,8 +257,9 @@ function cmp(a, b) {
287
257
  return 0;
288
258
  }
289
259
 
290
- /** Relative imports in a test file, resolved against the tracked tree. */
260
+ /** Imports in a file, resolved against the tracked tree. One path now: `extract`
261
+ * reads scripts with `scriptSpecifiers`, so gate, impact and the index agree on
262
+ * what a file imports (review 0003 Q41). */
291
263
  export function importsOf(testPath, text, lang, files) {
292
- const specs = lang === 'javascript' || lang === 'typescript' ? scriptSpecifiers(text) : extract(testPath, text, lang).imports;
293
- return resolveImports(testPath, specs, files, lang);
264
+ return resolveImports(testPath, extract(testPath, text, lang).imports, files, lang);
294
265
  }