carto-md 2.0.9 → 2.1.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 (172) hide show
  1. package/CONTRIBUTING.md +19 -15
  2. package/README.md +192 -415
  3. package/docs/api/README.md +99 -0
  4. package/docs/api/did_we_discuss_this.md +34 -0
  5. package/docs/api/dismiss_suggestion.md +34 -0
  6. package/docs/api/explain_change_in_natural_language.md +33 -0
  7. package/docs/api/find_consumers_of_api.md +34 -0
  8. package/docs/api/get_action_patterns.md +32 -0
  9. package/docs/api/get_active_drift.md +32 -0
  10. package/docs/api/get_active_suggestions.md +25 -0
  11. package/docs/api/get_ai_cost_attribution.md +32 -0
  12. package/docs/api/get_arch_events.md +42 -0
  13. package/docs/api/get_architectural_drift.md +37 -0
  14. package/docs/api/get_architecture.md +25 -0
  15. package/docs/api/get_blast_radius.md +34 -0
  16. package/docs/api/get_canonical_pattern.md +39 -0
  17. package/docs/api/get_change_plan.md +34 -0
  18. package/docs/api/get_change_velocity.md +32 -0
  19. package/docs/api/get_churn_vs_blast_radius.md +32 -0
  20. package/docs/api/get_complexity_trend.md +39 -0
  21. package/docs/api/get_context.md +34 -0
  22. package/docs/api/get_conventions.md +32 -0
  23. package/docs/api/get_cross_domain.md +25 -0
  24. package/docs/api/get_cross_language_call_graph.md +25 -0
  25. package/docs/api/get_cross_repo_blast_radius.md +34 -0
  26. package/docs/api/get_cross_team_coupling.md +25 -0
  27. package/docs/api/get_data_flow.md +33 -0
  28. package/docs/api/get_dead_code_with_confidence.md +32 -0
  29. package/docs/api/get_decision_log.md +32 -0
  30. package/docs/api/get_dependency_surface.md +25 -0
  31. package/docs/api/get_domain.md +34 -0
  32. package/docs/api/get_domain_evolution.md +39 -0
  33. package/docs/api/get_domain_health.md +32 -0
  34. package/docs/api/get_domains_list.md +25 -0
  35. package/docs/api/get_drift_digest.md +32 -0
  36. package/docs/api/get_env_vars.md +32 -0
  37. package/docs/api/get_evolution_delta.md +36 -0
  38. package/docs/api/get_file_ownership.md +33 -0
  39. package/docs/api/get_file_summary.md +34 -0
  40. package/docs/api/get_high_impact_files.md +32 -0
  41. package/docs/api/get_hot_in_prod_no_tests.md +34 -0
  42. package/docs/api/get_hotspot_files.md +37 -0
  43. package/docs/api/get_iac_resources.md +25 -0
  44. package/docs/api/get_interface_contract.md +33 -0
  45. package/docs/api/get_intervention_history.md +32 -0
  46. package/docs/api/get_invariants.md +37 -0
  47. package/docs/api/get_llm_enrichment.md +33 -0
  48. package/docs/api/get_microservice_cut_points.md +32 -0
  49. package/docs/api/get_microservices_migration_cut_points.md +25 -0
  50. package/docs/api/get_minimal_context_for_intent.md +39 -0
  51. package/docs/api/get_models.md +32 -0
  52. package/docs/api/get_neighbors.md +39 -0
  53. package/docs/api/get_org_architecture.md +25 -0
  54. package/docs/api/get_org_domain_mapping.md +25 -0
  55. package/docs/api/get_pending_decisions.md +32 -0
  56. package/docs/api/get_predictive_risk.md +32 -0
  57. package/docs/api/get_progressive_disclosure_tree.md +25 -0
  58. package/docs/api/get_recent_decisions.md +37 -0
  59. package/docs/api/get_risk_weighted_blast_radius.md +32 -0
  60. package/docs/api/get_routes.md +25 -0
  61. package/docs/api/get_safety_checklist.md +33 -0
  62. package/docs/api/get_semantic_diff.md +34 -0
  63. package/docs/api/get_service_boundary_violations.md +25 -0
  64. package/docs/api/get_service_dependency_graph.md +25 -0
  65. package/docs/api/get_session_context.md +32 -0
  66. package/docs/api/get_similar_patterns.md +39 -0
  67. package/docs/api/get_stale_docs.md +25 -0
  68. package/docs/api/get_structure.md +25 -0
  69. package/docs/api/get_temporal_context.md +34 -0
  70. package/docs/api/get_test_coverage_map.md +25 -0
  71. package/docs/api/get_token_budget_report.md +36 -0
  72. package/docs/api/get_upgrade_risk.md +25 -0
  73. package/docs/api/get_working_memory.md +25 -0
  74. package/docs/api/ingest_otlp_traces.md +34 -0
  75. package/docs/api/scaffold_for_intent.md +34 -0
  76. package/docs/api/search_routes.md +34 -0
  77. package/docs/api/simulate_change_impact.md +37 -0
  78. package/docs/api/validate_change.md +39 -0
  79. package/docs/api/validate_diff.md +39 -0
  80. package/docs/concepts/anci.md +87 -0
  81. package/docs/concepts/blast-radius.md +66 -0
  82. package/docs/concepts/domains.md +91 -0
  83. package/docs/concepts/import-graph.md +102 -0
  84. package/docs/concepts/mcp-integration.md +148 -0
  85. package/docs/guides/adding-feature-safely.md +101 -0
  86. package/docs/guides/ci-integration.md +175 -0
  87. package/docs/guides/monorepo-setup.md +121 -0
  88. package/docs/guides/onboarding-new-engineer.md +121 -0
  89. package/docs/guides/pre-merge-review.md +139 -0
  90. package/docs/migration/v1-to-v2.md +110 -0
  91. package/docs/quickstart.md +95 -0
  92. package/docs/scale.md +2 -2
  93. package/docs/troubleshooting.md +180 -0
  94. package/package.json +10 -4
  95. package/scripts/gen-api-docs.js +170 -0
  96. package/src/acp/agent.js +83 -11
  97. package/src/acp/config.js +64 -0
  98. package/src/acp/persistence.js +146 -0
  99. package/src/acp/providers/anthropic.js +179 -27
  100. package/src/acp/providers/index.js +15 -2
  101. package/src/acp/providers/openai.js +164 -38
  102. package/src/acp/providers/sse.js +82 -0
  103. package/src/acp/safety.js +128 -0
  104. package/src/acp/session.js +73 -0
  105. package/src/adjacent/call-graph.js +170 -0
  106. package/src/adjacent/iac.js +167 -0
  107. package/src/adjacent/llm-enrich.js +35 -0
  108. package/src/adjacent/runtime.js +216 -0
  109. package/src/adjacent/semantic-diff.js +143 -0
  110. package/src/agents/scan-structure.js +1 -1
  111. package/src/ai/context-builder.js +215 -0
  112. package/src/ai/retrieval/lexical.js +122 -0
  113. package/src/ai/retrieval/rrf.js +121 -0
  114. package/src/ai/retrieval/semantic.js +35 -0
  115. package/src/ai/retrieval/structural.js +82 -0
  116. package/src/ai/tools.js +423 -0
  117. package/src/anci/emit.js +2 -2
  118. package/src/bitmap/index.js +1 -1
  119. package/src/bitmap/tools.js +2 -2
  120. package/src/brain/conventions/index.js +185 -0
  121. package/src/brain/index.js +31 -0
  122. package/src/brain/invariants/index.js +252 -0
  123. package/src/brain/procedural/index.js +181 -0
  124. package/src/brain/suggestions/index.js +153 -0
  125. package/src/brain/working/index.js +170 -0
  126. package/src/cli/check.js +47 -1
  127. package/src/cli/diff.js +83 -0
  128. package/src/cli/doctor.js +270 -0
  129. package/src/cli/explain.js +61 -0
  130. package/src/cli/index.js +101 -0
  131. package/src/cli/init.js +143 -5
  132. package/src/cli/org.js +172 -0
  133. package/src/cli/pr-impact.js +59 -2
  134. package/src/cli/serve.js +1 -1
  135. package/src/cli/status.js +211 -0
  136. package/src/cli/sync.js +2 -2
  137. package/src/cli/temporal.js +188 -0
  138. package/src/cli/validate.js +201 -0
  139. package/src/cli/watch.js +4 -4
  140. package/src/cli/why.js +101 -0
  141. package/src/extractors/frameworks.js +236 -0
  142. package/src/extractors/languages/dart.js +138 -0
  143. package/src/extractors/languages/go.js +11 -1
  144. package/src/extractors/languages/javascript.js +13 -3
  145. package/src/extractors/languages/kotlin.js +169 -0
  146. package/src/extractors/languages/php.js +195 -0
  147. package/src/extractors/languages/python.js +12 -1
  148. package/src/extractors/languages/swift.js +140 -0
  149. package/src/extractors/languages/typescript.js +15 -2
  150. package/src/extractors/plugin-api.js +102 -0
  151. package/src/mcp/files-without-tests.js +285 -0
  152. package/src/mcp/middleware/index.js +451 -0
  153. package/src/mcp/server.js +2292 -0
  154. package/src/mcp/validate.js +1 -1
  155. package/src/org/detect.js +262 -0
  156. package/src/org/queries.js +144 -0
  157. package/src/org/store.js +173 -0
  158. package/src/org/sync.js +106 -0
  159. package/src/predictive/cut-points.js +83 -0
  160. package/src/predictive/drift-digest.js +88 -0
  161. package/src/predictive/ownership.js +145 -0
  162. package/src/predictive/risk-score.js +121 -0
  163. package/src/predictive/validate-change.js +55 -0
  164. package/src/store/store-adapter.js +3 -3
  165. package/src/store/{sync-v2.js → sync.js} +53 -13
  166. package/src/temporal/backfill.js +211 -0
  167. package/src/temporal/delta.js +85 -0
  168. package/src/temporal/events.js +180 -0
  169. package/src/temporal/queries.js +358 -0
  170. package/src/temporal/snapshot.js +151 -0
  171. package/src/temporal/store.js +400 -0
  172. package/src/mcp/server-v2.js +0 -986
