@monoes/monograph 1.3.0 → 1.4.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 (301) hide show
  1. package/.monomind/registry.json +6 -0
  2. package/__tests__/staleness/git-staleness.test.ts +3 -3
  3. package/dist/src/analysis/benchmark.js.map +1 -1
  4. package/dist/src/analysis/changed-files.d.ts.map +1 -1
  5. package/dist/src/analysis/changed-files.js +11 -2
  6. package/dist/src/analysis/changed-files.js.map +1 -1
  7. package/dist/src/analysis/changed-workspaces.d.ts.map +1 -1
  8. package/dist/src/analysis/changed-workspaces.js +11 -3
  9. package/dist/src/analysis/changed-workspaces.js.map +1 -1
  10. package/dist/src/analysis/churn.d.ts +1 -1
  11. package/dist/src/analysis/churn.d.ts.map +1 -1
  12. package/dist/src/analysis/churn.js +56 -10
  13. package/dist/src/analysis/churn.js.map +1 -1
  14. package/dist/src/analysis/cluster-quality.d.ts +4 -4
  15. package/dist/src/analysis/cluster-quality.d.ts.map +1 -1
  16. package/dist/src/analysis/cluster-quality.js +24 -27
  17. package/dist/src/analysis/cluster-quality.js.map +1 -1
  18. package/dist/src/analysis/combined.d.ts.map +1 -1
  19. package/dist/src/analysis/combined.js +2 -0
  20. package/dist/src/analysis/combined.js.map +1 -1
  21. package/dist/src/analysis/feature-flags.d.ts.map +1 -1
  22. package/dist/src/analysis/feature-flags.js +11 -5
  23. package/dist/src/analysis/feature-flags.js.map +1 -1
  24. package/dist/src/analysis/progress.d.ts.map +1 -1
  25. package/dist/src/analysis/project-state.d.ts.map +1 -1
  26. package/dist/src/analysis/shape-extractor.js.map +1 -1
  27. package/dist/src/analysis/suppression-context.d.ts.map +1 -1
  28. package/dist/src/analysis/surprise-scorer.js.map +1 -1
  29. package/dist/src/analysis/targets.d.ts.map +1 -1
  30. package/dist/src/analysis/targets.js +4 -5
  31. package/dist/src/analysis/targets.js.map +1 -1
  32. package/dist/src/analysis/worker-pool.d.ts.map +1 -1
  33. package/dist/src/analysis/worker-pool.js.map +1 -1
  34. package/dist/src/analyze/unused-deps.js.map +1 -1
  35. package/dist/src/analyze/unused-exports.js.map +1 -1
  36. package/dist/src/analyze/unused-files.js.map +1 -1
  37. package/dist/src/cache/augment-cache.js.map +1 -1
  38. package/dist/src/cache/extraction-cache.d.ts +21 -0
  39. package/dist/src/cache/extraction-cache.d.ts.map +1 -1
  40. package/dist/src/cache/extraction-cache.js +62 -6
  41. package/dist/src/cache/extraction-cache.js.map +1 -1
  42. package/dist/src/check/index.js.map +1 -1
  43. package/dist/src/cli/check-update.js.map +1 -1
  44. package/dist/src/cli/doctor.d.ts.map +1 -1
  45. package/dist/src/cli/doctor.js +6 -10
  46. package/dist/src/cli/doctor.js.map +1 -1
  47. package/dist/src/config/boundary-config.js.map +1 -1
  48. package/dist/src/config/config-parsing.d.ts +1 -1
  49. package/dist/src/config/config-parsing.d.ts.map +1 -1
  50. package/dist/src/config/migration-types.d.ts +2 -2
  51. package/dist/src/config/migration-types.d.ts.map +1 -1
  52. package/dist/src/config/migration-types.js.map +1 -1
  53. package/dist/src/config/used-class-members.js.map +1 -1
  54. package/dist/src/config/workspace.js.map +1 -1
  55. package/dist/src/coverage/upload-source-maps.js.map +1 -1
  56. package/dist/src/duplicates/detect/filtering.d.ts.map +1 -1
  57. package/dist/src/duplicates/shingle-filter.js.map +1 -1
  58. package/dist/src/error/fallow-error.d.ts.map +1 -1
  59. package/dist/src/error/fallow-error.js.map +1 -1
  60. package/dist/src/error.d.ts.map +1 -1
  61. package/dist/src/error.js.map +1 -1
  62. package/dist/src/export/analysis-json.js +1 -1
  63. package/dist/src/export/analysis-json.js.map +1 -1
  64. package/dist/src/export/badge.js.map +1 -1
  65. package/dist/src/export/dot.js.map +1 -1
  66. package/dist/src/export/sarif.d.ts.map +1 -1
  67. package/dist/src/export/sarif.js +13 -2
  68. package/dist/src/export/sarif.js.map +1 -1
  69. package/dist/src/fix/orchestrator.js.map +1 -1
  70. package/dist/src/graph/baseline.d.ts.map +1 -1
  71. package/dist/src/graph/baseline.js +29 -20
  72. package/dist/src/graph/baseline.js.map +1 -1
  73. package/dist/src/graph/cycle-detection.js.map +1 -1
  74. package/dist/src/graph/dead-code.d.ts +7 -2
  75. package/dist/src/graph/dead-code.d.ts.map +1 -1
  76. package/dist/src/graph/dead-code.js +19 -16
  77. package/dist/src/graph/dead-code.js.map +1 -1
  78. package/dist/src/graph/dfs.js.map +1 -1
  79. package/dist/src/graph/edge-filter.js.map +1 -1
  80. package/dist/src/graph/health-score.d.ts.map +1 -1
  81. package/dist/src/graph/health-score.js +11 -14
  82. package/dist/src/graph/health-score.js.map +1 -1
  83. package/dist/src/graph/hotspots.d.ts.map +1 -1
  84. package/dist/src/graph/hotspots.js +8 -4
  85. package/dist/src/graph/hotspots.js.map +1 -1
  86. package/dist/src/graph/import-chain.js.map +1 -1
  87. package/dist/src/graph/merge.js.map +1 -1
  88. package/dist/src/graph/node-search.js.map +1 -1
  89. package/dist/src/graph/node-types.d.ts.map +1 -1
  90. package/dist/src/graph/pagerank.d.ts.map +1 -1
  91. package/dist/src/graph/pagerank.js +10 -1
  92. package/dist/src/graph/pagerank.js.map +1 -1
  93. package/dist/src/graph/regex-search.js.map +1 -1
  94. package/dist/src/graph/regression.d.ts.map +1 -1
  95. package/dist/src/graph/resolve/discover-types.js.map +1 -1
  96. package/dist/src/graph/resolve/types.d.ts +3 -3
  97. package/dist/src/graph/resolve/types.d.ts.map +1 -1
  98. package/dist/src/graph/statistics.d.ts +3 -2
  99. package/dist/src/graph/statistics.d.ts.map +1 -1
  100. package/dist/src/graph/statistics.js +18 -7
  101. package/dist/src/graph/statistics.js.map +1 -1
  102. package/dist/src/graph/stats.d.ts.map +1 -1
  103. package/dist/src/graph/stats.js +2 -1
  104. package/dist/src/graph/stats.js.map +1 -1
  105. package/dist/src/groups/group-search.d.ts.map +1 -1
  106. package/dist/src/groups/group-search.js +9 -18
  107. package/dist/src/groups/group-search.js.map +1 -1
  108. package/dist/src/health/complexity-findings.js.map +1 -1
  109. package/dist/src/health/grouping.js.map +1 -1
  110. package/dist/src/health/health-report-types.js.map +1 -1
  111. package/dist/src/health/health-report.js.map +1 -1
  112. package/dist/src/health/targets.js.map +1 -1
  113. package/dist/src/index.d.ts +3 -0
  114. package/dist/src/index.d.ts.map +1 -1
  115. package/dist/src/index.js +3 -0
  116. package/dist/src/index.js.map +1 -1
  117. package/dist/src/ingest/transcribe.js.map +1 -1
  118. package/dist/src/ingest/url-ingest.js.map +1 -1
  119. package/dist/src/license/manager.d.ts.map +1 -1
  120. package/dist/src/license/manager.js.map +1 -1
  121. package/dist/src/lsp/diagnostics-push.js.map +1 -1
  122. package/dist/src/mcp-tools/impact.d.ts.map +1 -1
  123. package/dist/src/mcp-tools/impact.js +7 -1
  124. package/dist/src/mcp-tools/impact.js.map +1 -1
  125. package/dist/src/mcp-tools/list-repos.d.ts +1 -1
  126. package/dist/src/mcp-tools/list-repos.d.ts.map +1 -1
  127. package/dist/src/mcp-tools/query.d.ts +1 -1
  128. package/dist/src/mcp-tools/query.d.ts.map +1 -1
  129. package/dist/src/parsers/c.js.map +1 -1
  130. package/dist/src/parsers/cpp.js.map +1 -1
  131. package/dist/src/parsers/csharp.js.map +1 -1
  132. package/dist/src/parsers/dart.js.map +1 -1
  133. package/dist/src/parsers/go.js.map +1 -1
  134. package/dist/src/parsers/java.js.map +1 -1
  135. package/dist/src/parsers/kotlin.js.map +1 -1
  136. package/dist/src/parsers/loader.js.map +1 -1
  137. package/dist/src/parsers/php.js.map +1 -1
  138. package/dist/src/parsers/python.js.map +1 -1
  139. package/dist/src/parsers/ruby.js.map +1 -1
  140. package/dist/src/parsers/rust.js.map +1 -1
  141. package/dist/src/parsers/swift.js.map +1 -1
  142. package/dist/src/parsers/typescript.js.map +1 -1
  143. package/dist/src/parsers/vue.js.map +1 -1
  144. package/dist/src/pipeline/effort.js.map +1 -1
  145. package/dist/src/pipeline/orchestrator.d.ts.map +1 -1
  146. package/dist/src/pipeline/orchestrator.js +85 -24
  147. package/dist/src/pipeline/orchestrator.js.map +1 -1
  148. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.d.ts +11 -0
  149. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.d.ts.map +1 -0
  150. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.js +96 -0
  151. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.js.map +1 -0
  152. package/dist/src/pipeline/phases/bridge-adapters/registry.d.ts +3 -0
  153. package/dist/src/pipeline/phases/bridge-adapters/registry.d.ts.map +1 -0
  154. package/dist/src/pipeline/phases/bridge-adapters/registry.js +13 -0
  155. package/dist/src/pipeline/phases/bridge-adapters/registry.js.map +1 -0
  156. package/dist/src/pipeline/phases/bridge-adapters/tauri.d.ts +10 -0
  157. package/dist/src/pipeline/phases/bridge-adapters/tauri.d.ts.map +1 -0
  158. package/dist/src/pipeline/phases/bridge-adapters/tauri.js +104 -0
  159. package/dist/src/pipeline/phases/bridge-adapters/tauri.js.map +1 -0
  160. package/dist/src/pipeline/phases/bridge-adapters/types.d.ts +30 -0
  161. package/dist/src/pipeline/phases/bridge-adapters/types.d.ts.map +1 -0
  162. package/dist/src/pipeline/phases/bridge-adapters/types.js +2 -0
  163. package/dist/src/pipeline/phases/bridge-adapters/types.js.map +1 -0
  164. package/dist/src/pipeline/phases/bridge-adapters/wails.d.ts +10 -0
  165. package/dist/src/pipeline/phases/bridge-adapters/wails.d.ts.map +1 -0
  166. package/dist/src/pipeline/phases/bridge-adapters/wails.js +27 -0
  167. package/dist/src/pipeline/phases/bridge-adapters/wails.js.map +1 -0
  168. package/dist/src/pipeline/phases/bridge-resolver.d.ts +23 -0
  169. package/dist/src/pipeline/phases/bridge-resolver.d.ts.map +1 -0
  170. package/dist/src/pipeline/phases/bridge-resolver.js +70 -0
  171. package/dist/src/pipeline/phases/bridge-resolver.js.map +1 -0
  172. package/dist/src/pipeline/phases/cross-file.d.ts.map +1 -1
  173. package/dist/src/pipeline/phases/cross-file.js +70 -27
  174. package/dist/src/pipeline/phases/cross-file.js.map +1 -1
  175. package/dist/src/pipeline/phases/leiden.js.map +1 -1
  176. package/dist/src/pipeline/phases/parse.d.ts.map +1 -1
  177. package/dist/src/pipeline/phases/parse.js +58 -17
  178. package/dist/src/pipeline/phases/parse.js.map +1 -1
  179. package/dist/src/pipeline/phases/scan.d.ts.map +1 -1
  180. package/dist/src/pipeline/phases/scan.js +12 -5
  181. package/dist/src/pipeline/phases/scan.js.map +1 -1
  182. package/dist/src/pipeline/phases/scope-resolution.d.ts +13 -0
  183. package/dist/src/pipeline/phases/scope-resolution.d.ts.map +1 -1
  184. package/dist/src/pipeline/phases/scope-resolution.js +18 -4
  185. package/dist/src/pipeline/phases/scope-resolution.js.map +1 -1
  186. package/dist/src/pipeline/progress.d.ts.map +1 -1
  187. package/dist/src/pipeline/runner.d.ts.map +1 -1
  188. package/dist/src/pipeline/runner.js.map +1 -1
  189. package/dist/src/pipeline/types.d.ts +2 -0
  190. package/dist/src/pipeline/types.d.ts.map +1 -1
  191. package/dist/src/pipeline/types.js +1 -0
  192. package/dist/src/pipeline/types.js.map +1 -1
  193. package/dist/src/regression/baseline.js.map +1 -1
  194. package/dist/src/regression/outcome.js.map +1 -1
  195. package/dist/src/report/badge-svg.js.map +1 -1
  196. package/dist/src/report/duplication-grouping.js.map +1 -1
  197. package/dist/src/report/human-check.js.map +1 -1
  198. package/dist/src/report/human-dupes.js.map +1 -1
  199. package/dist/src/report/human-health.js.map +1 -1
  200. package/dist/src/report/json-builders.js.map +1 -1
  201. package/dist/src/report/number-format.js.map +1 -1
  202. package/dist/src/reporting/graph-report.d.ts.map +1 -1
  203. package/dist/src/reporting/graph-report.js +8 -9
  204. package/dist/src/reporting/graph-report.js.map +1 -1
  205. package/dist/src/scope-resolution/evidence-weights.d.ts.map +1 -1
  206. package/dist/src/search/hybrid-query.js.map +1 -1
  207. package/dist/src/security/safe-fetch.js.map +1 -1
  208. package/dist/src/staleness/git-staleness.d.ts.map +1 -1
  209. package/dist/src/staleness/git-staleness.js +5 -2
  210. package/dist/src/staleness/git-staleness.js.map +1 -1
  211. package/dist/src/storage/db.d.ts +13 -1
  212. package/dist/src/storage/db.d.ts.map +1 -1
  213. package/dist/src/storage/db.js +23 -2
  214. package/dist/src/storage/db.js.map +1 -1
  215. package/dist/src/storage/edge-store.d.ts +6 -0
  216. package/dist/src/storage/edge-store.d.ts.map +1 -1
  217. package/dist/src/storage/edge-store.js +8 -1
  218. package/dist/src/storage/edge-store.js.map +1 -1
  219. package/dist/src/storage/fts-store.d.ts.map +1 -1
  220. package/dist/src/storage/fts-store.js +31 -9
  221. package/dist/src/storage/fts-store.js.map +1 -1
  222. package/dist/src/storage/node-store.js.map +1 -1
  223. package/dist/src/storage/schema.d.ts +5 -0
  224. package/dist/src/storage/schema.d.ts.map +1 -1
  225. package/dist/src/storage/schema.js +25 -0
  226. package/dist/src/storage/schema.js.map +1 -1
  227. package/dist/src/storage/unlinked-refs.js.map +1 -1
  228. package/dist/src/types.d.ts +2 -2
  229. package/dist/src/types.d.ts.map +1 -1
  230. package/dist/src/types.js.map +1 -1
  231. package/dist/src/watch/watcher.d.ts.map +1 -1
  232. package/dist/src/watch/watcher.js +12 -2
  233. package/dist/src/watch/watcher.js.map +1 -1
  234. package/dist/src/web/analyze-api.d.ts.map +1 -1
  235. package/dist/src/web/analyze-api.js +3 -1
  236. package/dist/src/web/analyze-api.js.map +1 -1
  237. package/dist/src/web/api.d.ts +3 -0
  238. package/dist/src/web/api.d.ts.map +1 -1
  239. package/dist/src/web/api.js +59 -20
  240. package/dist/src/web/api.js.map +1 -1
  241. package/dist/src/web/async-jobs.d.ts.map +1 -1
  242. package/dist/src/web/async-jobs.js +15 -0
  243. package/dist/src/web/async-jobs.js.map +1 -1
  244. package/dist/src/web/react-ui.js +2 -2
  245. package/dist/src/wiki/providers.js.map +1 -1
  246. package/dist/src/wiki/wiki-generator.d.ts.map +1 -1
  247. package/dist/src/wiki/wiki-generator.js +9 -6
  248. package/dist/src/wiki/wiki-generator.js.map +1 -1
  249. package/dist/tsconfig.tsbuildinfo +1 -1
  250. package/package.json +5 -2
  251. package/src/__tests__/graph/dead-code.test.ts +27 -7
  252. package/src/__tests__/pipeline/phases/bridge-adapters/electron-ipc.test.ts +87 -0
  253. package/src/__tests__/pipeline/phases/bridge-adapters/tauri.test.ts +116 -0
  254. package/src/__tests__/pipeline/phases/bridge-adapters/wails.test.ts +81 -0
  255. package/src/__tests__/pipeline/phases/bridge-resolver.test.ts +110 -0
  256. package/src/analysis/changed-files.ts +14 -2
  257. package/src/analysis/changed-workspaces.ts +13 -4
  258. package/src/analysis/churn.ts +57 -10
  259. package/src/analysis/cluster-quality.ts +25 -26
  260. package/src/analysis/combined.ts +2 -0
  261. package/src/analysis/feature-flags.ts +10 -5
  262. package/src/analysis/targets.ts +4 -5
  263. package/src/cache/extraction-cache.ts +59 -6
  264. package/src/cli/doctor.ts +6 -10
  265. package/src/export/analysis-json.ts +1 -1
  266. package/src/export/sarif.ts +11 -2
  267. package/src/graph/baseline.ts +29 -20
  268. package/src/graph/dead-code.ts +19 -16
  269. package/src/graph/health-score.ts +13 -16
  270. package/src/graph/hotspots.ts +8 -4
  271. package/src/graph/pagerank.ts +10 -1
  272. package/src/graph/statistics.ts +19 -7
  273. package/src/graph/stats.ts +2 -1
  274. package/src/groups/group-search.ts +9 -27
  275. package/src/index.ts +3 -0
  276. package/src/mcp-tools/impact.ts +7 -1
  277. package/src/pipeline/orchestrator.ts +87 -26
  278. package/src/pipeline/phases/bridge-adapters/electron-ipc.ts +91 -0
  279. package/src/pipeline/phases/bridge-adapters/registry.ts +14 -0
  280. package/src/pipeline/phases/bridge-adapters/tauri.ts +103 -0
  281. package/src/pipeline/phases/bridge-adapters/types.ts +31 -0
  282. package/src/pipeline/phases/bridge-adapters/wails.ts +33 -0
  283. package/src/pipeline/phases/bridge-resolver.ts +77 -0
  284. package/src/pipeline/phases/cross-file.ts +75 -27
  285. package/src/pipeline/phases/parse.ts +60 -17
  286. package/src/pipeline/phases/scan.ts +11 -5
  287. package/src/pipeline/phases/scope-resolution.ts +19 -4
  288. package/src/pipeline/types.ts +3 -0
  289. package/src/reporting/graph-report.ts +8 -9
  290. package/src/staleness/git-staleness.ts +5 -2
  291. package/src/storage/db.ts +35 -1
  292. package/src/storage/edge-store.ts +8 -1
  293. package/src/storage/fts-store.ts +31 -9
  294. package/src/storage/schema.ts +30 -0
  295. package/src/watch/watcher.ts +11 -2
  296. package/src/web/analyze-api.ts +3 -1
  297. package/src/web/api.ts +62 -22
  298. package/src/web/async-jobs.ts +15 -0
  299. package/src/web/react-ui.ts +2 -2
  300. package/src/wiki/wiki-generator.ts +9 -6
  301. package/tsconfig.json +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monograph",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "description": "Native TypeScript code intelligence engine for monomind",
