simt-dev 0.2.2 → 0.3.1

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 (96) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +129 -75
  3. package/bin/simt-dev.mjs +21 -2
  4. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  5. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  6. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  7. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  8. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  9. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  10. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  11. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  12. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  13. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  14. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  15. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  16. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  17. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  18. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  19. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  20. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  21. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  22. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  23. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  24. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  25. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  26. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  27. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  28. package/package.json +2 -1
  29. package/rules/core.md +1 -1
  30. package/src/agenthooks/decide.mjs +123 -0
  31. package/src/agenthooks/policy.mjs +78 -0
  32. package/src/agenthooks/render.mjs +240 -0
  33. package/src/argv.mjs +54 -1
  34. package/src/bench/ablate.mjs +630 -0
  35. package/src/bench/adapters.json +149 -0
  36. package/src/bench/grade.mjs +229 -0
  37. package/src/bench/mine.mjs +811 -0
  38. package/src/bench/probes.mjs +1350 -0
  39. package/src/bench/report.mjs +899 -0
  40. package/src/bench/run.mjs +1002 -0
  41. package/src/ci/workflow.mjs +11 -1
  42. package/src/commands/agenthook.mjs +282 -0
  43. package/src/commands/bench.mjs +371 -0
  44. package/src/commands/evidence.mjs +96 -0
  45. package/src/commands/gate.mjs +165 -0
  46. package/src/commands/harness.mjs +156 -5
  47. package/src/commands/help.mjs +89 -1
  48. package/src/commands/hooks.mjs +45 -5
  49. package/src/commands/impact.mjs +205 -0
  50. package/src/commands/index.mjs +60 -1
  51. package/src/commands/map.mjs +91 -0
  52. package/src/commands/plan.mjs +99 -0
  53. package/src/commands/repoindex.mjs +52 -0
  54. package/src/commands/shared.mjs +398 -0
  55. package/src/commands/sync.mjs +72 -3
  56. package/src/commands/tests.mjs +192 -0
  57. package/src/commands/tokens.mjs +61 -1
  58. package/src/commands/why.mjs +177 -0
  59. package/src/decisions/index.mjs +23 -5
  60. package/src/graph/spec.mjs +1 -1
  61. package/src/hooks/dispatcher.mjs +15 -0
  62. package/src/hooks/shim.mjs +31 -0
  63. package/src/index/compact.mjs +228 -0
  64. package/src/index/extract.mjs +331 -0
  65. package/src/index/gate.mjs +101 -0
  66. package/src/index/govern.mjs +222 -0
  67. package/src/index/impact.mjs +186 -0
  68. package/src/index/map.mjs +393 -0
  69. package/src/index/persist.mjs +172 -0
  70. package/src/index/store.mjs +301 -0
  71. package/src/index/tests.mjs +294 -0
  72. package/src/kernel/budget.mjs +100 -0
  73. package/src/kernel/card.mjs +254 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/inferability.mjs +188 -0
  76. package/src/ledger/index.mjs +112 -0
  77. package/src/mcp/resources.mjs +157 -0
  78. package/src/mcp/server.mjs +37 -6
  79. package/src/mcp/tools.mjs +84 -0
  80. package/src/measure/context.mjs +184 -11
  81. package/src/measure/loaderView.mjs +435 -0
  82. package/src/measure/session.mjs +267 -0
  83. package/src/plan/index.mjs +650 -0
  84. package/src/plan/template.mjs +331 -0
  85. package/src/projection/disclosure.mjs +92 -0
  86. package/src/projection/loaders.mjs +689 -0
  87. package/src/projection/lock.mjs +93 -0
  88. package/src/projection/manifest.mjs +78 -3
  89. package/src/projection/markers.mjs +56 -17
  90. package/src/projection/project.mjs +24 -17
  91. package/src/projection/rules.mjs +452 -43
  92. package/src/projection/shared.mjs +428 -0
  93. package/src/projection/surfaces.mjs +22 -8
  94. package/src/projection/targets.mjs +2 -1
  95. package/src/toolwords.mjs +71 -0
  96. package/templates/plan/tracker.json +36 -0
