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.
- package/CHANGELOG.md +35 -0
- package/README.md +2 -2
- package/agents/simt-agent.md +84 -0
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- 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
- 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
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- 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
- 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
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/README.md +9 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +8 -0
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +109 -15
- package/src/commands/bench.mjs +2 -1
- package/src/commands/compare.mjs +8 -7
- package/src/commands/decide.mjs +6 -5
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +35 -14
- package/src/commands/hooks.mjs +68 -25
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +17 -4
- package/src/commands/init.mjs +169 -37
- package/src/commands/lease.mjs +203 -35
- package/src/commands/lessons.mjs +65 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/plugin.mjs +1 -1
- package/src/commands/repoindex.mjs +1 -0
- package/src/commands/rules.mjs +92 -18
- package/src/commands/skills.mjs +60 -25
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +12 -0
- package/src/commands/slice.mjs +377 -18
- package/src/commands/sync.mjs +37 -22
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +24 -16
- package/src/commands/upgrade.mjs +33 -1
- package/src/commands/why.mjs +10 -4
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -1
- package/src/completions.mjs +5 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +38 -19
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +335 -13
- package/src/hooks/resolve.mjs +9 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +296 -77
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +81 -22
- package/src/mcp/tools.mjs +45 -9
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +21 -14
- package/src/projection/manifest.mjs +69 -19
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +54 -3
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
package/src/index/store.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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)
|
|
67
|
-
if (s?.sig)
|
|
161
|
+
if (s?.name) symTokens.push(s.name, s.name);
|
|
162
|
+
if (s?.sig) symTokens.push(s.sig);
|
|
68
163
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
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
|
-
|
|
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')
|
|
243
|
-
|
|
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')
|
|
250
|
-
|
|
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')
|
|
257
|
-
|
|
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')
|
|
264
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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) {
|
package/src/index/tests.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
293
|
-
return resolveImports(testPath, specs, files, lang);
|
|
264
|
+
return resolveImports(testPath, extract(testPath, text, lang).imports, files, lang);
|
|
294
265
|
}
|