6
6
  "main": "dist/src/index.js",
@@ -11,6 +11,9 @@
11
11
  "import": "./dist/src/index.js"
12
12
  }
13
13
  },
14
+ "engines": {
15
+ "node": ">=20.0.0"
16
+ },
14
17
  "publishConfig": {
15
18
  "access": "public",
16
19
  "tag": "latest"
@@ -50,7 +53,7 @@
50
53
  "@types/express": "^5.0.0",
51
54
  "@types/micromatch": "^4.0.10",
52
55
  "@types/node": "^20.0.0",
53
- "typescript": "^5.3.0",
56
+ "typescript": "^7.0.2",
54
57
  "vitest": "^4.1.4"
55
58
  }
56
59
  }
@@ -34,6 +34,11 @@ function insertCallEdge(db: Database.Database, src: string, tgt: string) {
34
34
  .run(`${src}_${tgt}_calls`, src, tgt);
35
35
  }
36
36
 
37
+ function insertReExportEdge(db: Database.Database, srcFileId: string, tgtFileId: string) {
38
+ db.prepare(`INSERT INTO edges (id, source_id, target_id, relation, confidence, confidence_score) VALUES (?, ?, ?, 'RE_EXPORTS', 'INFERRED', 0.8)`)
39
+ .run(`${srcFileId}_${tgtFileId}_reexports`, srcFileId, tgtFileId);
40
+ }
41
+
37
42
  describe('detectDeadCode', () => {
38
43
  it('returns empty array for empty graph', () => {
39
44
  const db = makeTempDb();
@@ -93,24 +98,39 @@ describe('detectDeadCode', () => {
93
98
  db.close();
94
99
  });
95
100
 
96
- it('skips functions with same-name node in another file', () => {
101
+ it('does NOT suppress on a same-name coincidence alone (no real edge)', () => {
102
+ // Regression test for P2-18(a): the old suppression matched on NAME EQUALITY
103
+ // anywhere in the codebase, so a genuinely-dead `shared()` in a.ts was hidden
104
+ // just because an unrelated `shared` also existed in b.ts. With no actual
105
+ // RE_EXPORTS/IMPORTS edge between them, the candidate must still be flagged.
97
106
  const db = makeTempDb();
98
107
  insertFileNode(db, 'src/a.ts');
99
108
  insertFileNode(db, 'src/b.ts');
100
- insertFunction(db, 'shared', 'src/a.ts', { exported: true });
101
- // Same name in another file (import binding)
109
+ const id = insertFunction(db, 'shared', 'src/a.ts', { exported: true });
102
110
  insertFunction(db, 'shared', 'src/b.ts', { exported: false });
103
- expect(detectDeadCode(db)).toEqual([]);
111
+ expect(detectDeadCode(db)).toContain(id);
104
112
  db.close();
105
113
  });
106
114
 
107
- it('skips functions re-exported through barrel index', () => {
115
+ it('does NOT suppress on same-name-in-index.ts alone (no real RE_EXPORTS edge)', () => {
116
+ // Regression test for P2-18(a): a function sharing a name with something
117
+ // index.ts happens to also define is not the same as being re-exported by it.
108
118
  const db = makeTempDb();
109
119
  insertFileNode(db, 'src/utils.ts');
110
120
  insertFileNode(db, 'src/index.ts');
111
- insertFunction(db, 'helper', 'src/utils.ts', { exported: true });
112
- // Same name in index.ts (barrel re-export)
121
+ const id = insertFunction(db, 'helper', 'src/utils.ts', { exported: true });
113
122
  insertFunction(db, 'helper', 'src/index.ts', { exported: true });
123
+ expect(detectDeadCode(db)).toContain(id);
124
+ db.close();
125
+ });
126
+
127
+ it('skips functions re-exported through a REAL RE_EXPORTS edge from a barrel', () => {
128
+ const db = makeTempDb();
129
+ const utilsFileId = insertFileNode(db, 'src/utils.ts');
130
+ const indexFileId = insertFileNode(db, 'src/index.ts');
131
+ insertFunction(db, 'helper', 'src/utils.ts', { exported: true });
132
+ // Actual barrel edge: src/index.ts RE_EXPORTS src/utils.ts's File node.
133
+ insertReExportEdge(db, indexFileId, utilsFileId);
114
134
  expect(detectDeadCode(db)).toEqual([]);
115
135
  db.close();
116
136
  });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
3
+ import { join, dirname } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import Database from 'better-sqlite3';
6
+ import { openDb, closeDb } from '../../../../storage/db.js';
7
+ import { insertNodes } from '../../../../storage/node-store.js';
8
+ import type { MonographNode } from '../../../../types.js';
9
+ import type { PipelineContext } from '../../../../pipeline/types.js';
10
+ import { DEFAULT_OPTIONS } from '../../../../pipeline/types.js';
11
+ import { electronIpcAdapter } from '../../../../pipeline/phases/bridge-adapters/electron-ipc.js';
12
+
13
+ function makeCtx(repoPath: string, db: Database.Database): PipelineContext {
14
+ return {
15
+ repoPath,
16
+ db,
17
+ graph: undefined as never,
18
+ onProgress: () => {},
19
+ options: DEFAULT_OPTIONS,
20
+ };
21
+ }
22
+
23
+ function writeFile(root: string, relPath: string, content: string): void {
24
+ const full = join(root, relPath);
25
+ mkdirSync(dirname(full), { recursive: true });
26
+ writeFileSync(full, content, 'utf-8');
27
+ }
28
+
29
+ function fileNode(id: string, filePath: string): MonographNode {
30
+ return {
31
+ id, label: 'File', name: filePath.split('/').pop()!, normLabel: '', filePath,
32
+ isExported: false,
33
+ };
34
+ }
35
+
36
+ describe('electronIpcAdapter', () => {
37
+ let tmpDir: string;
38
+ let db: Database.Database;
39
+
40
+ beforeEach(() => {
41
+ tmpDir = mkdtempSync(join(tmpdir(), 'electron-ipc-bridge-test-'));
42
+ db = openDb(join(tmpDir, 'test.db'));
43
+ });
44
+
45
+ afterEach(() => {
46
+ closeDb(db);
47
+ rmSync(tmpDir, { recursive: true, force: true });
48
+ });
49
+
50
+ it('detects a repo with both ipcMain and ipcRenderer usage', () => {
51
+ writeFile(tmpDir, 'main.js', `ipcMain.handle('get-data', () => {});`);
52
+ writeFile(tmpDir, 'renderer.js', `ipcRenderer.invoke('get-data');`);
53
+ const ctx = makeCtx(tmpDir, db);
54
+ expect(electronIpcAdapter.detect(ctx, ['main.js', 'renderer.js'])).toBe(true);
55
+ });
56
+
57
+ it('does not detect main-only usage (no renderer side)', () => {
58
+ writeFile(tmpDir, 'main.js', `ipcMain.handle('get-data', () => {});`);
59
+ const ctx = makeCtx(tmpDir, db);
60
+ expect(electronIpcAdapter.detect(ctx, ['main.js'])).toBe(false);
61
+ });
62
+
63
+ it('links a channel from ipcMain.handle to ipcRenderer.invoke by name', () => {
64
+ writeFile(tmpDir, 'main.js', `ipcMain.handle('get-data', () => fetchData());`);
65
+ writeFile(tmpDir, 'renderer.js', `const data = await ipcRenderer.invoke('get-data');`);
66
+ insertNodes(db, [fileNode('main_file', 'main.js'), fileNode('renderer_file', 'renderer.js')]);
67
+ const ctx = makeCtx(tmpDir, db);
68
+
69
+ const defs = electronIpcAdapter.findDefinitions(ctx, ['main.js', 'renderer.js']);
70
+ expect(defs).toEqual([{ key: 'get-data', nodeId: 'main_file', language: 'javascript' }]);
71
+
72
+ const sites = electronIpcAdapter.findCallSites(ctx, ['main.js', 'renderer.js']);
73
+ expect(sites).toEqual([{ key: 'get-data', nodeId: 'renderer_file', language: 'javascript' }]);
74
+ });
75
+
76
+ it('also matches ipcMain.on / ipcRenderer.send', () => {
77
+ writeFile(tmpDir, 'main.js', `ipcMain.on('log', (e, msg) => console.log(msg));`);
78
+ writeFile(tmpDir, 'renderer.js', `ipcRenderer.send('log', 'hello');`);
79
+ insertNodes(db, [fileNode('main_file', 'main.js'), fileNode('renderer_file', 'renderer.js')]);
80
+ const ctx = makeCtx(tmpDir, db);
81
+
82
+ expect(electronIpcAdapter.findDefinitions(ctx, ['main.js']))
83
+ .toEqual([{ key: 'log', nodeId: 'main_file', language: 'javascript' }]);
84
+ expect(electronIpcAdapter.findCallSites(ctx, ['renderer.js']))
85
+ .toEqual([{ key: 'log', nodeId: 'renderer_file', language: 'javascript' }]);
86
+ });
87
+ });
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
3
+ import { join, dirname } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import Database from 'better-sqlite3';
6
+ import { openDb, closeDb } from '../../../../storage/db.js';
7
+ import { insertNodes } from '../../../../storage/node-store.js';
8
+ import type { MonographNode } from '../../../../types.js';
9
+ import type { PipelineContext } from '../../../../pipeline/types.js';
10
+ import { DEFAULT_OPTIONS } from '../../../../pipeline/types.js';
11
+ import { tauriAdapter } from '../../../../pipeline/phases/bridge-adapters/tauri.js';
12
+
13
+ function makeCtx(repoPath: string, db: Database.Database): PipelineContext {
14
+ return {
15
+ repoPath,
16
+ db,
17
+ graph: undefined as never,
18
+ onProgress: () => {},
19
+ options: DEFAULT_OPTIONS,
20
+ };
21
+ }
22
+
23
+ function writeFile(root: string, relPath: string, content: string): void {
24
+ const full = join(root, relPath);
25
+ mkdirSync(dirname(full), { recursive: true });
26
+ writeFileSync(full, content, 'utf-8');
27
+ }
28
+
29
+ function fnNode(id: string, name: string, filePath: string): MonographNode {
30
+ return {
31
+ id, label: 'Function', name, normLabel: name.toLowerCase(), filePath,
32
+ isExported: true, language: 'rust',
33
+ };
34
+ }
35
+
36
+ function fileNode(id: string, filePath: string): MonographNode {
37
+ return {
38
+ id, label: 'File', name: filePath.split('/').pop()!, normLabel: '', filePath,
39
+ isExported: false,
40
+ };
41
+ }
42
+
43
+ describe('tauriAdapter', () => {
44
+ let tmpDir: string;
45
+ let db: Database.Database;
46
+
47
+ beforeEach(() => {
48
+ tmpDir = mkdtempSync(join(tmpdir(), 'tauri-bridge-test-'));
49
+ db = openDb(join(tmpDir, 'test.db'));
50
+ });
51
+
52
+ afterEach(() => {
53
+ closeDb(db);
54
+ rmSync(tmpDir, { recursive: true, force: true });
55
+ });
56
+
57
+ it('detects a repo with a #[tauri::command]-annotated Rust file', () => {
58
+ writeFile(tmpDir, 'src-tauri/src/main.rs', `
59
+ #[tauri::command]
60
+ fn greet(name: String) -> String {
61
+ format!("Hello, {}!", name)
62
+ }
63
+ `);
64
+ const ctx = makeCtx(tmpDir, db);
65
+ expect(tauriAdapter.detect(ctx, ['src-tauri/src/main.rs'])).toBe(true);
66
+ });
67
+
68
+ it('does not detect a repo with no Rust files', () => {
69
+ const ctx = makeCtx(tmpDir, db);
70
+ expect(tauriAdapter.detect(ctx, ['src/App.tsx'])).toBe(false);
71
+ });
72
+
73
+ it('does not detect a Rust file with no #[tauri::command] annotation', () => {
74
+ writeFile(tmpDir, 'src-tauri/src/main.rs', `
75
+ fn internal_helper() -> i32 { 42 }
76
+ `);
77
+ const ctx = makeCtx(tmpDir, db);
78
+ expect(tauriAdapter.detect(ctx, ['src-tauri/src/main.rs'])).toBe(false);
79
+ });
80
+
81
+ it('finds a #[tauri::command] fn matched to its existing Function node', () => {
82
+ writeFile(tmpDir, 'src-tauri/src/main.rs', `
83
+ #[tauri::command]
84
+ fn greet(name: String) -> String {
85
+ format!("Hello, {}!", name)
86
+ }
87
+ `);
88
+ insertNodes(db, [fnNode('rust_fn_1', 'greet', 'src-tauri/src/main.rs')]);
89
+ const ctx = makeCtx(tmpDir, db);
90
+ const defs = tauriAdapter.findDefinitions(ctx, ['src-tauri/src/main.rs']);
91
+ expect(defs).toEqual([{ key: 'greet', nodeId: 'rust_fn_1', language: 'rust' }]);
92
+ });
93
+
94
+ it('ignores a plain fn with no #[tauri::command] attribute', () => {
95
+ writeFile(tmpDir, 'src-tauri/src/main.rs', `
96
+ fn internal_helper() -> i32 { 42 }
97
+ `);
98
+ insertNodes(db, [fnNode('rust_fn_2', 'internal_helper', 'src-tauri/src/main.rs')]);
99
+ const ctx = makeCtx(tmpDir, db);
100
+ const defs = tauriAdapter.findDefinitions(ctx, ['src-tauri/src/main.rs']);
101
+ expect(defs).toEqual([]);
102
+ });
103
+
104
+ it('finds an invoke() call site attached to its containing File node', () => {
105
+ writeFile(tmpDir, 'src/App.tsx', `
106
+ import { invoke } from '@tauri-apps/api/core';
107
+ async function onClick() {
108
+ await invoke('greet', { name: 'world' });
109
+ }
110
+ `);
111
+ insertNodes(db, [fileNode('file_1', 'src/App.tsx')]);
112
+ const ctx = makeCtx(tmpDir, db);
113
+ const sites = tauriAdapter.findCallSites(ctx, ['src/App.tsx']);
114
+ expect(sites).toEqual([{ key: 'greet', nodeId: 'file_1', language: 'javascript' }]);
115
+ });
116
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import Database from 'better-sqlite3';
6
+ import { openDb, closeDb } from '../../../../storage/db.js';
7
+ import { insertNodes } from '../../../../storage/node-store.js';
8
+ import type { MonographNode } from '../../../../types.js';
9
+ import type { PipelineContext } from '../../../../pipeline/types.js';
10
+ import { DEFAULT_OPTIONS } from '../../../../pipeline/types.js';
11
+ import { wailsAdapter } from '../../../../pipeline/phases/bridge-adapters/wails.js';
12
+
13
+ function makeCtx(repoPath: string, db: Database.Database): PipelineContext {
14
+ return {
15
+ repoPath,
16
+ db,
17
+ graph: undefined as never,
18
+ onProgress: () => {},
19
+ options: DEFAULT_OPTIONS,
20
+ };
21
+ }
22
+
23
+ function methodNode(id: string, name: string, filePath: string): MonographNode {
24
+ return {
25
+ id, label: 'Method', name, normLabel: name.toLowerCase(), filePath,
26
+ isExported: true, language: 'go',
27
+ };
28
+ }
29
+
30
+ function functionNode(id: string, name: string, filePath: string, language = 'typescript'): MonographNode {
31
+ return {
32
+ id, label: 'Function', name, normLabel: name.toLowerCase(), filePath,
33
+ isExported: true, language,
34
+ };
35
+ }
36
+
37
+ describe('wailsAdapter', () => {
38
+ let tmpDir: string;
39
+ let db: Database.Database;
40
+
41
+ beforeEach(() => {
42
+ tmpDir = mkdtempSync(join(tmpdir(), 'wails-bridge-test-'));
43
+ db = openDb(join(tmpDir, 'test.db'));
44
+ });
45
+
46
+ afterEach(() => {
47
+ closeDb(db);
48
+ rmSync(tmpDir, { recursive: true, force: true });
49
+ });
50
+
51
+ it('detects a repo with a wailsjs/go binding directory', () => {
52
+ const ctx = makeCtx(tmpDir, db);
53
+ expect(wailsAdapter.detect(ctx, ['frontend/src/wailsjs/go/main/App.js'])).toBe(true);
54
+ });
55
+
56
+ it('does not detect a repo with no wails bindings', () => {
57
+ const ctx = makeCtx(tmpDir, db);
58
+ expect(wailsAdapter.detect(ctx, ['app.go', 'frontend/src/App.jsx'])).toBe(false);
59
+ });
60
+
61
+ it('finds Go methods as definitions', () => {
62
+ insertNodes(db, [methodNode('go_method_1', 'SendMessage', 'app.go')]);
63
+ const ctx = makeCtx(tmpDir, db);
64
+ const defs = wailsAdapter.findDefinitions(ctx, []);
65
+ expect(defs).toEqual([{ key: 'SendMessage', nodeId: 'go_method_1', language: 'go' }]);
66
+ });
67
+
68
+ it('finds wailsjs binding functions as call sites', () => {
69
+ insertNodes(db, [functionNode('js_fn_1', 'SendMessage', 'frontend/src/wailsjs/go/main/App.js')]);
70
+ const ctx = makeCtx(tmpDir, db);
71
+ const sites = wailsAdapter.findCallSites(ctx, []);
72
+ expect(sites).toEqual([{ key: 'SendMessage', nodeId: 'js_fn_1', language: 'typescript' }]);
73
+ });
74
+
75
+ it('does not treat a Function outside wailsjs/go/ as a call site', () => {
76
+ insertNodes(db, [functionNode('js_fn_2', 'SendMessage', 'frontend/src/services/api.js')]);
77
+ const ctx = makeCtx(tmpDir, db);
78
+ const sites = wailsAdapter.findCallSites(ctx, []);
79
+ expect(sites).toEqual([]);
80
+ });
81
+ });
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
3
+ import { join, dirname } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import Database from 'better-sqlite3';
6
+ import { buildAsync } from '../../../pipeline/orchestrator.js';
7
+
8
+ function writeFile(root: string, relPath: string, content: string): void {
9
+ const full = join(root, relPath);
10
+ mkdirSync(dirname(full), { recursive: true });
11
+ writeFileSync(full, content, 'utf-8');
12
+ }
13
+
14
+ function edgeRows(dbPath: string): { source_id: string; target_id: string; relation: string; confidence: string; reason: string | null }[] {
15
+ const db = new Database(dbPath, { readonly: true });
16
+ try {
17
+ return db.prepare(`SELECT source_id, target_id, relation, confidence, reason FROM edges`).all() as never[];
18
+ } finally {
19
+ db.close();
20
+ }
21
+ }
22
+
23
+ /** Bridge edges are tagged with a reason of the form "<adapter> bridge (<lang> -> <lang>): ...". */
24
+ function isBridgeEdge(e: { reason: string | null }): boolean {
25
+ return !!e.reason && / bridge \(.+ -> .+\): /.test(e.reason);
26
+ }
27
+
28
+ describe('bridge-resolver (full pipeline)', () => {
29
+ let tmpDir: string;
30
+
31
+ afterEach(() => {
32
+ rmSync(tmpDir, { recursive: true, force: true });
33
+ });
34
+
35
+ it('links a Wails Go method to its generated JS binding function', async () => {
36
+ tmpDir = mkdtempSync(join(tmpdir(), 'bridge-e2e-wails-'));
37
+
38
+ writeFile(tmpDir, 'app.go', `
39
+ package main
40
+
41
+ type App struct{}
42
+
43
+ func (a *App) SendMessage(text string) error {
44
+ return nil
45
+ }
46
+ `);
47
+ writeFile(tmpDir, 'frontend/src/wailsjs/go/main/App.js', `
48
+ export function SendMessage(arg1) {
49
+ return window['go']['main']['App']['SendMessage'](arg1);
50
+ }
51
+ `);
52
+ writeFile(tmpDir, 'frontend/src/App.jsx', `
53
+ import { SendMessage } from './wailsjs/go/main/App';
54
+ function onClick() { SendMessage('hi'); }
55
+ `);
56
+
57
+ await buildAsync(tmpDir, { codeOnly: true });
58
+
59
+ const edges = edgeRows(join(tmpDir, '.monomind', 'monograph.db'));
60
+ const bridgeEdge = edges.find((e) => e.relation === 'CALLS' && e.confidence === 'INFERRED' && isBridgeEdge(e));
61
+ expect(bridgeEdge).toBeDefined();
62
+ });
63
+
64
+ it('does not emit a bridge edge when there is no matching Go method', async () => {
65
+ tmpDir = mkdtempSync(join(tmpdir(), 'bridge-e2e-wails-negative-'));
66
+
67
+ writeFile(tmpDir, 'app.go', `
68
+ package main
69
+
70
+ type App struct{}
71
+
72
+ func (a *App) UnrelatedMethod() error {
73
+ return nil
74
+ }
75
+ `);
76
+ writeFile(tmpDir, 'frontend/src/wailsjs/go/main/App.js', `
77
+ export function SendMessage(arg1) {
78
+ return window['go']['main']['App']['SendMessage'](arg1);
79
+ }
80
+ `);
81
+
82
+ await buildAsync(tmpDir, { codeOnly: true });
83
+
84
+ const db = new Database(join(tmpDir, '.monomind', 'monograph.db'), { readonly: true });
85
+ const jsFn = db.prepare(`SELECT id FROM nodes WHERE name = 'SendMessage' AND label = 'Function'`).get() as { id: string } | undefined;
86
+ db.close();
87
+ expect(jsFn).toBeDefined();
88
+
89
+ const edges = edgeRows(join(tmpDir, '.monomind', 'monograph.db'));
90
+ const bridgeEdgeToJsFn = edges.find((e) => e.target_id === jsFn!.id && isBridgeEdge(e));
91
+ expect(bridgeEdgeToJsFn).toBeUndefined();
92
+ });
93
+
94
+ it('does nothing in a repo with no bridge frameworks present', async () => {
95
+ tmpDir = mkdtempSync(join(tmpdir(), 'bridge-e2e-none-'));
96
+ writeFile(tmpDir, 'main.go', `
97
+ package main
98
+
99
+ func main() {
100
+ println("hello")
101
+ }
102
+ `);
103
+
104
+ await buildAsync(tmpDir, { codeOnly: true });
105
+
106
+ const edges = edgeRows(join(tmpDir, '.monomind', 'monograph.db'));
107
+ const bridgeEdges = edges.filter(isBridgeEdge);
108
+ expect(bridgeEdges).toEqual([]);
109
+ });
110
+ });
@@ -1,4 +1,4 @@
1
- import { execSync } from 'child_process';
1
+ import { execFileSync } from 'child_process';
2
2
  import * as path from 'path';
3
3
 
4
4
  export class ChangedFilesError extends Error {
@@ -8,9 +8,18 @@ export class ChangedFilesError extends Error {
8
8
  }
9
9
  }
10
10
 
11
+ // Leading '-' is rejected explicitly (not just by the character class) so a
12
+ // value like "--output=/tmp/pwned" can't be passed through and interpreted
13
+ // by git as a command-line option instead of a ref (git option injection).
11
14
  const VALID_REF_RE = /^[a-zA-Z0-9\-_./@~^]+$/;
12
15
 
13
16
  export function validateGitRef(ref: string): string {
17
+ if (ref.startsWith('-')) {
18
+ throw new ChangedFilesError(
19
+ `Invalid git ref: "${ref}". Refs must not start with '-' (would be interpreted as a git option)`,
20
+ 'invalid_ref',
21
+ );
22
+ }
14
23
  if (VALID_REF_RE.test(ref)) {
15
24
  return ref;
16
25
  }
@@ -25,7 +34,10 @@ export async function getChangedFiles(root: string, sinceRef: string): Promise<S
25
34
 
26
35
  let output: string;
27
36
  try {
28
- output = execSync(`git diff --name-only -z ${sinceRef} HEAD`, {
37
+ // execFileSync with array argv no shell involved, so a `root` (cwd)
38
+ // containing '"' or '$(...)' cannot break out and execute arbitrary
39
+ // commands.
40
+ output = execFileSync('git', ['diff', '--name-only', '-z', sinceRef, 'HEAD'], {
29
41
  cwd: root,
30
42
  encoding: 'utf8',
31
43
  });
@@ -1,4 +1,4 @@
1
- import { execSync } from 'child_process';
1
+ import { execFileSync } from 'child_process';
2
2
  import { existsSync, readdirSync, readFileSync } from 'fs';
3
3
  import { join, relative, resolve } from 'path';
4
4
 
@@ -48,15 +48,24 @@ export function getChangedWorkspaces(
48
48
  sinceRef: string,
49
49
  workspaceRoots?: string[],
50
50
  ): WorkspacePackage[] {
51
- // Validate git ref (same allowlist as changed-files.ts)
51
+ // Validate git ref (same allowlist as changed-files.ts). Leading '-' is
52
+ // rejected explicitly so a value like "--output=/tmp/pwned" can't be
53
+ // interpreted by git as a command-line option (git option injection).
54
+ if (sinceRef.startsWith('-')) {
55
+ throw new Error(`Invalid git ref: "${sinceRef}". Refs must not start with '-' (would be interpreted as a git option)`);
56
+ }
52
57
  if (!/^[a-zA-Z0-9\-_./@~^]+$/.test(sinceRef)) {
53
58
  throw new Error(`Invalid git ref: "${sinceRef}"`);
54
59
  }
55
60
 
56
61
  let changedFiles: Set<string>;
57
62
  try {
58
- const raw = execSync(
59
- `git -C "${projectRoot}" diff --name-only "${sinceRef}"...HEAD`,
63
+ // execFileSync with array argv — no shell involved, so a `projectRoot`
64
+ // containing '"' or '$(...)' cannot break out and execute arbitrary
65
+ // commands.
66
+ const raw = execFileSync(
67
+ 'git',
68
+ ['-C', projectRoot, 'diff', '--name-only', `${sinceRef}...HEAD`],
60
69
  { encoding: 'utf8' }
61
70
  );
62
71
  changedFiles = new Set(raw.split('\n').map(l => l.trim()).filter(Boolean));
@@ -1,5 +1,5 @@
1
1
  import { execSync } from 'child_process';
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
3
3
  import { join, relative } from 'path';
4
4
 
5
5
  export type ChurnTrend = 'accelerating' | 'stable' | 'cooling';
@@ -59,7 +59,15 @@ export function computeRecencyWeight(ageDays: number): number {
59
59
  return Math.exp(-Math.LN2 * ageDays / 90);
60
60
  }
61
61
 
62
- export function classifyChurnTrend(recentWeighted: number, olderWeighted: number): ChurnTrend {
62
+ export function classifyChurnTrend(
63
+ recentWeighted: number,
64
+ olderWeighted: number,
65
+ commitCount = 2,
66
+ ): ChurnTrend {
67
+ // A file with 0-1 commits in the window has no "older" phase to compare against —
68
+ // classifying it as 'cooling' (declining activity) is nonsensical for something
69
+ // that was just created or touched once.
70
+ if (commitCount <= 1) return 'stable';
63
71
  if (olderWeighted === 0) {
64
72
  return recentWeighted > 0 ? 'accelerating' : 'stable';
65
73
  }
@@ -69,6 +77,40 @@ export function classifyChurnTrend(recentWeighted: number, olderWeighted: number
69
77
  return 'stable';
70
78
  }
71
79
 
80
+ /** Coarse day-granularity bucket for the current moment, e.g. "2026-07-08". */
81
+ function todayBucket(): string {
82
+ return new Date().toISOString().slice(0, 10);
83
+ }
84
+
85
+ /**
86
+ * Delete churn cache files whose date bucket is older than `maxAgeDays` — the
87
+ * weighted-commit numbers decay with time even at a fixed HEAD, and without this
88
+ * a repo idle for weeks would keep serving stale cache, while cache files from
89
+ * every past HEAD/day combination would accumulate forever.
90
+ */
91
+ function pruneChurnCache(cacheDir: string, maxAgeDays = 3): void {
92
+ let entries: string[];
93
+ try {
94
+ entries = readdirSync(cacheDir);
95
+ } catch {
96
+ return;
97
+ }
98
+ const now = Date.now();
99
+ for (const entry of entries) {
100
+ const m = entry.match(/^churn-.+-(\d{4}-\d{2}-\d{2})\.json$/);
101
+ if (!m) continue;
102
+ const bucketMs = Date.parse(m[1]);
103
+ if (isNaN(bucketMs)) continue;
104
+ if ((now - bucketMs) / 86400000 > maxAgeDays) {
105
+ try {
106
+ unlinkSync(join(cacheDir, entry));
107
+ } catch {
108
+ // non-fatal — best-effort prune
109
+ }
110
+ }
111
+ }
112
+ }
113
+
72
114
  export async function analyzeChurn(
73
115
  root: string,
74
116
  since: SinceDuration | string,
@@ -76,12 +118,16 @@ export async function analyzeChurn(
76
118
  const sinceDuration: SinceDuration =
77
119
  typeof since === 'string' ? parseSince(since) : since;
78
120
 
79
- // Cache lookup
121
+ // Cache lookup — keyed by HEAD SHA + window size + a coarse day bucket, so
122
+ // entries naturally invalidate as time passes even at the same HEAD (the
123
+ // recency weighting is time-dependent, not just HEAD-dependent).
80
124
  const cacheDir = join(root, '.monograph', 'cache');
125
+ const dateBucket = todayBucket();
126
+ pruneChurnCache(cacheDir);
81
127
  let treeHash = '';
82
128
  try {
83
129
  treeHash = execSync('git rev-parse HEAD', { cwd: root }).toString().trim();
84
- const cacheFile = join(cacheDir, `churn-${treeHash}-${sinceDuration.days}.json`);
130
+ const cacheFile = join(cacheDir, `churn-${treeHash}-${sinceDuration.days}-${dateBucket}.json`);
85
131
  if (existsSync(cacheFile)) {
86
132
  try {
87
133
  const cached = JSON.parse(readFileSync(cacheFile, 'utf8'));
@@ -154,8 +200,10 @@ export async function analyzeChurn(
154
200
  const authorWeights = new Map<number, number>();
155
201
  let totalWeighted = 0;
156
202
 
157
- // Split into two halves for trend analysis
158
- const midpoint = Math.floor(sortedCommits.length / 2);
203
+ // Split by TIME (midpoint of the `since` window), not by commit-array index —
204
+ // otherwise 10 commits all from the last week still get split 5-old/5-recent,
205
+ // misrepresenting genuinely-recent-only activity as having an "older" phase.
206
+ const windowMidpoint = now - (sinceDuration.days * 86400000) / 2;
159
207
  let recentWeighted = 0;
160
208
  let olderWeighted = 0;
161
209
 
@@ -176,15 +224,14 @@ export async function analyzeChurn(
176
224
  authorWeights.set(authorIdx, existing + weight);
177
225
  totalWeighted += weight;
178
226
 
179
- // recent = first half (newest), older = second half
180
- if (i < midpoint) {
227
+ if (commitDate >= windowMidpoint) {
181
228
  recentWeighted += weight;
182
229
  } else {
183
230
  olderWeighted += weight;
184
231
  }
185
232
  }
186
233
 
187
- const trend = classifyChurnTrend(recentWeighted, olderWeighted);
234
+ const trend = classifyChurnTrend(recentWeighted, olderWeighted, sortedCommits.length);
188
235
 
189
236
  const authors: AuthorContribution[] = Array.from(authorWeights.entries()).map(
190
237
  ([authorIdx, weightedCommits]) => ({ authorIdx, weightedCommits }),
@@ -205,7 +252,7 @@ export async function analyzeChurn(
205
252
  if (treeHash) {
206
253
  try {
207
254
  mkdirSync(cacheDir, { recursive: true });
208
- const cacheFile = join(cacheDir, `churn-${treeHash}-${sinceDuration.days}.json`);
255
+ const cacheFile = join(cacheDir, `churn-${treeHash}-${sinceDuration.days}-${dateBucket}.json`);
209
256
  writeFileSync(cacheFile, JSON.stringify(result), 'utf8');
210
257
  } catch {
211
258
  // cache write failure is non-fatal