@monoes/monograph 1.4.0 → 1.5.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 (229) hide show
  1. package/dist/src/analysis/benchmark.js.map +1 -1
  2. package/dist/src/analysis/changed-files.d.ts.map +1 -1
  3. package/dist/src/analysis/changed-files.js +11 -2
  4. package/dist/src/analysis/changed-files.js.map +1 -1
  5. package/dist/src/analysis/changed-workspaces.d.ts.map +1 -1
  6. package/dist/src/analysis/changed-workspaces.js +11 -3
  7. package/dist/src/analysis/changed-workspaces.js.map +1 -1
  8. package/dist/src/analysis/churn.d.ts +1 -1
  9. package/dist/src/analysis/churn.d.ts.map +1 -1
  10. package/dist/src/analysis/churn.js +56 -10
  11. package/dist/src/analysis/churn.js.map +1 -1
  12. package/dist/src/analysis/feature-flags.d.ts.map +1 -1
  13. package/dist/src/analysis/feature-flags.js +11 -5
  14. package/dist/src/analysis/feature-flags.js.map +1 -1
  15. package/dist/src/analysis/progress.d.ts.map +1 -1
  16. package/dist/src/analysis/project-state.d.ts.map +1 -1
  17. package/dist/src/analysis/shape-extractor.js.map +1 -1
  18. package/dist/src/analysis/suppression-context.d.ts.map +1 -1
  19. package/dist/src/analysis/surprise-scorer.js.map +1 -1
  20. package/dist/src/analysis/targets.d.ts.map +1 -1
  21. package/dist/src/analysis/worker-pool.d.ts.map +1 -1
  22. package/dist/src/analysis/worker-pool.js.map +1 -1
  23. package/dist/src/analyze/unused-deps.js.map +1 -1
  24. package/dist/src/analyze/unused-exports.js.map +1 -1
  25. package/dist/src/analyze/unused-files.js.map +1 -1
  26. package/dist/src/cache/augment-cache.js.map +1 -1
  27. package/dist/src/cache/extraction-cache.d.ts +21 -0
  28. package/dist/src/cache/extraction-cache.d.ts.map +1 -1
  29. package/dist/src/cache/extraction-cache.js +62 -6
  30. package/dist/src/cache/extraction-cache.js.map +1 -1
  31. package/dist/src/check/index.js.map +1 -1
  32. package/dist/src/cli/check-update.js.map +1 -1
  33. package/dist/src/cli/doctor.d.ts.map +1 -1
  34. package/dist/src/cli/doctor.js +6 -10
  35. package/dist/src/cli/doctor.js.map +1 -1
  36. package/dist/src/config/boundary-config.js.map +1 -1
  37. package/dist/src/config/config-parsing.d.ts +1 -1
  38. package/dist/src/config/config-parsing.d.ts.map +1 -1
  39. package/dist/src/config/migration-types.d.ts +2 -2
  40. package/dist/src/config/migration-types.d.ts.map +1 -1
  41. package/dist/src/config/migration-types.js.map +1 -1
  42. package/dist/src/config/used-class-members.js.map +1 -1
  43. package/dist/src/config/workspace.js.map +1 -1
  44. package/dist/src/coverage/upload-source-maps.js.map +1 -1
  45. package/dist/src/duplicates/detect/filtering.d.ts.map +1 -1
  46. package/dist/src/duplicates/shingle-filter.js.map +1 -1
  47. package/dist/src/error/fallow-error.d.ts.map +1 -1
  48. package/dist/src/error/fallow-error.js.map +1 -1
  49. package/dist/src/error.d.ts.map +1 -1
  50. package/dist/src/error.js.map +1 -1
  51. package/dist/src/export/analysis-json.js.map +1 -1
  52. package/dist/src/export/badge.js.map +1 -1
  53. package/dist/src/export/dot.js.map +1 -1
  54. package/dist/src/export/sarif.js.map +1 -1
  55. package/dist/src/fix/orchestrator.js.map +1 -1
  56. package/dist/src/graph/cycle-detection.js.map +1 -1
  57. package/dist/src/graph/dead-code.d.ts +7 -2
  58. package/dist/src/graph/dead-code.d.ts.map +1 -1
  59. package/dist/src/graph/dead-code.js +19 -16
  60. package/dist/src/graph/dead-code.js.map +1 -1
  61. package/dist/src/graph/dfs.js.map +1 -1
  62. package/dist/src/graph/edge-filter.js.map +1 -1
  63. package/dist/src/graph/hotspots.js.map +1 -1
  64. package/dist/src/graph/import-chain.js.map +1 -1
  65. package/dist/src/graph/merge.js.map +1 -1
  66. package/dist/src/graph/node-search.js.map +1 -1
  67. package/dist/src/graph/node-types.d.ts.map +1 -1
  68. package/dist/src/graph/pagerank.d.ts.map +1 -1
  69. package/dist/src/graph/pagerank.js +10 -1
  70. package/dist/src/graph/pagerank.js.map +1 -1
  71. package/dist/src/graph/regex-search.js.map +1 -1
  72. package/dist/src/graph/regression.d.ts.map +1 -1
  73. package/dist/src/graph/resolve/discover-types.js.map +1 -1
  74. package/dist/src/graph/resolve/types.d.ts +3 -3
  75. package/dist/src/graph/resolve/types.d.ts.map +1 -1
  76. package/dist/src/groups/group-search.d.ts.map +1 -1
  77. package/dist/src/groups/group-search.js +9 -18
  78. package/dist/src/groups/group-search.js.map +1 -1
  79. package/dist/src/health/complexity-findings.js.map +1 -1
  80. package/dist/src/health/grouping.js.map +1 -1
  81. package/dist/src/health/health-report-types.js.map +1 -1
  82. package/dist/src/health/health-report.js.map +1 -1
  83. package/dist/src/health/targets.js.map +1 -1
  84. package/dist/src/ingest/transcribe.js.map +1 -1
  85. package/dist/src/ingest/url-ingest.js.map +1 -1
  86. package/dist/src/license/manager.d.ts.map +1 -1
  87. package/dist/src/license/manager.js.map +1 -1
  88. package/dist/src/lsp/diagnostics-push.js.map +1 -1
  89. package/dist/src/mcp-tools/impact.d.ts.map +1 -1
  90. package/dist/src/mcp-tools/impact.js +7 -1
  91. package/dist/src/mcp-tools/impact.js.map +1 -1
  92. package/dist/src/mcp-tools/list-repos.d.ts +1 -1
  93. package/dist/src/mcp-tools/list-repos.d.ts.map +1 -1
  94. package/dist/src/mcp-tools/query.d.ts +1 -1
  95. package/dist/src/mcp-tools/query.d.ts.map +1 -1
  96. package/dist/src/parsers/c.js.map +1 -1
  97. package/dist/src/parsers/cpp.js.map +1 -1
  98. package/dist/src/parsers/csharp.js.map +1 -1
  99. package/dist/src/parsers/dart.js.map +1 -1
  100. package/dist/src/parsers/go.js.map +1 -1
  101. package/dist/src/parsers/java.js.map +1 -1
  102. package/dist/src/parsers/kotlin.js.map +1 -1
  103. package/dist/src/parsers/loader.js.map +1 -1
  104. package/dist/src/parsers/php.js.map +1 -1
  105. package/dist/src/parsers/python.js.map +1 -1
  106. package/dist/src/parsers/ruby.js.map +1 -1
  107. package/dist/src/parsers/rust.js.map +1 -1
  108. package/dist/src/parsers/swift.js.map +1 -1
  109. package/dist/src/parsers/typescript.js.map +1 -1
  110. package/dist/src/parsers/vue.js.map +1 -1
  111. package/dist/src/pipeline/effort.js.map +1 -1
  112. package/dist/src/pipeline/orchestrator.d.ts.map +1 -1
  113. package/dist/src/pipeline/orchestrator.js +48 -24
  114. package/dist/src/pipeline/orchestrator.js.map +1 -1
  115. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.d.ts +11 -0
  116. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.d.ts.map +1 -0
  117. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.js +96 -0
  118. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.js.map +1 -0
  119. package/dist/src/pipeline/phases/bridge-adapters/registry.d.ts +3 -0
  120. package/dist/src/pipeline/phases/bridge-adapters/registry.d.ts.map +1 -0
  121. package/dist/src/pipeline/phases/bridge-adapters/registry.js +13 -0
  122. package/dist/src/pipeline/phases/bridge-adapters/registry.js.map +1 -0
  123. package/dist/src/pipeline/phases/bridge-adapters/tauri.d.ts +10 -0
  124. package/dist/src/pipeline/phases/bridge-adapters/tauri.d.ts.map +1 -0
  125. package/dist/src/pipeline/phases/bridge-adapters/tauri.js +104 -0
  126. package/dist/src/pipeline/phases/bridge-adapters/tauri.js.map +1 -0
  127. package/dist/src/pipeline/phases/bridge-adapters/types.d.ts +30 -0
  128. package/dist/src/pipeline/phases/bridge-adapters/types.d.ts.map +1 -0
  129. package/dist/src/pipeline/phases/bridge-adapters/types.js +2 -0
  130. package/dist/src/pipeline/phases/bridge-adapters/types.js.map +1 -0
  131. package/dist/src/pipeline/phases/bridge-adapters/wails.d.ts +10 -0
  132. package/dist/src/pipeline/phases/bridge-adapters/wails.d.ts.map +1 -0
  133. package/dist/src/pipeline/phases/bridge-adapters/wails.js +27 -0
  134. package/dist/src/pipeline/phases/bridge-adapters/wails.js.map +1 -0
  135. package/dist/src/pipeline/phases/bridge-resolver.d.ts +23 -0
  136. package/dist/src/pipeline/phases/bridge-resolver.d.ts.map +1 -0
  137. package/dist/src/pipeline/phases/bridge-resolver.js +70 -0
  138. package/dist/src/pipeline/phases/bridge-resolver.js.map +1 -0
  139. package/dist/src/pipeline/phases/cross-file.d.ts.map +1 -1
  140. package/dist/src/pipeline/phases/cross-file.js +70 -27
  141. package/dist/src/pipeline/phases/cross-file.js.map +1 -1
  142. package/dist/src/pipeline/phases/leiden.js.map +1 -1
  143. package/dist/src/pipeline/phases/parse.d.ts.map +1 -1
  144. package/dist/src/pipeline/phases/parse.js +58 -17
  145. package/dist/src/pipeline/phases/parse.js.map +1 -1
  146. package/dist/src/pipeline/phases/scan.d.ts.map +1 -1
  147. package/dist/src/pipeline/phases/scan.js +12 -5
  148. package/dist/src/pipeline/phases/scan.js.map +1 -1
  149. package/dist/src/pipeline/phases/scope-resolution.d.ts +13 -0
  150. package/dist/src/pipeline/phases/scope-resolution.d.ts.map +1 -1
  151. package/dist/src/pipeline/phases/scope-resolution.js +18 -4
  152. package/dist/src/pipeline/phases/scope-resolution.js.map +1 -1
  153. package/dist/src/pipeline/progress.d.ts.map +1 -1
  154. package/dist/src/pipeline/runner.d.ts.map +1 -1
  155. package/dist/src/pipeline/runner.js.map +1 -1
  156. package/dist/src/pipeline/types.d.ts +2 -0
  157. package/dist/src/pipeline/types.d.ts.map +1 -1
  158. package/dist/src/pipeline/types.js +1 -0
  159. package/dist/src/pipeline/types.js.map +1 -1
  160. package/dist/src/regression/baseline.js.map +1 -1
  161. package/dist/src/regression/outcome.js.map +1 -1
  162. package/dist/src/report/badge-svg.js.map +1 -1
  163. package/dist/src/report/duplication-grouping.js.map +1 -1
  164. package/dist/src/report/human-check.js.map +1 -1
  165. package/dist/src/report/human-dupes.js.map +1 -1
  166. package/dist/src/report/human-health.js.map +1 -1
  167. package/dist/src/report/json-builders.js.map +1 -1
  168. package/dist/src/report/number-format.js.map +1 -1
  169. package/dist/src/reporting/graph-report.js.map +1 -1
  170. package/dist/src/scope-resolution/evidence-weights.d.ts.map +1 -1
  171. package/dist/src/search/hybrid-query.js.map +1 -1
  172. package/dist/src/security/safe-fetch.js.map +1 -1
  173. package/dist/src/storage/db.d.ts +13 -1
  174. package/dist/src/storage/db.d.ts.map +1 -1
  175. package/dist/src/storage/db.js +17 -1
  176. package/dist/src/storage/db.js.map +1 -1
  177. package/dist/src/storage/edge-store.d.ts +6 -0
  178. package/dist/src/storage/edge-store.d.ts.map +1 -1
  179. package/dist/src/storage/edge-store.js +8 -1
  180. package/dist/src/storage/edge-store.js.map +1 -1
  181. package/dist/src/storage/fts-store.d.ts +7 -0
  182. package/dist/src/storage/fts-store.d.ts.map +1 -1
  183. package/dist/src/storage/fts-store.js +153 -10
  184. package/dist/src/storage/fts-store.js.map +1 -1
  185. package/dist/src/storage/node-store.js.map +1 -1
  186. package/dist/src/storage/unlinked-refs.js.map +1 -1
  187. package/dist/src/types.d.ts +2 -2
  188. package/dist/src/types.d.ts.map +1 -1
  189. package/dist/src/types.js.map +1 -1
  190. package/dist/src/watch/watcher.d.ts.map +1 -1
  191. package/dist/src/watch/watcher.js +12 -2
  192. package/dist/src/watch/watcher.js.map +1 -1
  193. package/dist/src/web/analyze-api.js.map +1 -1
  194. package/dist/src/web/api.js.map +1 -1
  195. package/dist/src/wiki/providers.js.map +1 -1
  196. package/dist/tsconfig.tsbuildinfo +1 -1
  197. package/package.json +5 -2
  198. package/src/__tests__/graph/dead-code.test.ts +27 -7
  199. package/src/__tests__/pipeline/phases/bridge-adapters/electron-ipc.test.ts +87 -0
  200. package/src/__tests__/pipeline/phases/bridge-adapters/tauri.test.ts +116 -0
  201. package/src/__tests__/pipeline/phases/bridge-adapters/wails.test.ts +81 -0
  202. package/src/__tests__/pipeline/phases/bridge-resolver.test.ts +110 -0
  203. package/src/analysis/changed-files.ts +14 -2
  204. package/src/analysis/changed-workspaces.ts +13 -4
  205. package/src/analysis/churn.ts +57 -10
  206. package/src/analysis/feature-flags.ts +10 -5
  207. package/src/cache/extraction-cache.ts +59 -6
  208. package/src/cli/doctor.ts +6 -10
  209. package/src/graph/dead-code.ts +19 -16
  210. package/src/graph/pagerank.ts +10 -1
  211. package/src/groups/group-search.ts +9 -27
  212. package/src/mcp-tools/impact.ts +7 -1
  213. package/src/pipeline/orchestrator.ts +50 -26
  214. package/src/pipeline/phases/bridge-adapters/electron-ipc.ts +91 -0
  215. package/src/pipeline/phases/bridge-adapters/registry.ts +14 -0
  216. package/src/pipeline/phases/bridge-adapters/tauri.ts +103 -0
  217. package/src/pipeline/phases/bridge-adapters/types.ts +31 -0
  218. package/src/pipeline/phases/bridge-adapters/wails.ts +33 -0
  219. package/src/pipeline/phases/bridge-resolver.ts +77 -0
  220. package/src/pipeline/phases/cross-file.ts +75 -27
  221. package/src/pipeline/phases/parse.ts +60 -17
  222. package/src/pipeline/phases/scan.ts +11 -5
  223. package/src/pipeline/phases/scope-resolution.ts +19 -4
  224. package/src/pipeline/types.ts +3 -0
  225. package/src/storage/db.ts +27 -1
  226. package/src/storage/edge-store.ts +8 -1
  227. package/src/storage/fts-store.ts +158 -10
  228. package/src/watch/watcher.ts +11 -2
  229. package/tsconfig.json +2 -1