@@ -0,0 +1,393 @@
1
+ // Ranked repo map (R22, ADR-091).
2
+ //
3
+ // PageRank over import and call edges, personalised to the task. An edge is
4
+ // followed in both directions: the file that imports a mentioned file is part of
5
+ // the task, and so is the file it imports. One direction would hide one of them.
6
+ // Iterations and the tie break are fixed so two runs of the same graph return
7
+ // the same order. A random walk, or a tie broken by insertion order, would not.
8
+
9
+ import { readdir, readFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+
12
+ import { resolveVaultDir } from '../bench/mine.mjs';
13
+ import { readEdges, readFiles, readSymbols } from './store.mjs';
14
+
15
+ export const ITERATIONS = 20;
16
+ export const DAMPING = 0.85;
17
+ const TOP = 10;
18
+ export const DEFAULT_BUDGET = 2048;
19
+
20
+ const STOP = new Set([
21
+ 'this', 'that', 'with', 'from', 'have', 'been', 'into', 'only', 'when', 'what',
22
+ 'which', 'their', 'about', 'after', 'before', 'where', 'there', 'these', 'those',
23
+ 'would', 'could', 'should', 'every', 'other', 'than', 'then', 'them', 'they',
24
+ 'your', 'will', 'does', 'file', 'files', 'test', 'tests', 'line', 'lines', 'code',
25
+ 'also', 'just', 'more', 'some', 'such', 'each', 'same', 'over', 'used', 'using',
26
+ ]);
27
+
28
+ /**
29
+ * Rank files for one task. No task text ranks the whole graph. A task ranks the
30
+ * files it mentions and the files one import or call away from those.
31
+ *
32
+ * @param {{files: {path: string}[], edges: {src: string, dst: string, kind: string, weight?: number}[], symbols: {path: string, name: string}[], task?: string}} input
33
+ * @returns {{path: string, rank: number}[]}
34
+ */
35
+ export function rankFiles({ files, edges, symbols, task = '' }) {
36
+ const nodes = [...new Set(files.map((file) => file.path))].sort();
37
+ const symbolsByPath = groupSymbols(symbols);
38
+ const scores = scoreFiles(nodes, symbolsByPath, task);
39
+ const kept = closure(nodes, edges, scores);
40
+ const index = new Map(kept.map((path, i) => [path, i]));
41
+ const links = buildLinks(kept, edges, index);
42
+ const rank = pageRank(kept, links, scores);
43
+ // A file the task mentions stays ahead of a file it does not. PageRank on
44
+ // this graph otherwise returns the file every module imports, and the files
45
+ // the task named never reach the top 10.
46
+ return kept
47
+ .map((path, i) => ({ path, rank: rank[i], score: scores.get(path) ?? 0 }))
48
+ .sort(byRank);
49
+ }
50
+
51
+ /**
52
+ * Render the ranking within a byte budget.
53
+ * `outline` is paths, `names` adds exported symbols, `signatures` adds the line.
54
+ * A budget of 0 does not truncate. The first file is kept even when it alone
55
+ * exceeds the budget, so the map is never empty while the ranking is not.
56
+ *
57
+ * @param {{path: string, rank: number}[]} ranked
58
+ * @param {{path: string, name: string, sig?: string, exported?: number|boolean}[]} symbols
59
+ * @param {{level?: string, budget?: number}} [opts]
60
+ */
61
+ export function renderMap(ranked, symbols, { level = 'outline', budget = DEFAULT_BUDGET } = {}) {
62
+ const byPath = groupSymbols(symbols);
63
+ const blocks = [];
64
+ let used = 0;
65
+ for (const row of ranked) {
66
+ const block = renderEntry(row.path, byPath.get(row.path) ?? [], level);
67
+ const gap = blocks.length === 0 ? 0 : 1;
68
+ const bytes = Buffer.byteLength(block);
69
+ if (blocks.length > 0 && budget > 0 && used + gap + bytes > budget) break;
70
+ blocks.push(block);
71
+ used += gap + bytes;
72
+ if (budget > 0 && used >= budget) break;
73
+ }
74
+ return {
75
+ text: blocks.join('\n'),
76
+ shown: blocks.length,
77
+ total: ranked.length,
78
+ truncated: blocks.length < ranked.length,
79
+ files: ranked.slice(0, blocks.length).map((row) => ({ path: row.path, rank: round6(row.rank) })),
80
+ };
81
+ }
82
+
83
+ /** Fraction of gold paths that sit in the top 10. Null when there is nothing to recall. */
84
+ export function recallAt10(ranked, gold) {
85
+ const relevant = [...new Set(gold)];
86
+ if (relevant.length === 0) return null;
87
+ const top = new Set(ranked.slice(0, TOP).map((row) => row.path));
88
+ let hit = 0;
89
+ for (const path of relevant) if (top.has(path)) hit += 1;
90
+ return hit / relevant.length;
91
+ }
92
+
93
+ /** Paths a unified diff changes. The destination side is the file after the patch. */
94
+ export function goldPaths(patch) {
95
+ const out = [];
96
+ for (const line of String(patch).split('\n')) {
97
+ const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
98
+ if (!match || match[2] === '/dev/null') continue;
99
+ out.push(match[2]);
100
+ }
101
+ return [...new Set(out)].sort();
102
+ }
103
+
104
+ /**
105
+ * Mean recall@10 over mined tasks whose gold patch is in the vault.
106
+ * A missing vault is unmeasured, not a zero: the absence of a hidden patch is
107
+ * not evidence the map missed the file (ADR-076).
108
+ *
109
+ * @param {string} root
110
+ * @param {{vaultRoot?: string, backend?: string}} [opts]
111
+ */
112
+ export async function measureRecall(root, opts = {}) {
113
+ const tasksDir = join(root, '.simt-dev', 'bench', 'tasks');
114
+ let names;
115
+ try {
116
+ names = (await readdir(tasksDir)).filter((name) => name.endsWith('.json')).sort();
117
+ } catch {
118
+ return { measured: false, reason: 'no tasks', tasks: 0, missing: 0, recall: null };
119
+ }
120
+ const files = await readFiles(root, opts);
121
+ if (files.length === 0) {
122
+ return { measured: false, reason: 'no index', tasks: 0, missing: 0, recall: null };
123
+ }
124
+ const [symbols, edges] = await Promise.all([readSymbols(root, opts), readEdges(root, opts)]);
125
+ const vault = opts.vaultRoot ?? resolveVaultDir(root);
126
+ let sum = 0;
127
+ let tasks = 0;
128
+ let missing = 0;
129
+ for (const name of names) {
130
+ const task = JSON.parse(await readFile(join(tasksDir, name), 'utf8'));
131
+ let patch;
132
+ try {
133
+ patch = await readFile(join(vault, task.id, 'gold.patch'), 'utf8');
134
+ } catch {
135
+ missing += 1;
136
+ continue;
137
+ }
138
+ const gold = goldPaths(patch);
139
+ if (gold.length === 0) {
140
+ missing += 1;
141
+ continue;
142
+ }
143
+ const ranked = rankFiles({ files, edges, symbols, task: task.prompt ?? '' });
144
+ sum += recallAt10(ranked, gold);
145
+ tasks += 1;
146
+ }
147
+ if (tasks === 0) return { measured: false, reason: 'no gold patches', tasks: 0, missing, recall: null };
148
+ return { measured: true, reason: '', tasks, missing, recall: sum / tasks };
149
+ }
150
+
151
+ export function formatRecall(result) {
152
+ if (!result?.measured || result.recall === null) {
153
+ return `recall@10 unmeasured (${result?.reason ?? 'not run'})`;
154
+ }
155
+ return `recall@10 ${(result.recall * 100).toFixed(1)}% of gold files, over ${result.tasks} mined tasks`;
156
+ }
157
+
158
+ function pageRank(nodes, links, scores) {
159
+ const n = nodes.length;
160
+ const pers = new Array(n).fill(0);
161
+ let mass = 0;
162
+ for (let i = 0; i < n; i += 1) {
163
+ pers[i] = scores.get(nodes[i]) ?? 0;
164
+ mass += pers[i];
165
+ }
166
+ if (mass === 0) {
167
+ for (let i = 0; i < n; i += 1) pers[i] = 1 / n;
168
+ } else {
169
+ for (let i = 0; i < n; i += 1) pers[i] /= mass;
170
+ }
171
+ let rank = pers.slice();
172
+ for (let iter = 0; iter < ITERATIONS; iter += 1) {
173
+ const next = pers.map((value) => (1 - DAMPING) * value);
174
+ for (let i = 0; i < n; i += 1) {
175
+ const outs = links[i];
176
+ if (outs.length === 0) {
177
+ for (let j = 0; j < n; j += 1) next[j] += DAMPING * rank[i] * pers[j];
178
+ } else {
179
+ let sum = 0;
180
+ for (const [, weight] of outs) sum += weight;
181
+ for (const [j, weight] of outs) next[j] += (DAMPING * rank[i] * weight) / sum;
182
+ }
183
+ }
184
+ rank = next;
185
+ }
186
+ return rank;
187
+ }
188
+
189
+ function buildLinks(nodes, edges, index) {
190
+ const weight = new Map();
191
+ const usable = edges.filter(
192
+ (edge) =>
193
+ (edge.kind === 'import' || edge.kind === 'call') &&
194
+ index.has(edge.src) &&
195
+ index.has(edge.dst) &&
196
+ edge.src !== edge.dst,
197
+ );
198
+ usable.sort((a, b) => cmp(a.src, b.src) || cmp(a.dst, b.dst) || cmp(a.kind, b.kind));
199
+ for (const edge of usable) {
200
+ const lo = edge.src < edge.dst ? edge.src : edge.dst;
201
+ const hi = edge.src < edge.dst ? edge.dst : edge.src;
202
+ const key = `${lo}\0${hi}`;
203
+ const value = Number.isFinite(edge.weight) && edge.weight > 0 ? edge.weight : 1;
204
+ weight.set(key, Math.max(weight.get(key) ?? 0, value));
205
+ }
206
+ const links = nodes.map(() => []);
207
+ for (const [key, value] of [...weight.entries()].sort((a, b) => cmp(a[0], b[0]))) {
208
+ const [lo, hi] = key.split('\0');
209
+ const i = index.get(lo);
210
+ const j = index.get(hi);
211
+ links[i].push([j, value]);
212
+ links[j].push([i, value]);
213
+ }
214
+ for (const list of links) list.sort((a, b) => a[0] - b[0]);
215
+ return links;
216
+ }
217
+
218
+ function closure(nodes, edges, scores) {
219
+ const seeds = new Set(nodes.filter((path) => (scores.get(path) ?? 0) > 0));
220
+ if (seeds.size === 0) return nodes;
221
+ const known = new Set(nodes);
222
+ const keep = new Set(seeds);
223
+ for (const edge of edges) {
224
+ if (edge.kind !== 'import' && edge.kind !== 'call') continue;
225
+ if (!known.has(edge.src) || !known.has(edge.dst)) continue;
226
+ if (seeds.has(edge.src)) keep.add(edge.dst);
227
+ if (seeds.has(edge.dst)) keep.add(edge.src);
228
+ }
229
+ return [...keep].sort();
230
+ }
231
+
232
+ function scoreFiles(nodes, symbolsByPath, task) {
233
+ const scores = new Map(nodes.map((path) => [path, 0]));
234
+ const { text, terms } = termsOf(task);
235
+ if (terms.size === 0) return scores;
236
+ const segFreq = new Map();
237
+ for (const path of nodes) {
238
+ for (const segment of segments(path)) segFreq.set(segment, (segFreq.get(segment) ?? 0) + 1);
239
+ }
240
+ const cap = Math.max(3, Math.floor(nodes.length * 0.15));
241
+ // A term that labels every file ("src") is the tree, not the task. A long
242
+ // word that sits inside dozens of paths ("decision") is the same thing, so
243
+ // the substring count uses the same ceiling.
244
+ const common = (freq) => freq > cap || (nodes.length > 1 && freq === nodes.length);
245
+ const termFreq = new Map();
246
+ for (const term of terms) {
247
+ if (term.length < 6) continue;
248
+ let count = 0;
249
+ for (const path of nodes) if (path.toLowerCase().includes(term)) count += 1;
250
+ termFreq.set(term, count);
251
+ }
252
+ const symFreq = new Map();
253
+ for (const path of nodes) {
254
+ const seen = new Set();
255
+ for (const symbol of symbolsByPath.get(path) ?? []) {
256
+ const name = String(symbol.name ?? '').toLowerCase();
257
+ if (name.length >= 3) seen.add(name);
258
+ }
259
+ for (const name of seen) symFreq.set(name, (symFreq.get(name) ?? 0) + 1);
260
+ }
261
+ for (const path of nodes) {
262
+ const lower = path.toLowerCase();
263
+ let score = 0;
264
+ if (lower.length >= 3 && text.includes(lower)) score += 8;
265
+ const stem = stemOf(lower);
266
+ if (stem.length >= 3 && terms.has(stem) && !common(segFreq.get(stem) ?? 0)) score += 4;
267
+ for (const segment of segments(lower)) {
268
+ if (terms.has(segment) && !common(segFreq.get(segment) ?? 0)) score += 2;
269
+ }
270
+ for (const term of terms) {
271
+ const freq = termFreq.get(term) ?? 0;
272
+ if (term.length < 6 || freq === 0 || !lower.includes(term) || common(freq)) continue;
273
+ score += 3;
274
+ // A record cited once is context. The record the task is about is named
275
+ // again. A flat bonus ranked every citation with the subject, and the
276
+ // files the patch actually changed fell out of the top 10.
277
+ if (freq === 1 && /\d/.test(term)) score += 8 * Math.min(mentions(text, term), 3);
278
+ }
279
+ let hits = 0;
280
+ for (const symbol of symbolsByPath.get(path) ?? []) {
281
+ const name = String(symbol.name ?? '').toLowerCase();
282
+ if (name.length >= 3 && terms.has(name) && (symFreq.get(name) ?? 0) <= 5) hits += 1;
283
+ }
284
+ scores.set(path, score + Math.min(hits, 8));
285
+ }
286
+ // A task that names one file in a small directory usually edits its
287
+ // neighbours too. A large directory (every decision record, every command)
288
+ // does not get that lift: one citation would pull the whole directory in.
289
+ const dirMax = new Map();
290
+ const dirCount = new Map();
291
+ for (const path of nodes) {
292
+ const dir = directoryOf(path);
293
+ dirCount.set(dir, (dirCount.get(dir) ?? 0) + 1);
294
+ dirMax.set(dir, Math.max(dirMax.get(dir) ?? 0, scores.get(path) ?? 0));
295
+ }
296
+ for (const path of nodes) {
297
+ const own = scores.get(path) ?? 0;
298
+ if (own === 0) continue;
299
+ const dir = directoryOf(path);
300
+ if ((dirCount.get(dir) ?? 0) > 8) continue;
301
+ const lifted = Math.floor((dirMax.get(dir) ?? 0) / 2);
302
+ if (lifted > own) scores.set(path, lifted);
303
+ }
304
+ return scores;
305
+ }
306
+
307
+ function directoryOf(path) {
308
+ const slash = path.lastIndexOf('/');
309
+ return slash < 0 ? '' : path.slice(0, slash);
310
+ }
311
+
312
+ function mentions(text, term) {
313
+ let count = 0;
314
+ let from = 0;
315
+ while (from < text.length) {
316
+ const at = text.indexOf(term, from);
317
+ if (at < 0) return count;
318
+ count += 1;
319
+ from = at + term.length;
320
+ }
321
+ return count;
322
+ }
323
+
324
+ function termsOf(task) {
325
+ const text = String(task ?? '').toLowerCase();
326
+ const terms = new Set();
327
+ for (const word of text.split(/[^a-z0-9-]+/)) {
328
+ if (word.length >= 3 && !STOP.has(word)) terms.add(word);
329
+ }
330
+ return { text, terms };
331
+ }
332
+
333
+ function segments(path) {
334
+ const lower = path.toLowerCase();
335
+ const out = [];
336
+ for (const part of lower.split('/')) {
337
+ const stem = stemOf(part);
338
+ if (stem.length >= 3) out.push(stem);
339
+ }
340
+ return out;
341
+ }
342
+
343
+ function stemOf(path) {
344
+ const base = path.slice(path.lastIndexOf('/') + 1);
345
+ const dot = base.lastIndexOf('.');
346
+ return dot > 0 ? base.slice(0, dot) : base;
347
+ }
348
+
349
+ function groupSymbols(symbols) {
350
+ const byPath = new Map();
351
+ for (const symbol of symbols ?? []) {
352
+ const list = byPath.get(symbol.path) ?? [];
353
+ list.push(symbol);
354
+ byPath.set(symbol.path, list);
355
+ }
356
+ return byPath;
357
+ }
358
+
359
+ function renderEntry(path, symbols, level) {
360
+ const exported = symbols
361
+ .filter((symbol) => symbol.exported === 1 || symbol.exported === true)
362
+ .sort((a, b) => cmp(a.name, b.name) || a.line - b.line);
363
+ if (level === 'names') {
364
+ const names = exported.map((symbol) => symbol.name);
365
+ return names.length === 0 ? path : `${path} ${names.join(' ')}`;
366
+ }
367
+ if (level === 'signatures') {
368
+ const lines = [path];
369
+ for (const symbol of exported) {
370
+ const sig = String(symbol.sig ?? '').trim();
371
+ lines.push(sig ? ` ${symbol.name} ${sig}` : ` ${symbol.name}`);
372
+ }
373
+ return lines.join('\n');
374
+ }
375
+ return path;
376
+ }
377
+
378
+ function byRank(a, b) {
379
+ const score = (b.score ?? 0) - (a.score ?? 0);
380
+ if (score !== 0) return score;
381
+ if (a.rank !== b.rank) return a.rank < b.rank ? 1 : -1;
382
+ return cmp(a.path, b.path);
383
+ }
384
+
385
+ function round6(value) {
386
+ return Math.round(value * 1e6) / 1e6;
387
+ }
388
+
389
+ function cmp(a, b) {
390
+ if (a < b) return -1;
391
+ if (a > b) return 1;
392
+ return 0;
393
+ }
@@ -0,0 +1,172 @@
1
+ // The two on-disk forms of the index (ADR-089, ADR-090).
2
+ //
3
+ // sqlite when this Node can open it, JSON-lines otherwise. Both store the same
4
+ // rows. This file is the format; store.mjs is the walk that fills it. They are
5
+ // split because the walk and the format together crossed the review trigger.
6
+
7
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ export const SQLITE_FILE = '.simt-dev/index.sqlite';
11
+ export const JSONL_FILE = '.simt-dev/index.jsonl';
12
+ export const SCHEMA_V = '2';
13
+
14
+ const SCHEMA = `
15
+ create table if not exists files(path text primary key, blob text, lang text, bytes int, pkg text);
16
+ create table if not exists symbols(id integer primary key, path text, name text, kind text, line int, sig text, exported int);
17
+ create table if not exists edges(src text, dst text, kind text, weight real);
18
+ create virtual table if not exists fts using fts5(kind, ref, body);
19
+ create table if not exists meta(key text primary key, value text);
20
+ `;
21
+
22
+ export async function writeSqlite(root, next, removed, symbols, edges, fts) {
23
+ const { DatabaseSync } = await import('node:sqlite');
24
+ const file = join(root, SQLITE_FILE);
25
+ await mkdir(dirname(file), { recursive: true });
26
+ const db = new DatabaseSync(file);
27
+ try {
28
+ db.exec(SCHEMA);
29
+ db.exec('begin');
30
+ const upsert = db.prepare(`insert into files(path, blob, lang, bytes, pkg) values (?, ?, ?, ?, ?)
31
+ on conflict(path) do update set blob = excluded.blob, lang = excluded.lang, bytes = excluded.bytes, pkg = excluded.pkg`);
32
+ const dropSymbols = db.prepare('delete from symbols where path = ?');
33
+ const dropEdges = db.prepare('delete from edges where src = ? or dst = ?');
34
+ const dropFts = db.prepare('delete from fts where ref = ?');
35
+ const dropFile = db.prepare('delete from files where path = ?');
36
+ const addSymbol = db.prepare('insert into symbols(path, name, kind, line, sig, exported) values (?, ?, ?, ?, ?, ?)');
37
+ const addEdge = db.prepare('insert into edges(src, dst, kind, weight) values (?, ?, ?, ?)');
38
+ const addFts = db.prepare('insert into fts(kind, ref, body) values (?, ?, ?)');
39
+ for (const row of next) {
40
+ upsert.run(row.path, row.blob, row.lang, row.bytes, row.pkg);
41
+ dropSymbols.run(row.path);
42
+ dropEdges.run(row.path, row.path);
43
+ dropFts.run(row.path);
44
+ }
45
+ for (const path of removed) {
46
+ dropFile.run(path);
47
+ dropSymbols.run(path);
48
+ dropEdges.run(path, path);
49
+ dropFts.run(path);
50
+ }
51
+ for (const row of symbols) addSymbol.run(row.path, row.name, row.kind, row.line, row.sig, row.exported);
52
+ for (const row of edges) addEdge.run(row.src, row.dst, row.kind, row.weight);
53
+ for (const row of fts) addFts.run(row.kind, row.ref, row.body);
54
+ db.prepare('insert into meta(key, value) values (?, ?) on conflict(key) do update set value = excluded.value').run('schema', SCHEMA_V);
55
+ db.exec('commit');
56
+ } catch (error) {
57
+ try { db.exec('rollback'); } catch { /* a failed begin has nothing to undo */ }
58
+ throw error;
59
+ } finally {
60
+ db.close();
61
+ }
62
+ }
63
+
64
+ export async function writeJsonl(root, previous, next, removed, symbols, edges, fts, force) {
65
+ const gone = new Set(removed);
66
+ const replaced = new Map(next.map((row) => [row.path, row]));
67
+ const drop = new Set([...replaced.keys(), ...gone]);
68
+ const files = [];
69
+ for (const row of previous) {
70
+ if (gone.has(row.path)) continue;
71
+ files.push(replaced.get(row.path) ?? row);
72
+ replaced.delete(row.path);
73
+ }
74
+ for (const row of replaced.values()) files.push(row);
75
+ files.sort(byPath);
76
+ const keptSymbols = force ? [] : (await readJsonl(root, 'symbols')).filter((row) => !drop.has(row.path));
77
+ const keptEdges = force ? [] : (await readJsonl(root, 'edges')).filter((row) => !drop.has(row.src) && !drop.has(row.dst));
78
+ const keptFts = force ? [] : (await readJsonl(root, 'fts')).filter((row) => !drop.has(row.ref));
79
+ const allSymbols = [...keptSymbols, ...symbols].sort(bySymbol);
80
+ const allEdges = [...keptEdges, ...edges].sort(byEdge);
81
+ const allFts = [...keptFts, ...fts].sort((a, b) => cmp(a.ref, b.ref));
82
+ const file = join(root, JSONL_FILE);
83
+ await mkdir(dirname(file), { recursive: true });
84
+ const lines = [
85
+ JSON.stringify({ table: 'meta', key: 'schema', value: SCHEMA_V }),
86
+ ...files.map((row) => JSON.stringify({ table: 'files', path: row.path, blob: row.blob, lang: row.lang, bytes: row.bytes, pkg: row.pkg })),
87
+ ...allSymbols.map((row) => JSON.stringify({ table: 'symbols', path: row.path, name: row.name, kind: row.kind, line: row.line, sig: row.sig, exported: row.exported })),
88
+ ...allEdges.map((row) => JSON.stringify({ table: 'edges', src: row.src, dst: row.dst, kind: row.kind, weight: row.weight })),
89
+ ...allFts.map((row) => JSON.stringify({ table: 'fts', kind: row.kind, ref: row.ref, body: row.body })),
90
+ ];
91
+ await writeFile(file, `${lines.join('\n')}\n`);
92
+ return { symbols: allSymbols.length, edges: allEdges.length };
93
+ }
94
+
95
+ export async function readJsonl(root, table) {
96
+ let text;
97
+ try {
98
+ text = await readFile(join(root, JSONL_FILE), 'utf8');
99
+ } catch {
100
+ return [];
101
+ }
102
+ const rows = [];
103
+ for (const line of text.split('\n')) {
104
+ if (line === '') continue;
105
+ const row = JSON.parse(line);
106
+ if (row.table !== table) continue;
107
+ if (table === 'meta') rows.push({ key: row.key, value: row.value });
108
+ else if (table === 'files') rows.push({ path: row.path, blob: row.blob, lang: row.lang, bytes: row.bytes, pkg: row.pkg });
109
+ else if (table === 'symbols') rows.push({ path: row.path, name: row.name, kind: row.kind, line: row.line, sig: row.sig, exported: row.exported });
110
+ else if (table === 'edges') rows.push({ src: row.src, dst: row.dst, kind: row.kind, weight: row.weight });
111
+ else if (table === 'fts') rows.push({ kind: row.kind, ref: row.ref, body: row.body });
112
+ }
113
+ if (table === 'symbols') rows.sort(bySymbol);
114
+ else if (table === 'edges') rows.sort(byEdge);
115
+ else if (table === 'fts') rows.sort((a, b) => cmp(a.ref, b.ref));
116
+ else rows.sort(byPath);
117
+ return rows;
118
+ }
119
+
120
+ export async function readSqliteRows(root, sql) {
121
+ let DatabaseSync;
122
+ try {
123
+ ({ DatabaseSync } = await import('node:sqlite'));
124
+ } catch {
125
+ return [];
126
+ }
127
+ const file = join(root, SQLITE_FILE);
128
+ try {
129
+ await stat(file);
130
+ } catch {
131
+ return [];
132
+ }
133
+ const db = new DatabaseSync(file);
134
+ try {
135
+ return db.prepare(sql).all();
136
+ } catch {
137
+ return [];
138
+ } finally {
139
+ db.close();
140
+ }
141
+ }
142
+
143
+ export async function readSchema(root, backend) {
144
+ if (backend === 'sqlite') {
145
+ const rows = await readSqliteRows(root, "select value from meta where key = 'schema'");
146
+ return rows[0]?.value ?? '';
147
+ }
148
+ const rows = await readJsonl(root, 'meta');
149
+ return rows.find((row) => row.key === 'schema')?.value ?? '';
150
+ }
151
+
152
+ export async function countSqlite(root) {
153
+ const symbols = await readSqliteRows(root, 'select count(*) as n from symbols');
154
+ const edges = await readSqliteRows(root, 'select count(*) as n from edges');
155
+ return { symbols: Number(symbols[0]?.n ?? 0), edges: Number(edges[0]?.n ?? 0) };
156
+ }
157
+
158
+ function byPath(a, b) {
159
+ return cmp(a.path, b.path);
160
+ }
161
+
162
+ function bySymbol(a, b) {
163
+ return cmp(a.path, b.path) || a.line - b.line || cmp(a.name, b.name) || cmp(a.kind, b.kind);
164
+ }
165
+
166
+ function byEdge(a, b) {
167
+ return cmp(a.src, b.src) || cmp(a.dst, b.dst) || cmp(a.kind, b.kind);
168
+ }
169
+
170
+ function cmp(a, b) {
171
+ return a < b ? -1 : a > b ? 1 : 0;
172
+ }