composto-ai 0.4.0 → 0.6.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.
@@ -1558,7 +1558,25 @@ function openDatabase(path) {
1558
1558
  }
1559
1559
 
1560
1560
  // src/memory/schema.ts
1561
- var CURRENT_VERSION = 1;
1561
+ var CURRENT_VERSION = 3;
1562
+ var V2_SQL = `
1563
+ CREATE INDEX IF NOT EXISTS idx_ft_file_commit ON file_touches(file_path, commit_sha);
1564
+ `;
1565
+ var V3_SQL = `
1566
+ CREATE TABLE IF NOT EXISTS hook_invocations (
1567
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1568
+ timestamp INTEGER NOT NULL,
1569
+ platform TEXT NOT NULL,
1570
+ event TEXT NOT NULL,
1571
+ file_path TEXT,
1572
+ verdict TEXT,
1573
+ score REAL,
1574
+ confidence REAL,
1575
+ latency_ms INTEGER NOT NULL,
1576
+ cache_hit INTEGER NOT NULL
1577
+ );
1578
+ CREATE INDEX IF NOT EXISTS idx_hi_timestamp ON hook_invocations(timestamp);
1579
+ `;
1562
1580
  var V1_SQL = `
1563
1581
  CREATE TABLE IF NOT EXISTS index_state (
1564
1582
  key TEXT PRIMARY KEY,
@@ -1646,7 +1664,9 @@ function runMigrations(db) {
1646
1664
  if (current >= CURRENT_VERSION) return;
1647
1665
  db.exec("BEGIN");
1648
1666
  try {
1649
- db.exec(V1_SQL);
1667
+ if (current < 1) db.exec(V1_SQL);
1668
+ if (current < 2) db.exec(V2_SQL);
1669
+ if (current < 3) db.exec(V3_SQL);
1650
1670
  db.pragma(`user_version = ${CURRENT_VERSION}`);
1651
1671
  db.exec("COMMIT");
1652
1672
  } catch (err) {
@@ -1788,13 +1808,19 @@ function computeRevertMatch(db, filePath) {
1788
1808
  };
1789
1809
  }
1790
1810
 
1811
+ // src/memory/signals/db-clock.ts
1812
+ function getDbMaxTimestamp(db) {
1813
+ const row = db.prepare("SELECT MAX(timestamp) AS ts FROM commits").get();
1814
+ return row?.ts ?? null;
1815
+ }
1816
+
1791
1817
  // src/memory/signals/hotspot.ts
1792
1818
  var WINDOW_SECONDS = 90 * 86400;
1793
1819
  var SATURATION_TOUCHES = 30;
1794
1820
  var FALLBACK_PRECISION2 = 0.3;
1795
1821
  function computeHotspot(db, filePath) {
1796
- const now = Math.floor(Date.now() / 1e3);
1797
- const lowerBound = now - WINDOW_SECONDS;
1822
+ const anchor = getDbMaxTimestamp(db) ?? Math.floor(Date.now() / 1e3);
1823
+ const lowerBound = anchor - WINDOW_SECONDS;
1798
1824
  const row = db.prepare(`
1799
1825
  SELECT COUNT(*) AS n
1800
1826
  FROM file_touches ft
@@ -1852,38 +1878,12 @@ function computeFixRatio(db, filePath) {
1852
1878
  };
1853
1879
  }
1854
1880
 
1855
- // src/memory/signals/coverage-decline.ts
1856
- var FALLBACK_PRECISION4 = 0.3;
1857
- function computeCoverageDecline(db, repoPath, filePath) {
1858
- const cal = getCalibration(db, "coverage_decline", FALLBACK_PRECISION4);
1859
- let strength = 0;
1860
- try {
1861
- const entries = getGitLog(repoPath, 200);
1862
- const trends = {
1863
- hotspots: detectHotspots(entries, { threshold: 10, fixRatioThreshold: 0.5 }),
1864
- decaySignals: detectDecay(entries),
1865
- inconsistencies: detectInconsistencies(entries)
1866
- };
1867
- const health = computeHealthFromTrends(filePath, trends);
1868
- if (health.coverageTrend === "down") strength = 1;
1869
- } catch {
1870
- strength = 0;
1871
- }
1872
- return {
1873
- type: "coverage_decline",
1874
- strength,
1875
- precision: cal.precision,
1876
- sample_size: cal.sampleSize,
1877
- evidence: []
1878
- };
1879
- }
1880
-
1881
1881
  // src/memory/signals/author-churn.ts