@@ -1,4 +1,4 @@
1
- import { readFileSync, readdirSync, statSync } from 'fs';
1
+ import { readFileSync, readdirSync, lstatSync } from 'fs';
2
2
  import { join, extname } from 'path';
3
3
 
4
4
  export type FlagKind = 'EnvironmentVariable' | 'SdkCall' | 'ConfigObject';
@@ -170,13 +170,18 @@ export function crossReferenceWithDeadCode(
170
170
  }
171
171
 
172
172
  function walkDir(dir: string, fn: (filePath: string) => void): void {
173
- let entries: string[];
174
- try { entries = readdirSync(dir); } catch { return; }
175
- for (const entry of entries) {
173
+ let dirents: import('fs').Dirent[];
174
+ try { dirents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
175
+ for (const dirent of dirents) {
176
+ const entry = dirent.name;
176
177
  if (entry === 'node_modules' || entry === '.git' || entry === 'dist' || entry === 'build') continue;
178
+ // Never follow symlinks — avoids infinite recursion on a symlink pointing to
179
+ // an ancestor directory, and avoids indexing a symlinked external tree as if
180
+ // it were part of the repo. Matches scan.ts's walk().
181
+ if (dirent.isSymbolicLink()) continue;
177
182
  const full = join(dir, entry);
178
183
  let st;
179
- try { st = statSync(full); } catch { continue; }
184
+ try { st = lstatSync(full); } catch { continue; }
180
185
  if (st.isDirectory()) walkDir(full, fn);
181
186
  else if (st.isFile()) fn(full);
182
187
  }
@@ -1,14 +1,25 @@
1
1
  import { createHash } from 'crypto';
2
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs';
2
+ import { readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync, mkdirSync, existsSync, statSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import type { MonographNode, MonographEdge } from '../types.js';
5
5
 
6
+ /**
7
+ * Bump this whenever the parser/extractor output format changes in a way that
8
+ * would make previously-cached nodes/edges stale or wrong (e.g. a parser bugfix,
9
+ * a change to what symbols get extracted, a change to node/edge shape). Any
10
+ * cached entry written under an older version is treated as a miss on read, so
11
+ * bumping this effectively invalidates the entire on-disk extraction cache.
12
+ */
13
+ export const EXTRACTION_CACHE_VERSION = 1;
14
+
6
15
  export interface CacheEntry {
7
16
  fileHash: string;
8
17
  mtimeMs?: number;
9
18
  size?: number;
10
19
  nodes: MonographNode[];
11
20
  edges: MonographEdge[];
21
+ /** Extraction format version this entry was written under — see EXTRACTION_CACHE_VERSION. */
22
+ cacheVersion?: number;
12
23
  }
13
24
 
14
25
  export class ExtractionCache {
@@ -32,6 +43,46 @@ export class ExtractionCache {
32
43
  return join(this.dir, `${key}.json`);
33
44
  }
34
45
 
46
+ /** tmp+rename write — cache corruption is low-stakes but a torn write from a
47
+ * killed process still shouldn't poison the next read. */
48
+ private writeAtomic(p: string, data: string): void {
49
+ const tmp = `${p}.${process.pid}.tmp`;
50
+ try {
51
+ writeFileSync(tmp, data);
52
+ renameSync(tmp, p);
53
+ } catch {
54
+ try { unlinkSync(tmp); } catch { /* ignore */ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Delete cache entries whose mtime exceeds maxAgeMs (default 30 days).
60
+ * Covers files that were deleted/renamed (their cache entry is now orphaned
61
+ * and would otherwise sit on disk forever) as well as entries simply not
62
+ * touched in a long time. Single readdir + stat pass over the cache dir.
63
+ * Returns the number of entries removed.
64
+ */
65
+ prune(maxAgeMs = 30 * 24 * 60 * 60 * 1000): number {
66
+ let removed = 0;
67
+ let entries: string[];
68
+ try {
69
+ entries = readdirSync(this.dir).filter((f) => f.endsWith('.json') && !f.startsWith('._'));
70
+ } catch {
71
+ return 0;
72
+ }
73
+ const cutoff = Date.now() - maxAgeMs;
74
+ for (const f of entries) {
75
+ const p = join(this.dir, f);
76
+ try {
77
+ if (statSync(p).mtimeMs < cutoff) {
78
+ unlinkSync(p);
79
+ removed++;
80
+ }
81
+ } catch { /* ignore */ }
82
+ }
83
+ return removed;
84
+ }
85
+
35
86
  /**
36
87
  * Fast-path: check mtime+size before falling back to content hash.
37
88
  * Returns cached entry if file hasn't changed, null on miss.
@@ -41,6 +92,7 @@ export class ExtractionCache {
41
92
  if (!existsSync(p)) return null;
42
93
  try {
43
94
  const entry: CacheEntry = JSON.parse(readFileSync(p, 'utf-8'));
95
+ if (entry.cacheVersion !== EXTRACTION_CACHE_VERSION) return null;
44
96
  const st = statSync(filePath);
45
97
  if (entry.mtimeMs === st.mtimeMs && entry.size === st.size) return entry;
46
98
  // mtime/size differ or missing — recheck content hash
@@ -49,7 +101,7 @@ export class ExtractionCache {
49
101
  // Hash matches — update entry with current mtime+size for next run
50
102
  entry.mtimeMs = st.mtimeMs;
51
103
  entry.size = st.size;
52
- try { writeFileSync(p, JSON.stringify(entry)); } catch { /* non-fatal */ }
104
+ try { this.writeAtomic(p, JSON.stringify(entry)); } catch { /* non-fatal */ }
53
105
  return entry;
54
106
  } catch { return null; }
55
107
  }
@@ -59,6 +111,7 @@ export class ExtractionCache {
59
111
  if (!existsSync(p)) return null;
60
112
  try {
61
113
  const entry: CacheEntry = JSON.parse(readFileSync(p, 'utf-8'));
114
+ if (entry.cacheVersion !== EXTRACTION_CACHE_VERSION) return null;
62
115
  return entry.fileHash === fileHash ? entry : null;
63
116
  } catch { return null; }
64
117
  }
@@ -67,21 +120,21 @@ export class ExtractionCache {
67
120
  let mtimeMs: number | undefined;
68
121
  let size: number | undefined;
69
122
  try { const st = statSync(filePath); mtimeMs = st.mtimeMs; size = st.size; } catch { /* ignore */ }
70
- const entry: CacheEntry = { fileHash, mtimeMs, size, nodes, edges };
71
- writeFileSync(this.entryPath(filePath), JSON.stringify(entry));
123
+ const entry: CacheEntry = { fileHash, mtimeMs, size, nodes, edges, cacheVersion: EXTRACTION_CACHE_VERSION };
124
+ this.writeAtomic(this.entryPath(filePath), JSON.stringify(entry));
72
125
  }
73
126
 
74
127
  setDeferred(filePath: string, fileHash: string, nodes: MonographNode[], edges: MonographEdge[]): void {
75
128
  let mtimeMs: number | undefined;
76
129
  let size: number | undefined;
77
130
  try { const st = statSync(filePath); mtimeMs = st.mtimeMs; size = st.size; } catch { /* ignore */ }
78
- const entry: CacheEntry = { fileHash, mtimeMs, size, nodes, edges };
131
+ const entry: CacheEntry = { fileHash, mtimeMs, size, nodes, edges, cacheVersion: EXTRACTION_CACHE_VERSION };
79
132
  this.pending.push({ path: this.entryPath(filePath), data: JSON.stringify(entry) });
80
133
  }
81
134
 
82
135
  flush(): void {
83
136
  for (const { path, data } of this.pending) {
84
- try { writeFileSync(path, data); } catch { /* non-fatal */ }
137
+ try { this.writeAtomic(path, data); } catch { /* non-fatal */ }
85
138
  }
86
139
  this.pending = [];
87
140
  }
package/src/cli/doctor.ts CHANGED
@@ -51,12 +51,9 @@ function checkDbExists(dbPath: string): DoctorCheck {
51
51
  };
52
52
  }
53
53
 
54
- function checkDbReadable(dbPath: string): DoctorCheck {
54
+ async function checkDbReadable(dbPath: string): Promise<DoctorCheck> {
55
55
  try {
56
- // Dynamic require keeps this check lazy so missing better-sqlite3 doesn't
57
- // crash the entire doctor invocation.
58
- // eslint-disable-next-line @typescript-eslint/no-var-requires
59
- const Database = require('better-sqlite3') as typeof import('better-sqlite3');
56
+ const Database = (await import('better-sqlite3')).default;
60
57
  const db = new Database(dbPath, { readonly: true });
61
58
  db.prepare('SELECT 1').get();
62
59
  db.close();
@@ -67,10 +64,9 @@ function checkDbReadable(dbPath: string): DoctorCheck {
67
64
  }
68
65
  }
69
66
 
70
- function checkNodeCount(dbPath: string): DoctorCheck {
67
+ async function checkNodeCount(dbPath: string): Promise<DoctorCheck> {
71
68
  try {
72
- // eslint-disable-next-line @typescript-eslint/no-var-requires
73
- const Database = require('better-sqlite3') as typeof import('better-sqlite3');
69
+ const Database = (await import('better-sqlite3')).default;
74
70
  const db = new Database(dbPath, { readonly: true });
75
71
  const row = db.prepare('SELECT COUNT(*) AS n FROM nodes').get() as { n: number } | undefined;
76
72
  db.close();
@@ -159,8 +155,8 @@ export async function runDoctor(repoPath: string): Promise<DoctorResult> {
159
155
 
160
156
  // 3. DB readable (only if DB exists)
161
157
  if (dbExistsCheck.status !== 'error') {
162
- checks.push(checkDbReadable(dbPath));
163
- checks.push(checkNodeCount(dbPath));
158
+ checks.push(await checkDbReadable(dbPath));
159
+ checks.push(await checkNodeCount(dbPath));
164
160
  } else {
165
161
  checks.push({
166
162
  name: 'SQLite DB readable',
@@ -11,8 +11,13 @@ import type { MonographDb } from '../storage/db.js';
11
11
  * - Only exported — private locals are internal by design
12
12
  * - Skips entry-point, test, dist, and node_modules paths
13
13
  * - Requires zero inbound CALLS, IMPORTS, REFERENCES, and RE_EXPORTS edges
14
- * - No same-name node in another file (catches import bindings)
15
- * - Not re-exported through any index.ts barrel file
14
+ * - Not re-exported through any index.ts barrel file, via an actual RE_EXPORTS
15
+ * edge from that barrel's File node (not merely sharing a name with something
16
+ * an index.ts happens to also define)
17
+ *
18
+ * Known gap: files consumed only via a dynamic `import()` call won't have a static
19
+ * edge in the graph, so they can still surface as false positives here — this
20
+ * detector only tracks statically-resolved edges.
16
21
  */
17
22
  export function detectDeadCode(db: MonographDb): string[] {
18
23
  return detectDeadCodeNodes(db).map(n => n.id);
@@ -58,21 +63,19 @@ export function detectDeadCodeNodes(db: MonographDb): DeadCodeNode[] {
58
63
  WHERE e.target_id = n.id
59
64
  AND e.relation IN ('CALLS', 'IMPORTS', 'REFERENCES', 'RE_EXPORTS')
60
65
  )
61
- -- no same-name node in another file (import bindings, .cjs callers, etc.)
62
- AND NOT EXISTS (
63
- SELECT 1 FROM nodes o
64
- WHERE o.name = n.name AND o.file_path != n.file_path
65
- AND o.file_path IS NOT NULL
66
- AND o.file_path NOT LIKE '%/dist/%'
67
- AND o.file_path NOT LIKE '%node_modules%'
68
- )
69
- -- not re-exported through a barrel (index.ts/index.js)
66
+ -- not re-exported through a barrel (index.ts/index.js) checked via an
67
+ -- ACTUAL RE_EXPORTS edge from the barrel's File node to this candidate's
68
+ -- File node, not a same-NAME coincidence with an unrelated symbol that
69
+ -- happens to also live in some index.ts elsewhere in the repo.
70
70
  AND NOT EXISTS (
71
- SELECT 1 FROM nodes b
72
- WHERE b.name = n.name
73
- AND b.file_path LIKE '%/index.%'
74
- AND b.file_path NOT LIKE '%/dist/%'
75
- AND b.file_path NOT LIKE '%node_modules%'
71
+ SELECT 1 FROM edges e
72
+ JOIN nodes fn ON fn.id = e.target_id AND fn.label = 'File'
73
+ JOIN nodes bn ON bn.id = e.source_id AND bn.label = 'File'
74
+ WHERE e.relation = 'RE_EXPORTS'
75
+ AND fn.file_path = n.file_path
76
+ AND bn.file_path LIKE '%/index.%'
77
+ AND bn.file_path NOT LIKE '%/dist/%'
78
+ AND bn.file_path NOT LIKE '%node_modules%'
76
79
  )
77
80
  ORDER BY n.file_path, n.start_line`,
78
81
  )
@@ -24,6 +24,10 @@ function getStmts(db: MonographDb): StmtCache {
24
24
  const key = (db as unknown as { name: string }).name ?? '__default__';
25
25
  let cache = _stmtCache.get(key);
26
26
  if (!cache) {
27
+ if (_stmtCache.size >= MAX_STMT_CACHE) {
28
+ const oldest = _stmtCache.keys().next().value!;
29
+ _stmtCache.delete(oldest);
30
+ }
27
31
  cache = {
28
32
  selectNodes: db.prepare('SELECT id FROM nodes'),
29
33
  selectEdges: db.prepare('SELECT source_id, target_id FROM edges'),
@@ -38,6 +42,8 @@ function getStmts(db: MonographDb): StmtCache {
38
42
  // changed. Keyed by (dbName, nodeCount, edgeCount) with a 5-second TTL.
39
43
  // ---------------------------------------------------------------------------
40
44
  const PAGERANK_CACHE_TTL_MS = 5_000;
45
+ const MAX_RESULT_CACHE = 50;
46
+ const MAX_STMT_CACHE = 20;
41
47
 
42
48
  interface PageRankCacheEntry {
43
49
  result: Map<string, number>;
@@ -149,7 +155,10 @@ export function pageRank(db: MonographDb, options: PageRankOptions = {}): Map<st
149
155
  result.set(nodes[i], scores[i]);
150
156
  }
151
157
 
152
- // Store in result cache
158
+ if (_resultCache.size >= MAX_RESULT_CACHE) {
159
+ const oldest = _resultCache.keys().next().value!;
160
+ _resultCache.delete(oldest);
161
+ }
153
162
  _resultCache.set(cacheKey, { result, expiresAt: now + PAGERANK_CACHE_TTL_MS });
154
163
 
155
164
  return result;
@@ -8,6 +8,7 @@ import { join } from 'path';
8
8
  import { existsSync } from 'fs';
9
9
  import Database from 'better-sqlite3';
10
10
  import { type RankedResult } from '../search/rrf.js';
11
+ import { ftsSearch } from '../storage/fts-store.js';
11
12
  import type { GroupConfig } from './group-config.js';
12
13
 
13
14
  export interface GroupResult {
@@ -19,13 +20,6 @@ export interface GroupResult {
19
20
  score: number;
20
21
  }
21
22
 
22
- interface RawFtsRow {
23
- id: string;
24
- name: string;
25
- label: string;
26
- file_path: string | null;
27
- }
28
-
29
23
  /**
30
24
  * Determine the monograph DB path for a given repo root.
31
25
  * Mirrors the convention used in monograph-tools.ts: <repoPath>/.monomind/monograph.db
@@ -53,31 +47,19 @@ function searchRepo(
53
47
  try {
54
48
  db = new Database(dbPath, { readonly: true });
55
49
 
56
- // Sanitize and build prefix query
57
- const safeQuery = query.replace(/['"*]/g, ' ').trim();
58
- if (!safeQuery) return [];
59
-
60
- const ftsPrefixQuery = safeQuery
61
- .split(/\s+/)
62
- .map((t) => t + '*')
63
- .join(' ');
64
-
65
- const sql = `
66
- SELECT n.id, n.name, n.label, n.file_path
67
- FROM nodes_fts
68
- JOIN nodes n ON n.rowid = nodes_fts.rowid
69
- WHERE nodes_fts MATCH ?
70
- ORDER BY nodes_fts.rank
71
- LIMIT ?
72
- `;
73
-
74
- const rows = db.prepare(sql).all(ftsPrefixQuery, perRepoLimit) as RawFtsRow[];
50
+ // Reuse the SAME ftsSearch() the single-repo `monograph_query` tool uses —
51
+ // its trigram-aware sanitization and MATCH/LIKE fallback (P2-15) — instead of
52
+ // a divergent inline query that appended `*` as if this were a prefix-match
53
+ // tokenizer. The trigram index needs no prefix wildcard and errors on terms
54
+ // shorter than 3 chars when one is appended, which silently produced empty
55
+ // results here before.
56
+ const rows = ftsSearch(db, query, perRepoLimit);
75
57
 
76
58
  return rows.map((r, idx) => ({
77
59
  id: `${repoName}::${r.id}`,
78
60
  name: r.name,
79
61
  label: r.label,
80
- filePath: r.file_path ?? null,
62
+ filePath: r.filePath,
81
63
  repo: repoName,
82
64
  // Use rank position as initial score so RRF can compute correctly
83
65
  score: 1 / (60 + idx + 1),
@@ -60,7 +60,13 @@ function reverseBfs(
60
60
  const visited = new Map<string, number>([[startNodeId, 0]]);
61
61
  const queue: Array<{ id: string; depth: number }> = [{ id: startNodeId, depth: 0 }];
62
62
 
63
- const relations = options.relationTypes ?? ['CALLS'];
63
+ // Default to a broad set of relation types, not just CALLS — a class extended
64
+ // in 20 files, or a function only ever referenced via import/re-export (never
65
+ // directly called), should still show up in the blast radius. A narrow default
66
+ // reports "0 symbols affected, risk LOW" for changes that are anything but low
67
+ // risk. Callers that need the old narrow/fast behavior can still pass
68
+ // relationTypes: ['CALLS'] explicitly.
69
+ const relations = options.relationTypes ?? ['CALLS', 'IMPORTS', 'REFERENCES', 'EXTENDS', 'RE_EXPORTS'];
64
70
  const placeholders = relations.map(() => '?').join(',');
65
71
  const baseQuery = `SELECT source_id FROM edges WHERE target_id = ? AND relation IN (${placeholders})`;
66
72
  const query = options.minConfidenceScore !== undefined
@@ -11,7 +11,7 @@ import { routesPhase } from './phases/routes.js';
11
11
  import { toolsPhase } from './phases/tools.js';
12
12
  import { ormPhase } from './phases/orm.js';
13
13
  import { crossFilePhase } from './phases/cross-file.js';
14
- import { scopeResolutionPhase } from './phases/scope-resolution.js';
14
+ import { scopeResolutionPhase, clearWorkspacePackageMapCache } from './phases/scope-resolution.js';
15
15
  import { mroPhase } from './phases/mro.js';
16
16
  import { communitiesPhase } from './phases/communities.js';
17
17
  import { processesPhase } from './phases/processes.js';
@@ -22,11 +22,13 @@ import { variablesPhase } from './phases/variables-phase.js';
22
22
  import { wildcardSynthesisPhase } from './phases/wildcard-phase.js';
23
23
  import { frameworkDetectPhase } from './phases/framework-detect.js';
24
24
  import { importResolverPhase } from './phases/import-resolver.js';
25
+ import { bridgeResolverPhase } from './phases/bridge-resolver.js';
25
26
  import type { PipelineOptions, PipelineContext } from './types.js';
26
27
  import { DEFAULT_OPTIONS } from './types.js';
27
28
  import type { PipelineProgress, SuggestedQuestion } from '../types.js';
28
29
  import { generateGraphReport } from '../reporting/graph-report.js';
29
30
  import { analyzeChurn } from '../analysis/churn.js';
31
+ import { ExtractionCache } from '../cache/extraction-cache.js';
30
32
 
31
33
  function getCurrentCommitHash(repoPath: string): string | null {
32
34
  try {
@@ -74,6 +76,7 @@ async function acquireBuildLock(dbPath: string): Promise<(() => void) | null> {
74
76
  export async function buildAsync(repoPath: string, options: BuildOptions = {}): Promise<void> {
75
77
  const dbPath = resolve(join(repoPath, '.monomind', 'monograph.db'));
76
78
  const fullOptions: PipelineOptions = { ...DEFAULT_OPTIONS, ...options };
79
+ clearWorkspacePackageMapCache(repoPath);
77
80
 
78
81
  const releaseLock = await acquireBuildLock(dbPath);
79
82
  if (!releaseLock) {
@@ -113,6 +116,20 @@ async function buildAsyncLocked(
113
116
  }
114
117
  }
115
118
 
119
+ // Parse-cache eviction — runs once at the start of a full (non-incremental)
120
+ // build. The cache only ever grows via writes (parse.ts's ExtractionCache);
121
+ // nothing else prunes entries for files that were deleted/renamed or
122
+ // haven't been touched in a long time, so it accumulates forever otherwise.
123
+ if (!options.incremental) {
124
+ try {
125
+ const parseCache = new ExtractionCache(resolve(join(repoPath, '.monomind', 'parse-cache')));
126
+ const removed = parseCache.prune();
127
+ if (removed > 0) {
128
+ options.onProgress?.({ phase: 'prune', message: `Parse cache: pruned ${removed} stale entries` });
129
+ }
130
+ } catch { /* non-fatal — cache pruning must never block a build */ }
131
+ }
132
+
116
133
  const db = openDb(dbPath);
117
134
 
118
135
  try {
@@ -128,38 +145,45 @@ async function buildAsyncLocked(
128
145
  scanPhase, frameworkDetectPhase, structurePhase, parsePhase, variablesPhase,
129
146
  markdownPhase, routesPhase, toolsPhase, ormPhase,
130
147
  crossFilePhase, wildcardSynthesisPhase, importResolverPhase, scopeResolutionPhase,
148
+ bridgeResolverPhase,
131
149
  mroPhase, communitiesPhase, processesPhase, godNodesPhase, surprisesPhase, suggestPhase,
132
150
  ]);
133
151
 
134
152
  const outputs = await runner.run(ctx);
135
153
 
136
- // Skip post-pipeline work when all files were cached (nothing changed)
137
- if (!ctx.allFilesCached) {
138
- // Sweep orphaned rows for files that were renamed/deleted since the last build.
139
- const scanOut = outputs.get('scan') as { filePaths: string[] } | undefined;
140
- if (scanOut) {
141
- const liveFiles = new Set(scanOut.filePaths.map((f) => resolve(f)));
142
- const staleFiles = (
143
- db.prepare('SELECT DISTINCT file_path FROM nodes WHERE file_path IS NOT NULL').all() as { file_path: string }[]
144
- )
145
- .map((r) => r.file_path)
146
- .filter((f) => !liveFiles.has(resolve(ctx.repoPath, f)));
147
- if (staleFiles.length > 0) {
148
- const deleteStale = db.transaction((files: string[]) => {
149
- const deleteEdges = db.prepare(`
150
- DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file_path = ?)
151
- OR target_id IN (SELECT id FROM nodes WHERE file_path = ?)
152
- `);
153
- const deleteNodesStmt = db.prepare('DELETE FROM nodes WHERE file_path = ?');
154
- for (const f of files) {
155
- deleteEdges.run(f, f);
156
- deleteNodesStmt.run(f);
157
- }
158
- });
159
- deleteStale(staleFiles);
160
- }
154
+ // Sweep orphaned rows for files that were renamed/deleted since the last build.
155
+ // This MUST run unconditionally — even when every remaining file cache-hit
156
+ // (ctx.allFilesCached === true), a file may have been deleted from disk between
157
+ // builds, which produces zero cache misses but still leaves ghost rows in the DB
158
+ // unless we compare the DB's known file set against the current on-disk set.
159
+ const scanOut = outputs.get('scan') as { filePaths: string[] } | undefined;
160
+ if (scanOut) {
161
+ const liveFiles = new Set(scanOut.filePaths.map((f) => resolve(f)));
162
+ const staleFiles = (
163
+ db.prepare('SELECT DISTINCT file_path FROM nodes WHERE file_path IS NOT NULL').all() as { file_path: string }[]
164
+ )
165
+ .map((r) => r.file_path)
166
+ .filter((f) => !liveFiles.has(resolve(ctx.repoPath, f)));
167
+ if (staleFiles.length > 0) {
168
+ const deleteStale = db.transaction((files: string[]) => {
169
+ const deleteEdges = db.prepare(`
170
+ DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file_path = ?)
171
+ OR target_id IN (SELECT id FROM nodes WHERE file_path = ?)
172
+ `);
173
+ const deleteNodesStmt = db.prepare('DELETE FROM nodes WHERE file_path = ?');
174
+ for (const f of files) {
175
+ deleteEdges.run(f, f);
176
+ deleteNodesStmt.run(f);
177
+ }
178
+ });
179
+ deleteStale(staleFiles);
161
180
  }
181
+ }
162
182
 
183
+ // Skip expensive report regeneration when all files were cached (nothing changed) —
184
+ // this is purely a performance optimization, not correctness-critical, so it stays
185
+ // gated behind allFilesCached.
186
+ if (!ctx.allFilesCached) {
163
187
  const suggestOut = outputs.get('suggest') as { questions: SuggestedQuestion[] } | undefined;
164
188
  const questions = suggestOut?.questions ?? [];
165
189
  await generateGraphReport(resolve(repoPath), undefined, dbPath, questions);
@@ -0,0 +1,91 @@
1
+ import { statSync, readFileSync } from 'fs';
2
+ import { join, extname } from 'path';
3
+ import type { PipelineContext } from '../../types.js';
4
+ import type { BridgeAdapter, BridgeEndpoint } from './types.js';
5
+
6
+ const JS_TS_EXT = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
7
+
8
+ const IPC_MAIN_RE = /\bipcMain\.(?:handle|on)\(\s*['"]([^'"]+)['"]/g;
9
+ const IPC_RENDERER_RE = /\bipcRenderer\.(?:invoke|send)\(\s*['"]([^'"]+)['"]/g;
10
+
11
+ function safeReadSource(absPath: string, maxBytes: number): string | undefined {
12
+ try {
13
+ const stat = statSync(absPath);
14
+ if (stat.size > maxBytes) return undefined;
15
+ return readFileSync(absPath, 'utf-8');
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ }
20
+
21
+ function extractChannels(re: RegExp, source: string): string[] {
22
+ re.lastIndex = 0;
23
+ const names: string[] = [];
24
+ let m: RegExpExecArray | null;
25
+ while ((m = re.exec(source)) !== null) names.push(m[1]!);
26
+ return names;
27
+ }
28
+
29
+ /**
30
+ * Electron IPC (main process JS <-> renderer/preload JS): both sides are the
31
+ * same language, so nothing in the existing same-file import resolution
32
+ * connects them — the only link is the shared channel-name string literal
33
+ * passed to `ipcMain.handle/on` (definition) and `ipcRenderer.invoke/send`
34
+ * (call site). Neither side has call-expression-level node granularity from
35
+ * a plain regex scan, so both attach to their containing File node.
36
+ */
37
+ export const electronIpcAdapter: BridgeAdapter = {
38
+ name: 'electron-ipc',
39
+
40
+ detect(ctx, filePaths) {
41
+ let hasMain = false;
42
+ let hasRenderer = false;
43
+ for (const relPath of filePaths) {
44
+ if (!JS_TS_EXT.has(extname(relPath))) continue;
45
+ const source = safeReadSource(join(ctx.repoPath, relPath), ctx.options.maxFileSizeBytes);
46
+ if (!source) continue;
47
+ if (!hasMain && /\bipcMain\.(?:handle|on)\(/.test(source)) hasMain = true;
48
+ if (!hasRenderer && /\bipcRenderer\.(?:invoke|send)\(/.test(source)) hasRenderer = true;
49
+ if (hasMain && hasRenderer) return true;
50
+ }
51
+ return false;
52
+ },
53
+
54
+ findDefinitions(ctx, filePaths) {
55
+ const endpoints: BridgeEndpoint[] = [];
56
+ for (const relPath of filePaths) {
57
+ if (!JS_TS_EXT.has(extname(relPath))) continue;
58
+ const source = safeReadSource(join(ctx.repoPath, relPath), ctx.options.maxFileSizeBytes);
59
+ if (!source) continue;
60
+ const channels = extractChannels(IPC_MAIN_RE, source);
61
+ if (channels.length === 0) continue;
62
+
63
+ const fileRow = ctx.db
64
+ .prepare(`SELECT id FROM nodes WHERE label = 'File' AND file_path = ?`)
65
+ .get(relPath) as { id: string } | undefined;
66
+ if (!fileRow) continue;
67
+
68
+ for (const channel of channels) endpoints.push({ key: channel, nodeId: fileRow.id, language: 'javascript' });
69
+ }
70
+ return endpoints;
71
+ },
72
+
73
+ findCallSites(ctx, filePaths) {
74
+ const endpoints: BridgeEndpoint[] = [];
75
+ for (const relPath of filePaths) {
76
+ if (!JS_TS_EXT.has(extname(relPath))) continue;
77
+ const source = safeReadSource(join(ctx.repoPath, relPath), ctx.options.maxFileSizeBytes);
78
+ if (!source) continue;
79
+ const channels = extractChannels(IPC_RENDERER_RE, source);
80
+ if (channels.length === 0) continue;
81
+
82
+ const fileRow = ctx.db
83
+ .prepare(`SELECT id FROM nodes WHERE label = 'File' AND file_path = ?`)
84
+ .get(relPath) as { id: string } | undefined;
85
+ if (!fileRow) continue;
86
+
87
+ for (const channel of channels) endpoints.push({ key: channel, nodeId: fileRow.id, language: 'javascript' });
88
+ }
89
+ return endpoints;
90
+ },
91
+ };
@@ -0,0 +1,14 @@
1
+ import type { BridgeAdapter } from './types.js';
2
+ import { wailsAdapter } from './wails.js';
3
+ import { tauriAdapter } from './tauri.js';
4
+ import { electronIpcAdapter } from './electron-ipc.js';
5
+
6
+ // Every adapter here has an unambiguous detect() signal (a generated binding
7
+ // path, or a framework-specific call shape that only appears when its
8
+ // framework is actually in use) — so all run unconditionally; detect() is
9
+ // what keeps the no-op cost near zero for repos that don't use any of them.
10
+ export const BUILTIN_BRIDGE_ADAPTERS: BridgeAdapter[] = [
11
+ wailsAdapter,
12
+ tauriAdapter,
13
+ electronIpcAdapter,
14
+ ];