simt-dev 0.3.0 → 0.4.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 (91) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +138 -74
  3. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  4. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
  5. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  6. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  7. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  8. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  9. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  10. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  11. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  12. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  13. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  14. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  15. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  16. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  17. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  18. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  19. package/package.json +2 -2
  20. package/rules/core.md +2 -2
  21. package/src/agenthooks/policy.mjs +10 -2
  22. package/src/agenthooks/prompt.mjs +81 -0
  23. package/src/agenthooks/render.mjs +138 -17
  24. package/src/argv.mjs +34 -1
  25. package/src/bench/grade.mjs +3 -0
  26. package/src/bench/run.mjs +85 -39
  27. package/src/bench/variants.mjs +456 -0
  28. package/src/brief/assemble.mjs +346 -0
  29. package/src/brief/kind.mjs +134 -0
  30. package/src/brief/packet.mjs +197 -0
  31. package/src/ci/workflow.mjs +11 -1
  32. package/src/commands/agenthook.mjs +84 -5
  33. package/src/commands/bench.mjs +53 -16
  34. package/src/commands/brief.mjs +78 -0
  35. package/src/commands/compare.mjs +10 -2
  36. package/src/commands/decide.mjs +116 -0
  37. package/src/commands/evidence.mjs +96 -0
  38. package/src/commands/gate.mjs +165 -0
  39. package/src/commands/help.mjs +65 -3
  40. package/src/commands/hooks.mjs +45 -5
  41. package/src/commands/impact.mjs +205 -0
  42. package/src/commands/index.mjs +52 -1
  43. package/src/commands/lessons.mjs +127 -0
  44. package/src/commands/local.mjs +97 -0
  45. package/src/commands/map.mjs +126 -0
  46. package/src/commands/repoindex.mjs +55 -0
  47. package/src/commands/rules.mjs +17 -2
  48. package/src/commands/shared.mjs +398 -0
  49. package/src/commands/skills.mjs +3 -17
  50. package/src/commands/skillscompile.mjs +255 -0
  51. package/src/commands/sync.mjs +87 -3
  52. package/src/commands/tests.mjs +145 -0
  53. package/src/commands/tokens.mjs +30 -1
  54. package/src/commands/why.mjs +172 -0
  55. package/src/compare/learn.mjs +680 -0
  56. package/src/decisions/index.mjs +116 -65
  57. package/src/federation/client.mjs +188 -0
  58. package/src/federation/config.mjs +134 -0
  59. package/src/federation/query.mjs +48 -0
  60. package/src/graph/compile.mjs +475 -0
  61. package/src/graph/spec.mjs +1 -1
  62. package/src/hooks/dispatcher.mjs +15 -0
  63. package/src/hooks/shim.mjs +31 -0
  64. package/src/index/compact.mjs +228 -0
  65. package/src/index/extract.mjs +331 -0
  66. package/src/index/gate.mjs +89 -0
  67. package/src/index/govern.mjs +234 -0
  68. package/src/index/impact.mjs +186 -0
  69. package/src/index/map.mjs +627 -0
  70. package/src/index/persist.mjs +239 -0
  71. package/src/index/store.mjs +515 -0
  72. package/src/index/tests.mjs +294 -0
  73. package/src/kernel/budget.mjs +100 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/variants.mjs +353 -0
  76. package/src/ledger/index.mjs +147 -0
  77. package/src/local/client.mjs +482 -0
  78. package/src/local/config.mjs +234 -0
  79. package/src/mcp/tools.mjs +138 -1
  80. package/src/measure/context.mjs +7 -5
  81. package/src/memory/decide.mjs +462 -0
  82. package/src/memory/lessons.mjs +858 -0
  83. package/src/projection/disclosure.mjs +92 -0
  84. package/src/projection/lock.mjs +125 -0
  85. package/src/projection/manifest.mjs +52 -0
  86. package/src/projection/markers.mjs +56 -17
  87. package/src/projection/project.mjs +24 -17
  88. package/src/projection/rules.mjs +407 -49
  89. package/src/projection/shared.mjs +428 -0
  90. package/src/projection/targets.mjs +2 -1
  91. package/src/toolwords.mjs +8 -0