1882
1882
  var WINDOW_SECONDS2 = 90 * 86400;
1883
1883
  var INACTIVE_THRESHOLD = 5;
1884
- var FALLBACK_PRECISION5 = 0.3;
1884
+ var FALLBACK_PRECISION4 = 0.3;
1885
1885
  function computeAuthorChurn(db, filePath) {
1886
- const cal = getCalibration(db, "author_churn", FALLBACK_PRECISION5);
1886
+ const cal = getCalibration(db, "author_churn", FALLBACK_PRECISION4);
1887
1887
  const base = {
1888
1888
  type: "author_churn",
1889
1889
  precision: cal.precision,
@@ -1899,8 +1899,8 @@ function computeAuthorChurn(db, filePath) {
1899
1899
  LIMIT 1
1900
1900
  `).get(filePath);
1901
1901
  if (!lastTouch) return { ...base, strength: 0 };
1902
- const now = Math.floor(Date.now() / 1e3);
1903
- const lowerBound = now - WINDOW_SECONDS2;
1902
+ const anchor = getDbMaxTimestamp(db) ?? Math.floor(Date.now() / 1e3);
1903
+ const lowerBound = anchor - WINDOW_SECONDS2;
1904
1904
  const activity = db.prepare(`SELECT COUNT(*) AS n FROM commits WHERE author = ? AND timestamp >= ?`).get(lastTouch.author, lowerBound);
1905
1905
  let strength = 0;
1906
1906
  if (activity.n === 0) strength = 1;
@@ -1909,12 +1909,11 @@ function computeAuthorChurn(db, filePath) {
1909
1909
  }
1910
1910
 
1911
1911
  // src/memory/signals/index.ts
1912
- function collectSignals(db, repoPath, filePath) {
1912
+ function collectSignals(db, _repoPath, filePath) {
1913
1913
  return [
1914
1914
  computeRevertMatch(db, filePath),
1915
1915
  computeHotspot(db, filePath),
1916
1916
  computeFixRatio(db, filePath),
1917
- computeCoverageDecline(db, repoPath, filePath),
1918
1917
  computeAuthorChurn(db, filePath)
1919
1918
  ];
1920
1919
  }
@@ -2245,6 +2244,24 @@ var MemoryAPI = class {
2245
2244
  });
2246
2245
  return this.bootstrapPromise;
2247
2246
  }
2247
+ // bootstrapFromBoundary indexes only commits between fromSha and HEAD.
2248
+ // Used by `composto index --since=YYYY-MM-DD` to bound work on huge repos.
2249
+ // Pass fromSha=null to index the full history (same as bootstrapIfNeeded).
2250
+ async bootstrapFromBoundary(fromSha) {
2251
+ if (this.bootstrapPromise) return this.bootstrapPromise;
2252
+ const head = revParseHead(this.repoPath);
2253
+ const range = { from: fromSha, to: head };
2254
+ this.bootstrapPromise = this.pool.runIngest({ dbPath: this.dbPath, repoPath: this.repoPath, range }).then(() => {
2255
+ this.log.info("bootstrap_done", { through: range.to, from: range.from });
2256
+ }).catch((err) => {
2257
+ this.log.error("bootstrap_failed", { message: err.message });
2258
+ this.failures.recordFailure("ingest_failure");
2259
+ throw err;
2260
+ }).finally(() => {
2261
+ this.bootstrapPromise = null;
2262
+ });
2263
+ return this.bootstrapPromise;
2264
+ }
2248
2265
  async blastradius(input) {
2249
2266
  const start = Date.now();
2250
2267
  if (this.failures.isDisabled()) {
@@ -2366,7 +2383,7 @@ var MemoryAPI = class {
2366
2383
  var ALL_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".py", ".go", ".rs"];
2367
2384
  var server = new McpServer({
2368
2385
  name: "composto",
2369
- version: "0.4.0"
2386
+ version: "0.4.2"
2370
2387
  });
2371
2388
  server.tool(
2372
2389
  "composto_ir",
@@ -14,7 +14,25 @@ function openDatabase(path) {
14
14
  }
15
15
 
16
16
  // src/memory/schema.ts
17
- var CURRENT_VERSION = 1;
17
+ var CURRENT_VERSION = 3;
18
+ var V2_SQL = `
19
+ CREATE INDEX IF NOT EXISTS idx_ft_file_commit ON file_touches(file_path, commit_sha);
20
+ `;
21
+ var V3_SQL = `
22
+ CREATE TABLE IF NOT EXISTS hook_invocations (
23
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
24
+ timestamp INTEGER NOT NULL,
25
+ platform TEXT NOT NULL,
26
+ event TEXT NOT NULL,
27
+ file_path TEXT,
28
+ verdict TEXT,
29
+ score REAL,
30
+ confidence REAL,
31
+ latency_ms INTEGER NOT NULL,
32
+ cache_hit INTEGER NOT NULL
33
+ );
34
+ CREATE INDEX IF NOT EXISTS idx_hi_timestamp ON hook_invocations(timestamp);
35
+ `;
18
36
  var V1_SQL = `
19
37
  CREATE TABLE IF NOT EXISTS index_state (
20
38
  key TEXT PRIMARY KEY,
@@ -102,7 +120,9 @@ function runMigrations(db) {
102
120
  if (current >= CURRENT_VERSION) return;
103
121
  db.exec("BEGIN");
104
122
  try {
105
- db.exec(V1_SQL);
123
+ if (current < 1) db.exec(V1_SQL);
124
+ if (current < 2) db.exec(V2_SQL);
125
+ if (current < 3) db.exec(V3_SQL);
106
126
  db.pragma(`user_version = ${CURRENT_VERSION}`);
107
127
  db.exec("COMMIT");
108
128
  } catch (err) {
@@ -244,13 +264,19 @@ function computeRevertMatch(db, filePath) {
244
264
  };
245
265
  }
246
266
 
267
+ // src/memory/signals/db-clock.ts
268
+ function getDbMaxTimestamp(db) {
269
+ const row = db.prepare("SELECT MAX(timestamp) AS ts FROM commits").get();
270
+ return row?.ts ?? null;
271
+ }
272
+
247
273
  // src/memory/signals/hotspot.ts
248
274
  var WINDOW_SECONDS = 90 * 86400;
249
275
  var SATURATION_TOUCHES = 30;
250
276
  var FALLBACK_PRECISION2 = 0.3;
251
277
  function computeHotspot(db, filePath) {
252
- const now = Math.floor(Date.now() / 1e3);
253
- const lowerBound = now - WINDOW_SECONDS;
278
+ const anchor = getDbMaxTimestamp(db) ?? Math.floor(Date.now() / 1e3);
279
+ const lowerBound = anchor - WINDOW_SECONDS;
254
280
  const row = db.prepare(`
255
281
  SELECT COUNT(*) AS n
256
282
  FROM file_touches ft
@@ -308,202 +334,12 @@ function computeFixRatio(db, filePath) {
308
334
  };
309
335
  }
310
336
 
311
- // src/trends/git-log-parser.ts
312
- import { execSync as execSync2 } from "child_process";
313
- var BUG_FIX_PATTERNS = [
314
- /\bfix\b/i,
315
- /\bbugfix\b/i,
316
- /\bhotfix\b/i,
317
- /\bpatch\b/i,
318
- /\bresolve\b/i,
319
- /\bbug\b/i
320
- ];
321
- function isBugFixCommit(message) {
322
- return BUG_FIX_PATTERNS.some((p) => p.test(message));
323
- }
324
- function parseGitLogOutput(output) {
325
- const entries = [];
326
- const lines = output.split("\n");
327
- let i = 0;
328
- while (i < lines.length) {
329
- const line = lines[i].trim();
330
- if (!line || !line.includes("|")) {
331
- i++;
332
- continue;
333
- }
334
- const [hash, author, date, ...messageParts] = line.split("|");
335
- const message = messageParts.join("|");
336
- const files = [];
337
- i++;
338
- while (i < lines.length && lines[i].trim() !== "" && !lines[i].includes("|")) {
339
- const fileLine = lines[i].trim();
340
- if (fileLine) files.push(fileLine);
341
- i++;
342
- }
343
- entries.push({ hash, author, date, message, files });
344
- }
345
- return entries;
346
- }
347
- function getGitLog(repoPath, count = 100) {
348
- try {
349
- const output = execSync2(
350
- `git log --format="%h|%an|%as|%s" --name-only -n ${count}`,
351
- { cwd: repoPath, encoding: "utf-8", timeout: 1e4 }
352
- );
353
- return parseGitLogOutput(output);
354
- } catch {
355
- return [];
356
- }
357
- }
358
-
359
- // src/trends/hotspot.ts
360
- function detectHotspots(entries, options) {
361
- const fileStats = /* @__PURE__ */ new Map();
362
- for (const entry of entries) {
363
- const isFix = isBugFixCommit(entry.message);
364
- for (const file of entry.files) {
365
- const stats = fileStats.get(file) ?? { changes: 0, fixes: 0, authors: /* @__PURE__ */ new Set() };
366
- stats.changes++;
367
- if (isFix) stats.fixes++;
368
- stats.authors.add(entry.author);
369
- fileStats.set(file, stats);
370
- }
371
- }
372
- const hotspots = [];
373
- for (const [file, stats] of fileStats) {
374
- const fixRatio = stats.changes > 0 ? stats.fixes / stats.changes : 0;
375
- if (stats.changes >= options.threshold && fixRatio >= options.fixRatioThreshold) {
376
- hotspots.push({
377
- file,
378
- changesInLast30Commits: stats.changes,
379
- bugFixRatio: fixRatio,
380
- authorCount: stats.authors.size
381
- });
382
- }
383
- }
384
- return hotspots.sort((a, b) => b.changesInLast30Commits - a.changesInLast30Commits);
385
- }
386
-
387
- // src/trends/decay.ts
388
- function detectDecay(entries) {
389
- const fileChanges = /* @__PURE__ */ new Map();
390
- for (const entry of entries) {
391
- for (const file of entry.files) {
392
- const changes = fileChanges.get(file) ?? [];
393
- changes.push({ date: entry.date });
394
- fileChanges.set(file, changes);
395
- }
396
- }
397
- const signals = [];
398
- for (const [file, changes] of fileChanges) {
399
- if (changes.length < 4) continue;
400
- const sorted = [...changes].sort((a, b) => a.date.localeCompare(b.date));
401
- const firstDate = new Date(sorted[0].date).getTime();
402
- const lastDate = new Date(sorted[sorted.length - 1].date).getTime();
403
- const midDate = firstDate + (lastDate - firstDate) / 2;
404
- const firstHalfCount = sorted.filter((c) => new Date(c.date).getTime() <= midDate).length;
405
- const secondHalfCount = sorted.length - firstHalfCount;
406
- if (secondHalfCount > firstHalfCount) {
407
- signals.push({
408
- file,
409
- metric: "churn",
410
- trend: "declining",
411
- dataPoints: sorted.map((c, i) => ({ date: c.date, value: i + 1 }))
412
- });
413
- }
414
- }
415
- return signals;
416
- }
417
-
418
- // src/trends/inconsistency.ts
419
- function detectInconsistencies(entries, minAuthors = 3) {
420
- const fileAuthors = /* @__PURE__ */ new Map();
421
- for (const entry of entries) {
422
- for (const file of entry.files) {
423
- const authors = fileAuthors.get(file) ?? /* @__PURE__ */ new Map();
424
- const commits = authors.get(entry.author) ?? [];
425
- commits.push(entry.message);
426
- authors.set(entry.author, commits);
427
- fileAuthors.set(file, authors);
428
- }
429
- }
430
- const inconsistencies = [];
431
- for (const [file, authors] of fileAuthors) {
432
- if (authors.size >= minAuthors) {
433
- const patterns = Array.from(authors.entries()).map(([author, commits]) => ({
434
- author,
435
- style: categorizeStyle(commits)
436
- }));
437
- inconsistencies.push({ file, patterns });
438
- }
439
- }
440
- return inconsistencies;
441
- }
442
- function categorizeStyle(commits) {
443
- const types = commits.map((m) => {
444
- if (m.match(/^fix/i)) return "fix";
445
- if (m.match(/^feat/i)) return "feature";
446
- if (m.match(/^refactor/i)) return "refactor";
447
- return "other";
448
- });
449
- const primary = mode(types);
450
- return `primarily ${primary} (${commits.length} commits)`;
451
- }
452
- function mode(arr) {
453
- const counts = /* @__PURE__ */ new Map();
454
- for (const item of arr) {
455
- counts.set(item, (counts.get(item) ?? 0) + 1);
456
- }
457
- return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown";
458
- }
459
-
460
- // src/ir/health.ts
461
- function computeHealthFromTrends(file, trends) {
462
- const hotspot = trends.hotspots.find((h) => h.file === file);
463
- const decay = trends.decaySignals.find((d) => d.file === file);
464
- const inconsistency = trends.inconsistencies.find((i) => i.file === file);
465
- return {
466
- churn: hotspot?.changesInLast30Commits ?? 0,
467
- fixRatio: hotspot?.bugFixRatio ?? 0,
468
- coverageTrend: decay?.trend === "declining" ? "down" : decay?.trend === "improving" ? "up" : "stable",
469
- staleness: "",
470
- authorCount: hotspot?.authorCount ?? 0,
471
- consistency: inconsistency ? "low" : "high"
472
- };
473
- }
474
-
475
- // src/memory/signals/coverage-decline.ts
476
- var FALLBACK_PRECISION4 = 0.3;
477
- function computeCoverageDecline(db, repoPath, filePath) {
478
- const cal = getCalibration(db, "coverage_decline", FALLBACK_PRECISION4);
479
- let strength = 0;
480
- try {
481
- const entries = getGitLog(repoPath, 200);
482
- const trends = {
483
- hotspots: detectHotspots(entries, { threshold: 10, fixRatioThreshold: 0.5 }),
484
- decaySignals: detectDecay(entries),
485
- inconsistencies: detectInconsistencies(entries)
486
- };
487
- const health = computeHealthFromTrends(filePath, trends);
488
- if (health.coverageTrend === "down") strength = 1;
489
- } catch {
490
- strength = 0;
491
- }
492
- return {
493
- type: "coverage_decline",
494
- strength,
495
- precision: cal.precision,
496
- sample_size: cal.sampleSize,
497
- evidence: []
498
- };
499
- }
500
-
501
337
  // src/memory/signals/author-churn.ts
502
338
  var WINDOW_SECONDS2 = 90 * 86400;
503
339
  var INACTIVE_THRESHOLD = 5;
504
- var FALLBACK_PRECISION5 = 0.3;
340
+ var FALLBACK_PRECISION4 = 0.3;
505
341
  function computeAuthorChurn(db, filePath) {
506
- const cal = getCalibration(db, "author_churn", FALLBACK_PRECISION5);
342
+ const cal = getCalibration(db, "author_churn", FALLBACK_PRECISION4);
507
343
  const base = {
508
344
  type: "author_churn",
509
345
  precision: cal.precision,
@@ -519,8 +355,8 @@ function computeAuthorChurn(db, filePath) {
519
355
  LIMIT 1
520
356
  `).get(filePath);
521
357
  if (!lastTouch) return { ...base, strength: 0 };
522
- const now = Math.floor(Date.now() / 1e3);
523
- const lowerBound = now - WINDOW_SECONDS2;
358
+ const anchor = getDbMaxTimestamp(db) ?? Math.floor(Date.now() / 1e3);
359
+ const lowerBound = anchor - WINDOW_SECONDS2;
524
360
  const activity = db.prepare(`SELECT COUNT(*) AS n FROM commits WHERE author = ? AND timestamp >= ?`).get(lastTouch.author, lowerBound);
525
361
  let strength = 0;
526
362
  if (activity.n === 0) strength = 1;
@@ -529,12 +365,11 @@ function computeAuthorChurn(db, filePath) {
529
365
  }
530
366
 
531
367
  // src/memory/signals/index.ts
532
- function collectSignals(db, repoPath, filePath) {
368
+ function collectSignals(db, _repoPath, filePath) {
533
369
  return [
534
370
  computeRevertMatch(db, filePath),
535
371
  computeHotspot(db, filePath),
536
372
  computeFixRatio(db, filePath),
537
- computeCoverageDecline(db, repoPath, filePath),
538
373
  computeAuthorChurn(db, filePath)
539
374
  ];
540
375
  }
@@ -865,6 +700,24 @@ var MemoryAPI = class {
865
700
  });
866
701
  return this.bootstrapPromise;
867
702
  }
703
+ // bootstrapFromBoundary indexes only commits between fromSha and HEAD.
704
+ // Used by `composto index --since=YYYY-MM-DD` to bound work on huge repos.
705
+ // Pass fromSha=null to index the full history (same as bootstrapIfNeeded).
706
+ async bootstrapFromBoundary(fromSha) {
707
+ if (this.bootstrapPromise) return this.bootstrapPromise;
708
+ const head = revParseHead(this.repoPath);
709
+ const range = { from: fromSha, to: head };
710
+ this.bootstrapPromise = this.pool.runIngest({ dbPath: this.dbPath, repoPath: this.repoPath, range }).then(() => {
711
+ this.log.info("bootstrap_done", { through: range.to, from: range.from });
712
+ }).catch((err) => {
713
+ this.log.error("bootstrap_failed", { message: err.message });
714
+ this.failures.recordFailure("ingest_failure");
715
+ throw err;
716
+ }).finally(() => {
717
+ this.bootstrapPromise = null;
718
+ });
719
+ return this.bootstrapPromise;
720
+ }
868
721
  async blastradius(input) {
869
722
  const start = Date.now();
870
723
  if (this.failures.isDisabled()) {
@@ -17,7 +17,25 @@ function openDatabase(path) {
17
17
  }
18
18
 
19
19
  // src/memory/schema.ts
20
- var CURRENT_VERSION = 1;
20
+ var CURRENT_VERSION = 3;
21
+ var V2_SQL = `
22
+ CREATE INDEX IF NOT EXISTS idx_ft_file_commit ON file_touches(file_path, commit_sha);
23
+ `;
24
+ var V3_SQL = `
25
+ CREATE TABLE IF NOT EXISTS hook_invocations (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ timestamp INTEGER NOT NULL,
28
+ platform TEXT NOT NULL,
29
+ event TEXT NOT NULL,
30
+ file_path TEXT,
31
+ verdict TEXT,
32
+ score REAL,
33
+ confidence REAL,
34
+ latency_ms INTEGER NOT NULL,
35
+ cache_hit INTEGER NOT NULL
36
+ );
37
+ CREATE INDEX IF NOT EXISTS idx_hi_timestamp ON hook_invocations(timestamp);
38
+ `;
21
39
  var V1_SQL = `
22
40
  CREATE TABLE IF NOT EXISTS index_state (
23
41
  key TEXT PRIMARY KEY,
@@ -105,7 +123,9 @@ function runMigrations(db) {
105
123
  if (current >= CURRENT_VERSION) return;
106
124
  db.exec("BEGIN");
107
125
  try {
108
- db.exec(V1_SQL);
126
+ if (current < 1) db.exec(V1_SQL);
127
+ if (current < 2) db.exec(V2_SQL);
128
+ if (current < 3) db.exec(V3_SQL);
109
129
  db.pragma(`user_version = ${CURRENT_VERSION}`);
110
130
  db.exec("COMMIT");
111
131
  } catch (err) {
@@ -153,50 +173,40 @@ function deriveFixLinks(db) {
153
173
  (fix_commit_sha, suspected_break_sha, evidence_type, confidence, window_hours)
154
174
  VALUES (?, ?, ?, ?, ?)
155
175
  `);
156
- const reverts = db.prepare(`
157
- SELECT sha, reverts_sha FROM commits
158
- WHERE is_revert = 1 AND reverts_sha IS NOT NULL
159
- `).all();
160
- for (const r of reverts) {
161
- const exists = db.prepare("SELECT 1 FROM commits WHERE sha = ?").get(r.reverts_sha);
162
- if (exists) insert.run(r.sha, r.reverts_sha, "revert_marker", 1, null);
163
- }
164
- const fixes = db.prepare(`
165
- SELECT sha, timestamp FROM commits WHERE is_fix = 1
166
- `).all();
167
- const priorByFile = db.prepare(`
168
- SELECT c.sha AS prior_sha
176
+ const bulkFollowup = db.prepare(`
177
+ INSERT OR IGNORE INTO fix_links
178
+ (fix_commit_sha, suspected_break_sha, evidence_type, confidence, window_hours)
179
+ SELECT DISTINCT
180
+ ft_fix.commit_sha,
181
+ c_prior.sha,
182
+ 'short_followup_fix',
183
+ 0.7,
184
+ ?
169
185
  FROM file_touches ft_fix
186
+ JOIN commits c_fix ON c_fix.sha = ft_fix.commit_sha AND c_fix.is_fix = 1
170
187
  JOIN file_touches ft_prior ON ft_prior.file_path = ft_fix.file_path
171
- JOIN commits c ON c.sha = ft_prior.commit_sha
172
- WHERE ft_fix.commit_sha = ?
173
- AND c.timestamp < ?
174
- AND c.timestamp >= ?
175
- AND c.sha != ?
176
- AND c.is_fix = 0
177
- AND c.is_revert = 0
188
+ JOIN commits c_prior ON c_prior.sha = ft_prior.commit_sha
189
+ WHERE c_prior.timestamp < c_fix.timestamp
190
+ AND c_prior.timestamp >= c_fix.timestamp - ?
191
+ AND c_prior.sha != c_fix.sha
192
+ AND c_prior.is_fix = 0
193
+ AND c_prior.is_revert = 0
178
194
  `);
179
- for (const f of fixes) {
180
- const lowerBound = f.timestamp - FOLLOWUP_WINDOW_HOURS * 3600;
181
- const priors = priorByFile.all(f.sha, f.timestamp, lowerBound, f.sha);
182
- const unique = new Set(priors.map((p) => p.prior_sha));
183
- for (const prior of unique) {
184
- insert.run(f.sha, prior, "short_followup_fix", 0.7, FOLLOWUP_WINDOW_HOURS);
185
- }
186
- }
187
- const filesWithFixes = db.prepare(`
188
- SELECT DISTINCT ft.file_path
189
- FROM file_touches ft
190
- JOIN commits c ON c.sha = ft.commit_sha
191
- WHERE c.is_fix = 1
192
- `).all();
193
- const fixesOnFile = db.prepare(`
194
- SELECT c.sha, c.timestamp
195
+ const bulkRevert = db.prepare(`
196
+ INSERT OR IGNORE INTO fix_links
197
+ (fix_commit_sha, suspected_break_sha, evidence_type, confidence, window_hours)
198
+ SELECT c.sha, c.reverts_sha, 'revert_marker', 1.0, NULL
195
199
  FROM commits c
196
- JOIN file_touches ft ON ft.commit_sha = c.sha
197
- WHERE ft.file_path = ? AND c.is_fix = 1
198
- ORDER BY c.timestamp ASC
200
+ JOIN commits target ON target.sha = c.reverts_sha
201
+ WHERE c.is_revert = 1 AND c.reverts_sha IS NOT NULL
199
202
  `);
203
+ const allFixesByFile = db.prepare(`
204
+ SELECT ft.file_path, c.sha, c.timestamp
205
+ FROM commits c
206
+ JOIN file_touches ft ON ft.commit_sha = c.sha
207
+ WHERE c.is_fix = 1
208
+ ORDER BY ft.file_path, c.timestamp ASC
209
+ `).all();
200
210
  const priorNonFixOnFile = db.prepare(`
201
211
  SELECT c.sha
202
212
  FROM commits c
@@ -207,19 +217,29 @@ function deriveFixLinks(db) {
207
217
  ORDER BY c.timestamp DESC
208
218
  LIMIT 1
209
219
  `);
210
- for (const { file_path } of filesWithFixes) {
211
- const rows = fixesOnFile.all(file_path);
220
+ const derive = db.transaction(() => {
221
+ bulkRevert.run();
222
+ bulkFollowup.run(FOLLOWUP_WINDOW_HOURS, FOLLOWUP_WINDOW_HOURS * 3600);
212
223
  const windowSec = CHAIN_WINDOW_DAYS * 86400;
213
- for (let i = 0; i + CHAIN_MIN - 1 < rows.length; i++) {
214
- const windowEnd = rows[i + CHAIN_MIN - 1];
215
- if (windowEnd.timestamp - rows[i].timestamp > windowSec) continue;
216
- const prior = priorNonFixOnFile.get(file_path, rows[i].timestamp);
217
- if (!prior) continue;
218
- for (let j = i; j < rows.length && rows[j].timestamp - rows[i].timestamp <= windowSec; j++) {
219
- insert.run(rows[j].sha, prior.sha, "same_region_fix_chain", 0.4, CHAIN_WINDOW_DAYS * 24);
224
+ let i = 0;
225
+ while (i < allFixesByFile.length) {
226
+ let j = i;
227
+ while (j < allFixesByFile.length && allFixesByFile[j].file_path === allFixesByFile[i].file_path) j++;
228
+ const rows = allFixesByFile.slice(i, j);
229
+ const file_path = rows[0].file_path;
230
+ i = j;
231
+ for (let k = 0; k + CHAIN_MIN - 1 < rows.length; k++) {
232
+ const windowEnd = rows[k + CHAIN_MIN - 1];
233
+ if (windowEnd.timestamp - rows[k].timestamp > windowSec) continue;
234
+ const prior = priorNonFixOnFile.get(file_path, rows[k].timestamp);
235
+ if (!prior) continue;
236
+ for (let m = k; m < rows.length && rows[m].timestamp - rows[k].timestamp <= windowSec; m++) {
237
+ insert.run(rows[m].sha, prior.sha, "same_region_fix_chain", 0.4, CHAIN_WINDOW_DAYS * 24);
238
+ }
220
239
  }
221
240
  }
222
- }
241
+ });
242
+ derive();
223
243
  }
224
244
 
225
245
  // src/memory/calibration.ts
@@ -258,9 +278,6 @@ function validateFixRatio(db) {
258
278
  `).get().n;
259
279
  return { total, hits };
260
280
  }
261
- function validateCoverageDecline(_db) {
262
- return { total: 0, hits: 0 };
263
- }
264
281
  function validateAuthorChurn(db) {
265
282
  const total = db.prepare(`SELECT COUNT(*) AS n FROM file_touches`).get().n;
266
283
  const hits = db.prepare(`SELECT COUNT(*) AS n FROM fix_links`).get().n;
@@ -270,7 +287,6 @@ var VALIDATORS = {
270
287
  revert_match: validateRevertMatch,
271
288
  hotspot: validateHotspot,
272
289
  fix_ratio: validateFixRatio,
273
- coverage_decline: validateCoverageDecline,
274
290
  author_churn: validateAuthorChurn
275
291
  };
276
292
  function refreshCalibration(db, headSha) {
@@ -354,10 +370,24 @@ function parseLogOutput(output) {
354
370
  }
355
371
  return commits;
356
372
  }
373
+ function resolveRevertsSha(raw, knownShas) {
374
+ if (!raw) return null;
375
+ if (knownShas.has(raw)) return raw;
376
+ if (raw.length < 40) {
377
+ for (const sha of knownShas) {
378
+ if (sha.startsWith(raw)) return sha;
379
+ }
380
+ }
381
+ return null;
382
+ }
357
383
  function ingestRange(db, repoPath, range) {
358
384
  const raw = logRange(repoPath, range.from, range.to);
359
385
  const commits = parseLogOutput(raw);
360
386
  commits.sort((a, b) => a.timestamp - b.timestamp);
387
+ const knownShas = new Set(commits.map((c) => c.sha));
388
+ for (const existing of db.prepare(`SELECT sha FROM commits`).all()) {
389
+ knownShas.add(existing.sha);
390
+ }
361
391
  const insertCommit = db.prepare(`
362
392
  INSERT OR IGNORE INTO commits
363
393
  (sha, parent_sha, author, timestamp, subject, is_fix, is_revert, reverts_sha)
@@ -383,16 +413,21 @@ function ingestRange(db, repoPath, range) {
383
413
  c.subject,
384
414
  parsed.is_fix ? 1 : 0,
385
415
  parsed.is_revert ? 1 : 0,
386
- parsed.reverts_sha
416
+ resolveRevertsSha(parsed.reverts_sha, knownShas)
387
417
  );
388
418
  for (const t of c.touches) {
389
419
  insertTouch.run(c.sha, t.file_path, t.adds, t.dels, t.change_type);
390
420
  }
391
421
  }
392
422
  });
393
- const BATCH = 1e3;
394
- for (let i = 0; i < commits.length; i += BATCH) {
395
- tx(commits.slice(i, i + BATCH));
423
+ db.pragma("foreign_keys = OFF");
424
+ try {
425
+ const BATCH = 1e3;
426
+ for (let i = 0; i < commits.length; i += BATCH) {
427
+ tx(commits.slice(i, i + BATCH));
428
+ }
429
+ } finally {
430
+ db.pragma("foreign_keys = ON");
396
431
  }
397
432
  deriveFixLinks(db);
398
433
  if (shouldRefresh(db, range.to)) {