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.
- package/CONTRIBUTING.md +19 -15
- package/README.md +192 -415
- package/docs/api/README.md +99 -0
- package/docs/api/did_we_discuss_this.md +34 -0
- package/docs/api/dismiss_suggestion.md +34 -0
- package/docs/api/explain_change_in_natural_language.md +33 -0
- package/docs/api/find_consumers_of_api.md +34 -0
- package/docs/api/get_action_patterns.md +32 -0
- package/docs/api/get_active_drift.md +32 -0
- package/docs/api/get_active_suggestions.md +25 -0
- package/docs/api/get_ai_cost_attribution.md +32 -0
- package/docs/api/get_arch_events.md +42 -0
- package/docs/api/get_architectural_drift.md +37 -0
- package/docs/api/get_architecture.md +25 -0
- package/docs/api/get_blast_radius.md +34 -0
- package/docs/api/get_canonical_pattern.md +39 -0
- package/docs/api/get_change_plan.md +34 -0
- package/docs/api/get_change_velocity.md +32 -0
- package/docs/api/get_churn_vs_blast_radius.md +32 -0
- package/docs/api/get_complexity_trend.md +39 -0
- package/docs/api/get_context.md +34 -0
- package/docs/api/get_conventions.md +32 -0
- package/docs/api/get_cross_domain.md +25 -0
- package/docs/api/get_cross_language_call_graph.md +25 -0
- package/docs/api/get_cross_repo_blast_radius.md +34 -0
- package/docs/api/get_cross_team_coupling.md +25 -0
- package/docs/api/get_data_flow.md +33 -0
- package/docs/api/get_dead_code_with_confidence.md +32 -0
- package/docs/api/get_decision_log.md +32 -0
- package/docs/api/get_dependency_surface.md +25 -0
- package/docs/api/get_domain.md +34 -0
- package/docs/api/get_domain_evolution.md +39 -0
- package/docs/api/get_domain_health.md +32 -0
- package/docs/api/get_domains_list.md +25 -0
- package/docs/api/get_drift_digest.md +32 -0
- package/docs/api/get_env_vars.md +32 -0
- package/docs/api/get_evolution_delta.md +36 -0
- package/docs/api/get_file_ownership.md +33 -0
- package/docs/api/get_file_summary.md +34 -0
- package/docs/api/get_high_impact_files.md +32 -0
- package/docs/api/get_hot_in_prod_no_tests.md +34 -0
- package/docs/api/get_hotspot_files.md +37 -0
- package/docs/api/get_iac_resources.md +25 -0
- package/docs/api/get_interface_contract.md +33 -0
- package/docs/api/get_intervention_history.md +32 -0
- package/docs/api/get_invariants.md +37 -0
- package/docs/api/get_llm_enrichment.md +33 -0
- package/docs/api/get_microservice_cut_points.md +32 -0
- package/docs/api/get_microservices_migration_cut_points.md +25 -0
- package/docs/api/get_minimal_context_for_intent.md +39 -0
- package/docs/api/get_models.md +32 -0
- package/docs/api/get_neighbors.md +39 -0
- package/docs/api/get_org_architecture.md +25 -0
- package/docs/api/get_org_domain_mapping.md +25 -0
- package/docs/api/get_pending_decisions.md +32 -0
- package/docs/api/get_predictive_risk.md +32 -0
- package/docs/api/get_progressive_disclosure_tree.md +25 -0
- package/docs/api/get_recent_decisions.md +37 -0
- package/docs/api/get_risk_weighted_blast_radius.md +32 -0
- package/docs/api/get_routes.md +25 -0
- package/docs/api/get_safety_checklist.md +33 -0
- package/docs/api/get_semantic_diff.md +34 -0
- package/docs/api/get_service_boundary_violations.md +25 -0
- package/docs/api/get_service_dependency_graph.md +25 -0
- package/docs/api/get_session_context.md +32 -0
- package/docs/api/get_similar_patterns.md +39 -0
- package/docs/api/get_stale_docs.md +25 -0
- package/docs/api/get_structure.md +25 -0
- package/docs/api/get_temporal_context.md +34 -0
- package/docs/api/get_test_coverage_map.md +25 -0
- package/docs/api/get_token_budget_report.md +36 -0
- package/docs/api/get_upgrade_risk.md +25 -0
- package/docs/api/get_working_memory.md +25 -0
- package/docs/api/ingest_otlp_traces.md +34 -0
- package/docs/api/scaffold_for_intent.md +34 -0
- package/docs/api/search_routes.md +34 -0
- package/docs/api/simulate_change_impact.md +37 -0
- package/docs/api/validate_change.md +39 -0
- package/docs/api/validate_diff.md +39 -0
- package/docs/concepts/anci.md +87 -0
- package/docs/concepts/blast-radius.md +66 -0
- package/docs/concepts/domains.md +91 -0
- package/docs/concepts/import-graph.md +102 -0
- package/docs/concepts/mcp-integration.md +148 -0
- package/docs/guides/adding-feature-safely.md +101 -0
- package/docs/guides/ci-integration.md +175 -0
- package/docs/guides/monorepo-setup.md +121 -0
- package/docs/guides/onboarding-new-engineer.md +121 -0
- package/docs/guides/pre-merge-review.md +139 -0
- package/docs/migration/v1-to-v2.md +110 -0
- package/docs/quickstart.md +95 -0
- package/docs/scale.md +2 -2
- package/docs/troubleshooting.md +180 -0
- package/package.json +10 -4
- package/scripts/gen-api-docs.js +170 -0
- package/src/acp/agent.js +83 -11
- package/src/acp/config.js +64 -0
- package/src/acp/persistence.js +146 -0
- package/src/acp/providers/anthropic.js +179 -27
- package/src/acp/providers/index.js +15 -2
- package/src/acp/providers/openai.js +164 -38
- package/src/acp/providers/sse.js +82 -0
- package/src/acp/safety.js +128 -0
- package/src/acp/session.js +73 -0
- package/src/adjacent/call-graph.js +170 -0
- package/src/adjacent/iac.js +167 -0
- package/src/adjacent/llm-enrich.js +35 -0
- package/src/adjacent/runtime.js +216 -0
- package/src/adjacent/semantic-diff.js +143 -0
- package/src/agents/scan-structure.js +1 -1
- package/src/ai/context-builder.js +215 -0
- package/src/ai/retrieval/lexical.js +122 -0
- package/src/ai/retrieval/rrf.js +121 -0
- package/src/ai/retrieval/semantic.js +35 -0
- package/src/ai/retrieval/structural.js +82 -0
- package/src/ai/tools.js +423 -0
- package/src/anci/emit.js +2 -2
- package/src/bitmap/index.js +1 -1
- package/src/bitmap/tools.js +2 -2
- package/src/brain/conventions/index.js +185 -0
- package/src/brain/index.js +31 -0
- package/src/brain/invariants/index.js +252 -0
- package/src/brain/procedural/index.js +181 -0
- package/src/brain/suggestions/index.js +153 -0
- package/src/brain/working/index.js +170 -0
- package/src/cli/check.js +47 -1
- package/src/cli/diff.js +83 -0
- package/src/cli/doctor.js +270 -0
- package/src/cli/explain.js +61 -0
- package/src/cli/index.js +101 -0
- package/src/cli/init.js +143 -5
- package/src/cli/org.js +172 -0
- package/src/cli/pr-impact.js +59 -2
- package/src/cli/serve.js +1 -1
- package/src/cli/status.js +211 -0
- package/src/cli/sync.js +2 -2
- package/src/cli/temporal.js +188 -0
- package/src/cli/validate.js +201 -0
- package/src/cli/watch.js +4 -4
- package/src/cli/why.js +101 -0
- package/src/extractors/frameworks.js +236 -0
- package/src/extractors/languages/dart.js +138 -0
- package/src/extractors/languages/go.js +11 -1
- package/src/extractors/languages/javascript.js +13 -3
- package/src/extractors/languages/kotlin.js +169 -0
- package/src/extractors/languages/php.js +195 -0
- package/src/extractors/languages/python.js +12 -1
- package/src/extractors/languages/swift.js +140 -0
- package/src/extractors/languages/typescript.js +15 -2
- package/src/extractors/plugin-api.js +102 -0
- package/src/mcp/files-without-tests.js +285 -0
- package/src/mcp/middleware/index.js +451 -0
- package/src/mcp/server.js +2292 -0
- package/src/mcp/validate.js +1 -1
- package/src/org/detect.js +262 -0
- package/src/org/queries.js +144 -0
- package/src/org/store.js +173 -0
- package/src/org/sync.js +106 -0
- package/src/predictive/cut-points.js +83 -0
- package/src/predictive/drift-digest.js +88 -0
- package/src/predictive/ownership.js +145 -0
- package/src/predictive/risk-score.js +121 -0
- package/src/predictive/validate-change.js +55 -0
- package/src/store/store-adapter.js +3 -3
- package/src/store/{sync-v2.js → sync.js} +53 -13
- package/src/temporal/backfill.js +211 -0
- package/src/temporal/delta.js +85 -0
- package/src/temporal/events.js +180 -0
- package/src/temporal/queries.js +358 -0
- package/src/temporal/snapshot.js +151 -0
- package/src/temporal/store.js +400 -0
- 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 };
|