@@ -0,0 +1,358 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Temporal queries — back the 8 temporal MCP tools.
5
+ *
6
+ * Each function takes an open TemporalStore plus optional time window and
7
+ * returns a plain-object result the MCP server formats as markdown.
8
+ *
9
+ * Tools:
10
+ * 1. getArchitecturalDrift(domain?, sinceMs)
11
+ * 2. getDomainEvolution(domain, sinceMs)
12
+ * 3. getHotspotFiles({ sinceMs, limit })
13
+ * 4. getComplexityTrend(filePath, sinceMs)
14
+ * 5. getChurnVsBlastRadius(sinceMs)
15
+ * 6. getArchEvents({ severity, sinceMs, kind, limit })
16
+ * 7. getDomainHealth(domain?)
17
+ * 8. getTemporalContext(filePath)
18
+ *
19
+ * All queries fail soft: if the temporal DB is missing, return an empty
20
+ * structured response. Callers (MCP server, CLI) decide how to render that.
21
+ */
22
+
23
+ /**
24
+ * parseTimeRange("7d" | "30d" | "24h" | "1y") → milliseconds | null
25
+ */
26
+ function parseTimeRange(s) {
27
+ if (typeof s !== 'string' || s.length === 0) return null;
28
+ const m = s.trim().match(/^(\d+)\s*([smhdwy])?$/i);
29
+ if (!m) return null;
30
+ const n = parseInt(m[1], 10);
31
+ if (!Number.isFinite(n) || n < 0) return null;
32
+ const unit = (m[2] || 'd').toLowerCase();
33
+ const mult = unit === 's' ? 1000 :
34
+ unit === 'm' ? 60_000 :
35
+ unit === 'h' ? 3_600_000 :
36
+ unit === 'd' ? 86_400_000 :
37
+ unit === 'w' ? 604_800_000 :
38
+ unit === 'y' ? 31_536_000_000 : null;
39
+ if (mult === null) return null;
40
+ return n * mult;
41
+ }
42
+
43
+ function sinceTsFromRange(range) {
44
+ if (!range) return null;
45
+ const ms = parseTimeRange(range);
46
+ if (!ms) return null;
47
+ return Date.now() - ms;
48
+ }
49
+
50
+ /**
51
+ * getArchitecturalDrift(temporal, { domain, timeRange })
52
+ *
53
+ * Returns: { window, totals, byDomain, trend }
54
+ * - byDomain: [{ domain, snapshots, fileChange, eventCount }]
55
+ * - trend: 'growing' | 'stable' | 'shrinking' | 'unknown'
56
+ */
57
+ function getArchitecturalDrift(temporal, { domain = null, timeRange = '30d' } = {}) {
58
+ if (!temporal || !temporal.db) return emptyDriftResult();
59
+ const sinceTs = sinceTsFromRange(timeRange);
60
+ const snaps = sinceTs ? temporal.getSnapshotsBetween(sinceTs, Date.now()) : temporal.getSnapshotsBetween(0, Date.now());
61
+ if (snaps.length < 2) return { ...emptyDriftResult(), window: timeRange, reason: 'insufficient_data' };
62
+
63
+ const first = snaps[0];
64
+ const last = snaps[snaps.length - 1];
65
+
66
+ // Per-domain growth between first and last.
67
+ const firstDomains = bucketByDomain(temporal.getFileDomainsAt(first.id));
68
+ const lastDomains = bucketByDomain(temporal.getFileDomainsAt(last.id));
69
+ const allDomains = new Set([...firstDomains.keys(), ...lastDomains.keys()]);
70
+
71
+ const byDomain = [];
72
+ for (const d of allDomains) {
73
+ if (domain && d !== domain) continue;
74
+ const before = (firstDomains.get(d) || new Set()).size;
75
+ const after = (lastDomains.get(d) || new Set()).size;
76
+ const eventCount = temporal.getArchEvents({ sinceTs }).filter(e => e.domain === d).length;
77
+ byDomain.push({ domain: d, before, after, delta: after - before, eventCount });
78
+ }
79
+ byDomain.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
80
+
81
+ const totalBefore = byDomain.reduce((s, r) => s + r.before, 0);
82
+ const totalAfter = byDomain.reduce((s, r) => s + r.after, 0);
83
+ const trend = totalAfter > totalBefore * 1.05 ? 'growing'
84
+ : totalAfter < totalBefore * 0.95 ? 'shrinking'
85
+ : 'stable';
86
+
87
+ return {
88
+ window: timeRange,
89
+ totals: { snapshots: snaps.length, fileCountBefore: totalBefore, fileCountAfter: totalAfter },
90
+ byDomain,
91
+ trend,
92
+ };
93
+ }
94
+
95
+ function emptyDriftResult() {
96
+ return { window: null, totals: { snapshots: 0, fileCountBefore: 0, fileCountAfter: 0 }, byDomain: [], trend: 'unknown' };
97
+ }
98
+
99
+ /**
100
+ * getDomainEvolution(temporal, { domain, timeRange })
101
+ *
102
+ * Returns: { domain, points: [{ ts, fileCount }] }
103
+ */
104
+ function getDomainEvolution(temporal, { domain, timeRange = '90d' } = {}) {
105
+ if (!temporal || !temporal.db || !domain) return { domain, points: [] };
106
+ const sinceTs = sinceTsFromRange(timeRange);
107
+ const snaps = sinceTs ? temporal.getSnapshotsBetween(sinceTs, Date.now()) : temporal.getSnapshotsBetween(0, Date.now());
108
+ const points = [];
109
+ for (const s of snaps) {
110
+ const buckets = bucketByDomain(temporal.getFileDomainsAt(s.id));
111
+ const set = buckets.get(domain);
112
+ const fileCount = set ? set.size : 0;
113
+ points.push({ ts: s.ts, snapshot_id: s.id, fileCount });
114
+ }
115
+ return { domain, points };
116
+ }
117
+
118
+ /**
119
+ * getHotspotFiles(temporal, { sinceTs?, limit? })
120
+ *
121
+ * "Hotspot" = commit_count * blast_radius (the classic CodeHealth heuristic
122
+ * adapted for Carto). High-churn files in high-blast-radius positions are
123
+ * where bugs cluster.
124
+ *
125
+ * Returns: { hotspots: [{ file_path, commit_count, blast_radius, score }] }
126
+ */
127
+ function getHotspotFiles(temporal, { timeRange = '90d', limit = 20 } = {}) {
128
+ if (!temporal || !temporal.db) return { hotspots: [], window: timeRange };
129
+ const sinceTs = sinceTsFromRange(timeRange);
130
+ const churn = temporal.getTopChurned(500, sinceTs);
131
+ const scored = churn.map(c => ({
132
+ file_path: c.file_path,
133
+ commit_count: c.commit_count,
134
+ blast_radius: c.blast_radius || 0,
135
+ last_modified_ts: c.last_modified_ts,
136
+ score: c.commit_count * Math.max(1, c.blast_radius || 0),
137
+ }));
138
+ scored.sort((a, b) => b.score - a.score);
139
+ return { hotspots: scored.slice(0, limit), window: timeRange };
140
+ }
141
+
142
+ /**
143
+ * getComplexityTrend(temporal, { file, timeRange })
144
+ *
145
+ * Returns: { file, points: [{ ts, blast_radius, snapshot_id }], trend }
146
+ */
147
+ function getComplexityTrend(temporal, { file, timeRange = '90d' } = {}) {
148
+ if (!temporal || !temporal.db || !file) return { file, points: [], trend: 'unknown' };
149
+ const sinceTs = sinceTsFromRange(timeRange);
150
+ // We don't currently snapshot per-file blast radius into a time series
151
+ // (that would explode storage). We approximate by reading file_domains_at
152
+ // for the file across snapshots — counts +1 per snapshot the file was
153
+ // present, and reports the *current* blast_radius from file_churn.
154
+ const rows = sinceTs
155
+ ? temporal.db.prepare(`
156
+ SELECT s.id as snapshot_id, s.ts FROM snapshots s
157
+ JOIN file_domains_at fda ON fda.snapshot_id = s.id
158
+ WHERE fda.file_path = ? AND s.ts >= ?
159
+ ORDER BY s.ts ASC
160
+ `).all(file, sinceTs)
161
+ : temporal.db.prepare(`
162
+ SELECT s.id as snapshot_id, s.ts FROM snapshots s
163
+ JOIN file_domains_at fda ON fda.snapshot_id = s.id
164
+ WHERE fda.file_path = ?
165
+ ORDER BY s.ts ASC
166
+ `).all(file);
167
+
168
+ const churn = temporal.getFileChurn(file);
169
+ const currentBR = churn ? (churn.blast_radius || 0) : 0;
170
+ const points = rows.map(r => ({ ts: r.ts, snapshot_id: r.snapshot_id, blast_radius: currentBR }));
171
+
172
+ return {
173
+ file,
174
+ points,
175
+ snapshots_present: points.length,
176
+ commit_count: churn ? churn.commit_count : 0,
177
+ last_modified_ts: churn ? churn.last_modified_ts : null,
178
+ trend: points.length >= 3 ? 'tracked' : 'sparse',
179
+ };
180
+ }
181
+
182
+ /**
183
+ * getChurnVsBlastRadius(temporal, { timeRange })
184
+ *
185
+ * Scatter data — every file in the temporal index with non-zero churn.
186
+ * Returns: { files: [{ file_path, commit_count, blast_radius }] }
187
+ */
188
+ function getChurnVsBlastRadius(temporal, { timeRange = '90d' } = {}) {
189
+ if (!temporal || !temporal.db) return { files: [], window: timeRange };
190
+ const sinceTs = sinceTsFromRange(timeRange);
191
+ const rows = sinceTs
192
+ ? temporal.db.prepare(`
193
+ SELECT file_path, commit_count, blast_radius FROM file_churn
194
+ WHERE last_modified_ts >= ? AND commit_count > 0
195
+ ORDER BY commit_count DESC
196
+ LIMIT 1000
197
+ `).all(sinceTs)
198
+ : temporal.db.prepare(`
199
+ SELECT file_path, commit_count, blast_radius FROM file_churn
200
+ WHERE commit_count > 0
201
+ ORDER BY commit_count DESC
202
+ LIMIT 1000
203
+ `).all();
204
+ return { files: rows, window: timeRange };
205
+ }
206
+
207
+ /**
208
+ * getArchEvents(temporal, { severity, timeRange, kind, limit })
209
+ */
210
+ function getArchEvents(temporal, { severity = null, timeRange = '90d', kind = null, limit = 100 } = {}) {
211
+ if (!temporal || !temporal.db) return { events: [], window: timeRange };
212
+ const sinceTs = sinceTsFromRange(timeRange);
213
+ const events = temporal.getArchEvents({ severity, kind, sinceTs, limit });
214
+ return { events, window: timeRange };
215
+ }
216
+
217
+ /**
218
+ * getDomainHealth(temporal, { domain? })
219
+ *
220
+ * Returns: { domains: [{ domain, current_size, prior_size, growth_rate,
221
+ * instability, hotspots, events }] }
222
+ */
223
+ function getDomainHealth(temporal, { domain = null } = {}) {
224
+ if (!temporal || !temporal.db) return { domains: [] };
225
+ const recent = temporal.getMostRecentSnapshot();
226
+ if (!recent) return { domains: [] };
227
+ const current = bucketByDomain(temporal.getFileDomainsAt(recent.id));
228
+
229
+ // Compare to a snapshot ~30 days ago.
230
+ const cutoff = Date.now() - 30 * 86_400_000;
231
+ const priorRow = temporal.db
232
+ .prepare('SELECT * FROM snapshots WHERE ts <= ? ORDER BY ts DESC LIMIT 1')
233
+ .get(cutoff);
234
+
235
+ const prior = priorRow ? bucketByDomain(temporal.getFileDomainsAt(priorRow.id)) : new Map();
236
+
237
+ const allDomains = new Set([...current.keys(), ...prior.keys()]);
238
+ const result = [];
239
+
240
+ for (const d of allDomains) {
241
+ if (domain && d !== domain) continue;
242
+ const c = current.get(d) || new Set();
243
+ const p = prior.get(d) || new Set();
244
+ const intersection = setIntersect(p, c);
245
+ const moved = p.size - intersection.size;
246
+ const growth = c.size - p.size;
247
+ const events = temporal.getArchEvents({ sinceTs: cutoff }).filter(e => e.domain === d);
248
+
249
+ // Score blast radii of files in this domain.
250
+ const allChurn = temporal.getAllChurn();
251
+ const churnMap = new Map(allChurn.map(r => [r.file_path, r]));
252
+ const hotFiles = [];
253
+ for (const fp of c) {
254
+ const ch = churnMap.get(fp);
255
+ if (ch && ch.commit_count >= 3 && ch.blast_radius >= 10) {
256
+ hotFiles.push({
257
+ file_path: fp,
258
+ commit_count: ch.commit_count,
259
+ blast_radius: ch.blast_radius,
260
+ });
261
+ }
262
+ }
263
+ hotFiles.sort((a, b) => (b.commit_count * b.blast_radius) - (a.commit_count * a.blast_radius));
264
+
265
+ result.push({
266
+ domain: d,
267
+ current_size: c.size,
268
+ prior_size: p.size,
269
+ growth,
270
+ growth_rate: p.size > 0 ? Math.round((growth / p.size) * 100) / 100 : null,
271
+ instability: p.size > 0 ? Math.round((moved / p.size) * 100) / 100 : 0,
272
+ hotspots: hotFiles.slice(0, 5),
273
+ events: events.length,
274
+ });
275
+ }
276
+ result.sort((a, b) => Math.abs(b.growth) - Math.abs(a.growth));
277
+ return { domains: result };
278
+ }
279
+
280
+ /**
281
+ * getTemporalContext(temporal, { file })
282
+ *
283
+ * Returns: {
284
+ * file, first_seen_ts, last_modified_ts, commit_count, blast_radius,
285
+ * recent_events: [{ ts, kind, severity, detail }],
286
+ * age_days: number,
287
+ * snapshots_present: number
288
+ * }
289
+ */
290
+ function getTemporalContext(temporal, { file } = {}) {
291
+ if (!temporal || !temporal.db || !file) return { file, present: false };
292
+ const churn = temporal.getFileChurn(file);
293
+ if (!churn) return { file, present: false };
294
+
295
+ const events = temporal.db
296
+ .prepare('SELECT * FROM arch_events WHERE file_path = ? ORDER BY ts DESC LIMIT 10')
297
+ .all(file);
298
+
299
+ const snapshots = temporal.db
300
+ .prepare('SELECT COUNT(*) as c FROM file_domains_at WHERE file_path = ?')
301
+ .get(file);
302
+
303
+ const ageDays = churn.first_seen_ts
304
+ ? Math.round((Date.now() - churn.first_seen_ts) / 86_400_000)
305
+ : null;
306
+
307
+ return {
308
+ file,
309
+ present: true,
310
+ first_seen_ts: churn.first_seen_ts,
311
+ last_modified_ts: churn.last_modified_ts,
312
+ commit_count: churn.commit_count,
313
+ blast_radius: churn.blast_radius || 0,
314
+ age_days: ageDays,
315
+ snapshots_present: snapshots ? snapshots.c : 0,
316
+ recent_events: events.map(e => ({
317
+ ts: e.ts,
318
+ kind: e.kind,
319
+ severity: e.severity,
320
+ detail: e.detail_json ? safeParse(e.detail_json) : null,
321
+ })),
322
+ };
323
+ }
324
+
325
+ // ── helpers ──────────────────────────────────────────────────────
326
+ function bucketByDomain(rows) {
327
+ const m = new Map();
328
+ for (const r of rows) {
329
+ const d = r.domain_name;
330
+ if (!d) continue;
331
+ if (!m.has(d)) m.set(d, new Set());
332
+ m.get(d).add(r.file_path);
333
+ }
334
+ return m;
335
+ }
336
+
337
+ function setIntersect(a, b) {
338
+ const out = new Set();
339
+ const [small, large] = a.size < b.size ? [a, b] : [b, a];
340
+ for (const v of small) if (large.has(v)) out.add(v);
341
+ return out;
342
+ }
343
+
344
+ function safeParse(s) {
345
+ try { return JSON.parse(s); } catch { return null; }
346
+ }
347
+
348
+ module.exports = {
349
+ getArchitecturalDrift,
350
+ getDomainEvolution,
351
+ getHotspotFiles,
352
+ getComplexityTrend,
353
+ getChurnVsBlastRadius,
354
+ getArchEvents,
355
+ getDomainHealth,
356
+ getTemporalContext,
357
+ parseTimeRange,
358
+ };
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * captureSnapshot — write a temporal snapshot from the current sidecar.
5
+ *
6
+ * Called by `runSync()` after a successful sync (and by `carto temporal
7
+ * init` for the initial state). Records:
8
+ * - snapshot row in `snapshots`
9
+ * - file→domain mapping in `file_domains_at`
10
+ * - file_churn `blast_radius` column refreshed
11
+ * - XOR delta vs the most recent prior sync snapshot (forward adjacency)
12
+ * - architectural events vs prior snapshot (domain split/merge, hotspot emergence)
13
+ *
14
+ * Cost budget: <100ms on a 10K-file repo. Hot loop is the file_domains_at
15
+ * write (a single transaction over ~10K rows). The XOR delta is a single
16
+ * gzip of a few KB of bits.
17
+ */
18
+
19
+ const { TemporalStore } = require('./store');
20
+ const { xorBitsets, compressBitset, flattenMap } = require('./delta');
21
+ const { detectEvents } = require('./events');
22
+
23
+ /**
24
+ * captureSnapshot({ projectRoot, sidecar, store, source, commitSha, ts })
25
+ *
26
+ * sidecar — bitmap sidecar from buildFromStore()
27
+ * store — main SQLiteStore (for blast radius / centrality data)
28
+ * source — 'sync' | 'commit' | 'backfill'
29
+ * commitSha — optional, for commit-source snapshots
30
+ * ts — optional, defaults to Date.now()
31
+ *
32
+ * Returns: { snapshotId, events: Array, eventCount: number }
33
+ */
34
+ function captureSnapshot({ projectRoot, sidecar, store, source = 'sync', commitSha = null, ts = null }) {
35
+ const temporal = new TemporalStore(projectRoot);
36
+ temporal.open();
37
+ try {
38
+ return captureSnapshotWithStore({ temporal, sidecar, store, source, commitSha, ts });
39
+ } finally {
40
+ temporal.close();
41
+ }
42
+ }
43
+
44
+ function captureSnapshotWithStore({ temporal, sidecar, store, source = 'sync', commitSha = null, ts = null }) {
45
+ const tsNow = ts || Date.now();
46
+ if (!sidecar) {
47
+ // No bitmap — still record a metadata-only snapshot.
48
+ const snapshotId = temporal.insertSnapshot({
49
+ ts: tsNow, commit_sha: commitSha, source,
50
+ summary: { file_count: 0, edge_count: 0, domain_count: 0 },
51
+ });
52
+ return { snapshotId, events: [], eventCount: 0 };
53
+ }
54
+
55
+ // ── 1. Compute summary stats ────────────────────────────────────
56
+ const fileCount = sidecar.filePathArr ? sidecar.filePathArr.filter(Boolean).length : 0;
57
+ let edgeCount = 0;
58
+ if (sidecar.forward) {
59
+ for (const bs of sidecar.forward.values()) edgeCount += bs.popcount();
60
+ }
61
+ const domainCount = sidecar.domainNameArr ? sidecar.domainNameArr.filter(Boolean).length : 0;
62
+
63
+ // ── 2. Insert snapshot row ──────────────────────────────────────
64
+ const snapshotId = temporal.insertSnapshot({
65
+ ts: tsNow,
66
+ commit_sha: commitSha,
67
+ source,
68
+ summary: { file_count: fileCount, edge_count: edgeCount, domain_count: domainCount },
69
+ });
70
+
71
+ // ── 3. Insert file→domain mapping ───────────────────────────────
72
+ const mappings = [];
73
+ for (let fid = 0; fid < (sidecar.size || 0); fid++) {
74
+ const filePath = sidecar.filePathArr ? sidecar.filePathArr[fid] : null;
75
+ if (!filePath) continue;
76
+ const dId = sidecar.fileDomainArr ? sidecar.fileDomainArr[fid] : -1;
77
+ const domainName = dId >= 0 && sidecar.domainNameArr ? sidecar.domainNameArr[dId] : null;
78
+ mappings.push({ file_path: filePath, domain_name: domainName });
79
+ }
80
+ temporal.insertFileDomains(snapshotId, mappings);
81
+
82
+ // ── 4. Refresh blast_radius per file ────────────────────────────
83
+ if (sidecar.popcountIndex && sidecar.filePathArr) {
84
+ const radiusMap = new Map();
85
+ for (const { fileId, count } of sidecar.popcountIndex) {
86
+ const p = sidecar.filePathArr[fileId];
87
+ if (p) radiusMap.set(p, count);
88
+ }
89
+ temporal.updateBlastRadii(radiusMap);
90
+ }
91
+
92
+ // ── 5. XOR delta vs prior snapshot ──────────────────────────────
93
+ const prior = findPriorSnapshot(temporal, snapshotId);
94
+ if (prior) {
95
+ try {
96
+ const current = flattenMap(sidecar.forward || new Map(), sidecar.size || 0);
97
+ const priorBits = priorFlatBitset(temporal, prior);
98
+ if (priorBits) {
99
+ const delta = xorBitsets(current, priorBits);
100
+ const blob = compressBitset(delta);
101
+ temporal.insertDelta(snapshotId, prior.id, 'forward_xor', blob, current.size);
102
+ }
103
+ } catch {
104
+ // XOR delta is a nice-to-have. A failure here doesn't break temporal.
105
+ }
106
+ }
107
+
108
+ // ── 6. Architectural event detection vs prior snapshot ──────────
109
+ const events = detectEvents({ temporal, snapshotId, prior, mappings, sidecar, ts: tsNow });
110
+ for (const e of events) {
111
+ temporal.insertEvent({
112
+ ts: e.ts || tsNow,
113
+ severity: e.severity,
114
+ kind: e.kind,
115
+ domain: e.domain || null,
116
+ file_path: e.file_path || null,
117
+ detail: e.detail || null,
118
+ snapshot_id: snapshotId,
119
+ });
120
+ }
121
+
122
+ return { snapshotId, events, eventCount: events.length };
123
+ }
124
+
125
+ /**
126
+ * Find the snapshot immediately prior to `snapshotId` (any source).
127
+ * Used to compute XOR deltas and event detection.
128
+ */
129
+ function findPriorSnapshot(temporal, snapshotId) {
130
+ return temporal.db
131
+ .prepare('SELECT * FROM snapshots WHERE id < ? ORDER BY id DESC LIMIT 1')
132
+ .get(snapshotId);
133
+ }
134
+
135
+ /**
136
+ * Reconstruct the prior flattened bitset by re-applying XOR deltas from
137
+ * the most recent stored full-state snapshot.
138
+ *
139
+ * Today we don't store full reconstructable forward bitsets per snapshot
140
+ * (that would defeat the XOR-delta compression). Returns null for now —
141
+ * the XOR-delta is stored as a fact about "what changed between snapshot
142
+ * N-1 and snapshot N", and queries iterate deltas to answer "what changed
143
+ * in a time window?" without needing point-in-time graph reconstruction.
144
+ *
145
+ * Full point-in-time reconstruction is a Tier-2 follow-up.
146
+ */
147
+ function priorFlatBitset(_temporal, _prior) {
148
+ return null;
149
+ }
150
+
151
+ module.exports = { captureSnapshot, captureSnapshotWithStore };