@@ -0,0 +1,515 @@
1
+ // The local index (ADR-089).
2
+ //
3
+ // `node:sqlite` when this Node has it, including FTS5, and a JSON-lines file
4
+ // otherwise. Either one is a cache: `git ls-files -s` can rebuild it, so a missing
5
+ // file is not a lost database. A blob that has not changed is not read again.
6
+
7
+ import { execFile } from 'node:child_process';
8
+ import { stat } from 'node:fs/promises';
9
+ import { dirname, join } from 'node:path';
10
+
11
+ import { readManifest } from '../projection/manifest.mjs';
12
+ import { readLocalConfig } from '../local/config.mjs';
13
+ import { localEmbed } from '../local/client.mjs';
14
+ import { extract, resolveImports } from './extract.mjs';
15
+ import {
16
+ JSONL_FILE,
17
+ SCHEMA_V,
18
+ SQLITE_FILE,
19
+ countSqlite,
20
+ decodeVectorBlob,
21
+ encodeVectorBlob,
22
+ readJsonl,
23
+ readSchema,
24
+ readSqliteRows,
25
+ writeJsonl,
26
+ writeSqlite,
27
+ } from './persist.mjs';
28
+
29
+ export { JSONL_FILE, SQLITE_FILE, decodeVectorBlob, encodeVectorBlob };
30
+
31
+ const MARKERS = ['package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml'];
32
+
33
+ const LANG = {
34
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
35
+ ts: 'typescript', tsx: 'typescript', kt: 'kotlin', kts: 'kotlin', java: 'java',
36
+ py: 'python', go: 'go', rs: 'rust', swift: 'swift', dart: 'dart', rb: 'ruby', cs: 'csharp',
37
+ };
38
+
39
+ /** `sqlite` when this process can open a database and create an FTS5 table. */
40
+ async function probeBackend() {
41
+ try {
42
+ const { DatabaseSync } = await import('node:sqlite');
43
+ const db = new DatabaseSync(':memory:');
44
+ db.exec('create virtual table fts using fts5(kind, ref, body)');
45
+ db.close();
46
+ return 'sqlite';
47
+ } catch {
48
+ return 'jsonl';
49
+ }
50
+ }
51
+
52
+ export function ftsKindOf(filePath, lang = '') {
53
+ if (filePath.startsWith('docs/decisions/')) return 'decisions';
54
+ if (filePath.startsWith('docs/lessons/')) return 'lessons';
55
+ if (filePath.includes('skills') && (filePath.endsWith('.md') || filePath.endsWith('.json'))) return 'skills';
56
+ if (lang) return 'code';
57
+ return 'docs';
58
+ }
59
+
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);
64
+ }
65
+ for (const s of symbolsForPath) {
66
+ if (s?.name) tokens.push(s.name, s.name);
67
+ if (s?.sig) tokens.push(s.sig);
68
+ }
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
+ }
81
+ }
82
+ return tokens.join(' ');
83
+ }
84
+
85
+ const FTS_STOP = new Set([
86
+ 'this', 'that', 'with', 'from', 'have', 'been', 'into', 'only', 'when', 'what',
87
+ 'which', 'their', 'about', 'after', 'before', 'where', 'there', 'these', 'those',
88
+ 'would', 'could', 'should', 'every', 'other', 'than', 'then', 'them', 'they',
89
+ 'your', 'will', 'does', 'file', 'files', 'test', 'tests', 'line', 'lines', 'code',
90
+ 'also', 'just', 'more', 'some', 'such', 'each', 'same', 'over', 'used', 'using',
91
+ 'the', 'and', 'for', 'not', 'are', 'was', 'were', 'has', 'had', 'but', 'can', 'one', 'two',
92
+ ]);
93
+
94
+ 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);
103
+ }
104
+
105
+ /**
106
+ * Rebuild the index from the git index. Unchanged blobs are left as they are.
107
+ *
108
+ * @param {string} root
109
+ * @param {{backend?: 'sqlite'|'jsonl', embed?: boolean, allowRemote?: boolean, fetchImpl?: typeof fetch}} [opts]
110
+ */
111
+ export async function reindex(root, opts = {}) {
112
+ const started = Date.now();
113
+ const backend = opts.backend ?? await probeBackend();
114
+ const listed = await lsFiles(root);
115
+ const version = await readSchema(root, backend);
116
+ // A cache written before symbols existed has the right blobs and the wrong
117
+ // tables. Re-extract once, then an unchanged blob stays untouched.
118
+ const force = version !== SCHEMA_V;
119
+ const previous = await readFiles(root, { backend });
120
+ const prior = new Map(previous.map((row) => [row.path, row]));
121
+ const cache = new Map();
122
+ const changed = [];
123
+ for (const entry of listed) {
124
+ const old = prior.get(entry.path);
125
+ if (!force && old && old.blob === entry.blob) continue;
126
+ changed.push(entry);
127
+ }
128
+ const sizes = await blobSizes(root, changed.map((entry) => entry.blob));
129
+ const texts = await blobTexts(root, changed.map((entry) => entry.blob));
130
+ const adapter = await resolveAdapter(root, opts);
131
+ const paths = new Set(listed.map((entry) => entry.path));
132
+ const next = [];
133
+ const symbols = [];
134
+ const edges = [];
135
+ const fts = [];
136
+ for (const entry of changed) {
137
+ const lang = langOf(entry.path);
138
+ next.push({
139
+ path: entry.path,
140
+ blob: entry.blob,
141
+ lang,
142
+ bytes: sizes.get(entry.blob) ?? 0,
143
+ pkg: await packageOf(root, entry.path, cache),
144
+ });
145
+ const text = texts.get(entry.blob);
146
+ const pulled = text == null
147
+ ? { symbols: [], imports: [], edges: null }
148
+ : await runExtract(entry.path, text, lang, adapter.module);
149
+ symbols.push(...pulled.symbols);
150
+ 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 });
154
+ }
155
+ }
156
+ const removed = previous.filter((row) => !paths.has(row.path)).map((row) => row.path);
157
+ const embedBuild = await buildEmbeddings(root, listed, fts, {
158
+ backend,
159
+ force,
160
+ embed: opts.embed === true,
161
+ allowRemote: opts.allowRemote === true,
162
+ fetchImpl: opts.fetchImpl,
163
+ });
164
+ let stored;
165
+ if (backend === 'sqlite') {
166
+ await writeSqlite(root, next, removed, symbols, edges, fts, embedBuild.rows);
167
+ stored = await countSqlite(root);
168
+ } else {
169
+ stored = await writeJsonl(root, previous, next, removed, symbols, edges, fts, force, embedBuild.rows);
170
+ }
171
+ return {
172
+ backend,
173
+ files: listed.length,
174
+ updated: next.length,
175
+ removed: removed.length,
176
+ symbols: stored.symbols,
177
+ edges: stored.edges,
178
+ embeddings: stored.embeddings ?? 0,
179
+ embedState: embedBuild.state,
180
+ parsers: adapter.state,
181
+ ms: Date.now() - started,
182
+ };
183
+ }
184
+
185
+ async function buildEmbeddings(root, listed, changedFts, { backend, force, embed, allowRemote, fetchImpl }) {
186
+ if (!embed) return { state: 'off', rows: [] };
187
+ const localCfg = await readLocalConfig(root, { allowRemote });
188
+ if (!localCfg.embed) return { state: 'off', rows: [] };
189
+ if (localCfg.embed.blocked) return { state: 'refused-remote', rows: [] };
190
+
191
+ const existingEmbeds = force ? [] : await readEmbeddings(root, { backend });
192
+ const embedByPath = new Map(existingEmbeds.map((row) => [row.path, row]));
193
+ const ftsByPath = new Map(changedFts.map((row) => [row.ref, row.body]));
194
+ const missingEntries = [];
195
+ for (const entry of listed) {
196
+ const prev = embedByPath.get(entry.path);
197
+ if (prev && prev.blob === entry.blob && prev.model === localCfg.embed.model && prev.vec.length > 0) {
198
+ continue;
199
+ }
200
+ missingEntries.push(entry);
201
+ }
202
+ if (missingEntries.length === 0) return { state: 'cached', rows: [] };
203
+
204
+ // Ensure FTS summaries are available for any entry that did not change in this pass.
205
+ const needFts = missingEntries.some((entry) => !ftsByPath.has(entry.path));
206
+ if (needFts) {
207
+ const storedFts = await readFts(root, { backend });
208
+ for (const row of storedFts) {
209
+ if (!ftsByPath.has(row.ref)) ftsByPath.set(row.ref, row.body);
210
+ }
211
+ }
212
+
213
+ const rows = [];
214
+ const batchSize = 32;
215
+ for (let i = 0; i < missingEntries.length; i += batchSize) {
216
+ const slice = missingEntries.slice(i, i + batchSize);
217
+ const inputs = slice.map((entry) => {
218
+ const summary = ftsByPath.get(entry.path) ?? entry.path;
219
+ return `${entry.path}\n${summary}`.slice(0, 4096);
220
+ });
221
+ const res = await localEmbed(localCfg.embed, { input: inputs, allowRemote, fetchImpl });
222
+ if (!res.ok || res.embeddings.length !== slice.length) {
223
+ return { state: 'failed', rows };
224
+ }
225
+ for (let j = 0; j < slice.length; j += 1) {
226
+ const vec = res.embeddings[j];
227
+ rows.push({
228
+ path: slice[j].path,
229
+ blob: slice[j].blob,
230
+ model: res.model ?? localCfg.embed.model,
231
+ dims: vec.length,
232
+ vec: encodeVectorBlob(vec),
233
+ });
234
+ }
235
+ }
236
+ return { state: 'embedded', rows };
237
+ }
238
+
239
+ /** File rows, sorted by path. The same blobs read back as the same rows. */
240
+ export async function readFiles(root, opts = {}) {
241
+ 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');
244
+ }
245
+
246
+ /** Symbol rows for the blobs currently indexed. */
247
+ export async function readSymbols(root, opts = {}) {
248
+ 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');
251
+ }
252
+
253
+ /** Edges currently indexed. An import edge's src is the file that imports. */
254
+ export async function readEdges(root, opts = {}) {
255
+ 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');
258
+ }
259
+
260
+ /** FTS rows currently indexed. */
261
+ export async function readFts(root, opts = {}) {
262
+ 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');
265
+ }
266
+
267
+ /** Stored vector embeddings, decoded as Float32Array per path. */
268
+ export async function readEmbeddings(root, opts = {}) {
269
+ const backend = opts.backend ?? await probeBackend();
270
+ 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) => {
273
+ const vec = decodeVectorBlob(row.vec);
274
+ return {
275
+ path: row.path,
276
+ blob: row.blob ?? '',
277
+ model: row.model ?? '',
278
+ dims: Number.isInteger(row.dims) ? row.dims : vec.length,
279
+ vec,
280
+ };
281
+ });
282
+ }
283
+ return readJsonl(root, 'embeddings');
284
+ }
285
+
286
+ /**
287
+ * Rank indexed documents against a task string using BM25.
288
+ *
289
+ * Uses SQLite FTS5 `bm25(fts)` when the sqlite backend is active on disk, and
290
+ * falls back to an identical Okapi BM25 computation over `fts` rows otherwise.
291
+ */
292
+ export async function searchFtsBm25(root, task, opts = {}) {
293
+ const terms = ftsQueryTerms(task);
294
+ if (terms.length === 0) return [];
295
+ const backend = opts.backend ?? await probeBackend();
296
+ 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 }));
305
+ }
306
+ }
307
+ const ftsRows = opts.fts ?? await readFts(root, { backend });
308
+ return scoreFtsBm25(ftsRows, task);
309
+ }
310
+
311
+ /**
312
+ * Deterministic Okapi BM25 ranking over FTS rows (`{ kind, ref, body }`).
313
+ */
314
+ export function scoreFtsBm25(ftsRows, task) {
315
+ const terms = ftsQueryTerms(task);
316
+ if (terms.length === 0 || !Array.isArray(ftsRows) || ftsRows.length === 0) return [];
317
+ const k1 = 1.2;
318
+ const b = 0.75;
319
+ const N = ftsRows.length;
320
+ const docs = ftsRows.map((row) => {
321
+ const path = row.ref ?? row.path ?? '';
322
+ const words = `${row.kind ?? ''} ${path} ${row.body ?? ''}`
323
+ .toLowerCase()
324
+ .split(/[^a-z0-9_]+/)
325
+ .filter(Boolean);
326
+ const tf = new Map();
327
+ for (const w of words) tf.set(w, (tf.get(w) ?? 0) + 1);
328
+ return { kind: row.kind ?? 'code', path, len: words.length, tf };
329
+ });
330
+ const avgdl = docs.reduce((sum, d) => sum + d.len, 0) / N || 1;
331
+ const df = new Map();
332
+ for (const term of terms) {
333
+ let n = 0;
334
+ for (const d of docs) if (d.tf.has(term)) n += 1;
335
+ df.set(term, n);
336
+ }
337
+ const out = [];
338
+ for (const d of docs) {
339
+ let score = 0;
340
+ for (const term of terms) {
341
+ const f = d.tf.get(term) ?? 0;
342
+ if (f === 0) continue;
343
+ const n = df.get(term) ?? 0;
344
+ const idf = Math.max(1e-6, Math.log((N - n + 0.5) / (n + 0.5)));
345
+ score += idf * ((f * (k1 + 1)) / (f + k1 * (1 - b + b * (d.len / avgdl))));
346
+ }
347
+ if (score > 0) out.push({ kind: d.kind, path: d.path, score });
348
+ }
349
+ return out.sort((a, b) => b.score - a.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
350
+ }
351
+
352
+ export function langOf(filePath) {
353
+ const base = filePath.slice(filePath.lastIndexOf('/') + 1);
354
+ const dot = base.lastIndexOf('.');
355
+ if (dot <= 0) return '';
356
+ return LANG[base.slice(dot + 1)] ?? '';
357
+ }
358
+
359
+ export async function packageOf(root, filePath, cache) {
360
+ let dir = dirname(filePath);
361
+ if (dir === '.') dir = '';
362
+ const seen = [];
363
+ for (;;) {
364
+ if (cache.has(dir)) {
365
+ const found = cache.get(dir);
366
+ for (const prior of seen) cache.set(prior, found);
367
+ return found;
368
+ }
369
+ seen.push(dir);
370
+ let hit = false;
371
+ for (const name of MARKERS) {
372
+ try {
373
+ await stat(join(root, dir, name));
374
+ hit = true;
375
+ break;
376
+ } catch {
377
+ // This directory is not that kind of package. The next marker may be.
378
+ }
379
+ }
380
+ if (hit || dir === '') {
381
+ const found = hit ? dir : '';
382
+ for (const prior of seen) cache.set(prior, found);
383
+ return found;
384
+ }
385
+ const parent = dirname(dir);
386
+ dir = parent === '.' ? '' : parent;
387
+ }
388
+ }
389
+
390
+ function git(root, args, input, binary = false) {
391
+ return new Promise((resolve, reject) => {
392
+ const child = execFile('git', args, { cwd: root, encoding: binary ? 'buffer' : 'utf8', maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
393
+ if (error) reject(Object.assign(error, { stdout, stderr }));
394
+ else resolve(stdout);
395
+ });
396
+ if (input !== undefined) {
397
+ child.stdin.end(input);
398
+ }
399
+ });
400
+ }
401
+
402
+ async function lsFiles(root) {
403
+ const stdout = await git(root, ['ls-files', '-s', '-z']);
404
+ const rows = [];
405
+ for (const rec of stdout.split('\0')) {
406
+ if (rec === '') continue;
407
+ const tab = rec.indexOf('\t');
408
+ if (tab < 0) continue;
409
+ const meta = rec.slice(0, tab).split(' ');
410
+ if (meta[2] !== '0') continue;
411
+ rows.push({ blob: meta[1], path: rec.slice(tab + 1) });
412
+ }
413
+ rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
414
+ return rows;
415
+ }
416
+
417
+ async function resolveAdapter(root, opts) {
418
+ if (opts.adapter) return { module: opts.adapter, state: 'loaded' };
419
+ let manifest = null;
420
+ try {
421
+ const read = await readManifest(root);
422
+ if (!read.error) manifest = read.manifest;
423
+ } catch {
424
+ return { module: null, state: 'off' };
425
+ }
426
+ const name = manifest?.index?.parsers;
427
+ if (typeof name !== 'string' || name === '') return { module: null, state: 'off' };
428
+ try {
429
+ const { createRequire } = await import('node:module');
430
+ const req = createRequire(join(root, 'package.json'));
431
+ return { module: req(name), state: 'loaded' };
432
+ } catch {
433
+ return { module: null, state: 'missing' };
434
+ }
435
+ }
436
+
437
+ async function runExtract(filePath, text, lang, adapter) {
438
+ if (adapter && typeof adapter.extract === 'function') {
439
+ try {
440
+ const got = await adapter.extract(filePath, text, lang);
441
+ if (got && Array.isArray(got.symbols)) return normalise(filePath, got);
442
+ } catch {
443
+ // A parser that throws does not sink the index. The built-in rows are the cache.
444
+ }
445
+ }
446
+ return extract(filePath, text, lang);
447
+ }
448
+
449
+ function normalise(filePath, got) {
450
+ const symbols = [];
451
+ for (const row of got.symbols) {
452
+ if (!row || typeof row.name !== 'string' || row.name === '') continue;
453
+ symbols.push({
454
+ path: filePath,
455
+ name: row.name,
456
+ kind: typeof row.kind === 'string' ? row.kind : 'symbol',
457
+ line: Number.isInteger(row.line) ? row.line : 0,
458
+ sig: typeof row.sig === 'string' ? row.sig.slice(0, 200) : '',
459
+ exported: row.exported ? 1 : 0,
460
+ });
461
+ }
462
+ symbols.sort((a, b) => a.line - b.line || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
463
+ let edges = null;
464
+ if (Array.isArray(got.edges)) {
465
+ edges = [];
466
+ for (const row of got.edges) {
467
+ if (!row || typeof row.dst !== 'string') continue;
468
+ edges.push({
469
+ src: filePath,
470
+ dst: row.dst,
471
+ kind: typeof row.kind === 'string' ? row.kind : 'import',
472
+ weight: typeof row.weight === 'number' ? row.weight : 1,
473
+ });
474
+ }
475
+ edges.sort((a, b) => (a.dst < b.dst ? -1 : a.dst > b.dst ? 1 : 0) || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0));
476
+ }
477
+ return { symbols, imports: Array.isArray(got.imports) ? got.imports : [], edges };
478
+ }
479
+
480
+ export async function blobTexts(root, blobs) {
481
+ const unique = [...new Set(blobs)];
482
+ const texts = new Map();
483
+ if (unique.length === 0) return texts;
484
+ const stdout = await git(root, ['cat-file', '--batch'], `${unique.join('\n')}\n`, true);
485
+ let offset = 0;
486
+ while (offset < stdout.length) {
487
+ const nl = stdout.indexOf(0x0a, offset);
488
+ if (nl < 0) break;
489
+ const header = stdout.toString('utf8', offset, nl);
490
+ const parts = header.split(' ');
491
+ const size = Number(parts[2]);
492
+ if (parts[1] !== 'blob' || !Number.isFinite(size)) {
493
+ offset = nl + 1;
494
+ continue;
495
+ }
496
+ const start = nl + 1;
497
+ const body = stdout.subarray(start, start + size);
498
+ offset = start + size + 1;
499
+ texts.set(parts[0], body.includes(0) ? null : body.toString('utf8'));
500
+ }
501
+ return texts;
502
+ }
503
+
504
+ async function blobSizes(root, blobs) {
505
+ const unique = [...new Set(blobs)];
506
+ const sizes = new Map();
507
+ if (unique.length === 0) return sizes;
508
+ const stdout = await git(root, ['cat-file', '--batch-check=%(objectname) %(objectsize)'], `${unique.join('\n')}\n`);
509
+ for (const line of stdout.split('\n')) {
510
+ if (line === '') continue;
511
+ const sp = line.indexOf(' ');
512
+ sizes.set(line.slice(0, sp), Number(line.slice(sp + 1)));
513
+ }
514
+ return sizes;
515
+ }