carto-md 2.0.8 → 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 +203 -286
- package/docs/anci/v0.1-DRAFT.md +420 -0
- 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 +129 -0
- package/docs/troubleshooting.md +180 -0
- package/package.json +12 -5
- package/scripts/gen-api-docs.js +170 -0
- package/scripts/postinstall.js +391 -24
- 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/leiden.js +4 -4
- package/src/agents/scan-structure.js +2 -2
- 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/consumer.js +305 -0
- package/src/anci/deserialize.js +160 -0
- package/src/anci/emit.js +85 -0
- package/src/anci/serialize.js +264 -0
- package/src/anci/yaml.js +401 -0
- 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/anci.js +237 -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 +115 -0
- package/src/cli/init.js +144 -6
- package/src/cli/inspect.js +1 -1
- package/src/cli/org.js +172 -0
- package/src/cli/pr-impact.js +554 -0
- 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 +16 -6
- 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/change-plan.js +8 -8
- 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} +105 -16
- 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,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Microservice cut-point finder.
|
|
5
|
+
*
|
|
6
|
+
* Identifies natural microservice boundaries by finding clusters with
|
|
7
|
+
* high internal cohesion and low external coupling.
|
|
8
|
+
*
|
|
9
|
+
* Method:
|
|
10
|
+
* 1. For each domain D in the store, count
|
|
11
|
+
* - intra_edges imports where both ends are in D
|
|
12
|
+
* - external_edges imports from D to ¬D, plus imports from ¬D to D
|
|
13
|
+
* 2. cohesion = intra_edges / (intra_edges + external_edges)
|
|
14
|
+
* 3. A domain with cohesion >= 0.7 and size >= 10 is a candidate cut-point.
|
|
15
|
+
*
|
|
16
|
+
* Output: per-domain { domain, files, intra_edges, external_edges,
|
|
17
|
+
* cohesion_score, candidate: boolean }.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function findCutPoints({ store, threshold = 0.7, minSize = 10 } = {}) {
|
|
21
|
+
if (!store || !store.db) return { cut_points: [], all_domains: [] };
|
|
22
|
+
|
|
23
|
+
const allDomains = store.getDomainsList();
|
|
24
|
+
const intraEdges = store.db.prepare(`
|
|
25
|
+
SELECT d.name as domain, COUNT(*) as c
|
|
26
|
+
FROM imports i
|
|
27
|
+
JOIN files f1 ON i.from_file_id = f1.id
|
|
28
|
+
JOIN files f2 ON i.to_file_id = f2.id
|
|
29
|
+
JOIN domains d ON f1.domain_id = d.id
|
|
30
|
+
WHERE f1.domain_id = f2.domain_id AND i.to_file_id IS NOT NULL
|
|
31
|
+
GROUP BY d.name
|
|
32
|
+
`).all();
|
|
33
|
+
const intraMap = new Map(intraEdges.map(r => [r.domain, r.c]));
|
|
34
|
+
|
|
35
|
+
const externalEdges = store.db.prepare(`
|
|
36
|
+
SELECT d.name as domain, COUNT(*) as c
|
|
37
|
+
FROM imports i
|
|
38
|
+
JOIN files f1 ON i.from_file_id = f1.id
|
|
39
|
+
JOIN files f2 ON i.to_file_id = f2.id
|
|
40
|
+
JOIN domains d ON f1.domain_id = d.id
|
|
41
|
+
WHERE f1.domain_id != f2.domain_id AND i.to_file_id IS NOT NULL
|
|
42
|
+
GROUP BY d.name
|
|
43
|
+
`).all();
|
|
44
|
+
const externalMap = new Map(externalEdges.map(r => [r.domain, r.c]));
|
|
45
|
+
|
|
46
|
+
// Inbound external edges: imports INTO domain D from other domains.
|
|
47
|
+
const inboundExt = store.db.prepare(`
|
|
48
|
+
SELECT d.name as domain, COUNT(*) as c
|
|
49
|
+
FROM imports i
|
|
50
|
+
JOIN files f1 ON i.from_file_id = f1.id
|
|
51
|
+
JOIN files f2 ON i.to_file_id = f2.id
|
|
52
|
+
JOIN domains d ON f2.domain_id = d.id
|
|
53
|
+
WHERE f1.domain_id != f2.domain_id AND i.to_file_id IS NOT NULL
|
|
54
|
+
GROUP BY d.name
|
|
55
|
+
`).all();
|
|
56
|
+
const inboundMap = new Map(inboundExt.map(r => [r.domain, r.c]));
|
|
57
|
+
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const d of allDomains) {
|
|
60
|
+
const intra = intraMap.get(d.name) || 0;
|
|
61
|
+
const outbound = externalMap.get(d.name) || 0;
|
|
62
|
+
const inbound = inboundMap.get(d.name) || 0;
|
|
63
|
+
const external = outbound + inbound;
|
|
64
|
+
const total = intra + external;
|
|
65
|
+
const cohesion = total === 0 ? 0 : intra / total;
|
|
66
|
+
out.push({
|
|
67
|
+
domain: d.name,
|
|
68
|
+
files: d.fileCount,
|
|
69
|
+
intra_edges: intra,
|
|
70
|
+
outbound_edges: outbound,
|
|
71
|
+
inbound_edges: inbound,
|
|
72
|
+
cohesion: Math.round(cohesion * 1000) / 1000,
|
|
73
|
+
candidate: cohesion >= threshold && d.fileCount >= minSize,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
out.sort((a, b) => b.cohesion - a.cohesion);
|
|
77
|
+
return {
|
|
78
|
+
cut_points: out.filter(x => x.candidate),
|
|
79
|
+
all_domains: out,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { findCutPoints };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Weekly drift digest renderer.
|
|
5
|
+
*
|
|
6
|
+
* Produces a CLI-renderable weekly architectural digest as markdown.
|
|
7
|
+
* Slack delivery is left to whatever cron + webhook the user wires up;
|
|
8
|
+
* this module only renders the body.
|
|
9
|
+
*
|
|
10
|
+
* Content:
|
|
11
|
+
* 1. Domain growth/shrink over `time_range` (default 7d)
|
|
12
|
+
* 2. Top hotspots (commit_count × blast_radius)
|
|
13
|
+
* 3. New architectural events
|
|
14
|
+
* 4. New cross-domain edges introduced
|
|
15
|
+
* 5. Top files by predictive risk score
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function renderDriftDigest({ store, temporalStore, projectRoot, timeRange = '7d' }) {
|
|
19
|
+
if (!store) return '# Drift Digest\n\n_No store available._';
|
|
20
|
+
|
|
21
|
+
const lines = [`# Drift Digest (${timeRange})`];
|
|
22
|
+
lines.push(`\nGenerated ${new Date().toISOString()}\n`);
|
|
23
|
+
|
|
24
|
+
// ── Domain drift ────────────────────────────────────────────────
|
|
25
|
+
if (temporalStore) {
|
|
26
|
+
try {
|
|
27
|
+
const q = require('../temporal/queries');
|
|
28
|
+
const drift = q.getArchitecturalDrift(temporalStore, { timeRange });
|
|
29
|
+
lines.push('## Domain drift');
|
|
30
|
+
lines.push(`Trend: **${drift.trend}**.`);
|
|
31
|
+
if (drift.byDomain && drift.byDomain.length > 0) {
|
|
32
|
+
lines.push('| Domain | Before | After | Δ |');
|
|
33
|
+
lines.push('|--------|-------:|------:|---|');
|
|
34
|
+
for (const d of drift.byDomain.slice(0, 10)) {
|
|
35
|
+
lines.push(`| ${d.domain} | ${d.before} | ${d.after} | ${d.delta > 0 ? '+' + d.delta : d.delta} |`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch {}
|
|
39
|
+
|
|
40
|
+
// ── Hotspots ────────────────────────────────────────────────
|
|
41
|
+
try {
|
|
42
|
+
const q = require('../temporal/queries');
|
|
43
|
+
const h = q.getHotspotFiles(temporalStore, { timeRange, limit: 10 });
|
|
44
|
+
if (h.hotspots && h.hotspots.length > 0) {
|
|
45
|
+
lines.push('\n## Top hotspots');
|
|
46
|
+
lines.push('| File | Commits | Blast | Score |');
|
|
47
|
+
lines.push('|------|--------:|------:|------:|');
|
|
48
|
+
for (const x of h.hotspots) {
|
|
49
|
+
lines.push(`| ${x.file_path} | ${x.commit_count} | ${x.blast_radius} | ${x.score} |`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch {}
|
|
53
|
+
|
|
54
|
+
// ── Events ──────────────────────────────────────────────────
|
|
55
|
+
try {
|
|
56
|
+
const q = require('../temporal/queries');
|
|
57
|
+
const e = q.getArchEvents(temporalStore, { timeRange, limit: 20 });
|
|
58
|
+
if (e.events && e.events.length > 0) {
|
|
59
|
+
lines.push('\n## Architectural events');
|
|
60
|
+
lines.push('| When | Severity | Kind | Target |');
|
|
61
|
+
lines.push('|------|----------|------|--------|');
|
|
62
|
+
for (const ev of e.events) {
|
|
63
|
+
const when = new Date(ev.ts).toISOString();
|
|
64
|
+
const target = ev.domain || ev.file_path || '';
|
|
65
|
+
lines.push(`| ${when} | ${ev.severity} | ${ev.kind} | ${target} |`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {}
|
|
69
|
+
} else {
|
|
70
|
+
lines.push('## Temporal data unavailable\n\nRun `carto temporal init` to enable drift, hotspot, and events sections.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Predictive risk top 10 ────────────────────────────────
|
|
74
|
+
try {
|
|
75
|
+
const { scoreFiles } = require('./risk-score');
|
|
76
|
+
const ranked = scoreFiles({ store, temporalStore, projectRoot }).slice(0, 10);
|
|
77
|
+
if (ranked.length > 0) {
|
|
78
|
+
lines.push('\n## Predicted-risk top 10');
|
|
79
|
+
lines.push('| File | Score |');
|
|
80
|
+
lines.push('|------|------:|');
|
|
81
|
+
for (const f of ranked) lines.push(`| ${f.path} | ${f.score} |`);
|
|
82
|
+
}
|
|
83
|
+
} catch {}
|
|
84
|
+
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { renderDriftDigest };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Team intelligence — implicit ownership detection + cross-team coupling.
|
|
5
|
+
*
|
|
6
|
+
* Detects "who touches this file most" from `git blame` and `git log`
|
|
7
|
+
* stats. Returns per-file and per-domain owners. From there we can
|
|
8
|
+
* surface cross-team coupling — Domain A is mostly touched by Alice but
|
|
9
|
+
* imports from Domain B, which Alice never touches → coordination cost.
|
|
10
|
+
*
|
|
11
|
+
* All git-shell-outs are wrapped: 10 s timeout, bounded output, errors
|
|
12
|
+
* fail soft to empty.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { execFileSync } = require('child_process');
|
|
16
|
+
|
|
17
|
+
function gitLog(projectRoot, args, timeoutMs = 10_000) {
|
|
18
|
+
try {
|
|
19
|
+
return execFileSync('git', ['-C', projectRoot, ...args], {
|
|
20
|
+
encoding: 'utf-8',
|
|
21
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
22
|
+
timeout: timeoutMs,
|
|
23
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
24
|
+
});
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* ownersForFile({ projectRoot, file }) → { file, top_author, authors: [{ name, lines }] }
|
|
32
|
+
*
|
|
33
|
+
* Uses `git blame --line-porcelain` to count lines per author. Fails soft
|
|
34
|
+
* to { file, authors: [] } when git is unavailable / file not tracked.
|
|
35
|
+
*/
|
|
36
|
+
function ownersForFile({ projectRoot, file }) {
|
|
37
|
+
const out = gitLog(projectRoot, ['blame', '--line-porcelain', '--', file]);
|
|
38
|
+
if (!out) return { file, authors: [], top_author: null };
|
|
39
|
+
const counts = new Map();
|
|
40
|
+
for (const line of out.split('\n')) {
|
|
41
|
+
if (line.startsWith('author ')) {
|
|
42
|
+
const name = line.slice('author '.length);
|
|
43
|
+
counts.set(name, (counts.get(name) || 0) + 1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const authors = Array.from(counts.entries())
|
|
47
|
+
.map(([name, lines]) => ({ name, lines }))
|
|
48
|
+
.sort((a, b) => b.lines - a.lines);
|
|
49
|
+
return { file, authors, top_author: authors[0] ? authors[0].name : null };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ownersForDomain({ store, projectRoot, domain }) — aggregates per-file
|
|
54
|
+
* blame counts across the files in `domain`. Returns top authors.
|
|
55
|
+
*/
|
|
56
|
+
function ownersForDomain({ store, projectRoot, domain, maxFiles = 100 }) {
|
|
57
|
+
if (!store || !store.db) return { domain, authors: [], top_author: null };
|
|
58
|
+
const files = store.db.prepare(`
|
|
59
|
+
SELECT f.path FROM files f
|
|
60
|
+
JOIN domains d ON f.domain_id = d.id
|
|
61
|
+
WHERE d.name = ?
|
|
62
|
+
ORDER BY f.centrality DESC LIMIT ?
|
|
63
|
+
`).all(domain, maxFiles).map(r => r.path);
|
|
64
|
+
const counts = new Map();
|
|
65
|
+
for (const f of files) {
|
|
66
|
+
const r = ownersForFile({ projectRoot, file: f });
|
|
67
|
+
for (const a of r.authors) {
|
|
68
|
+
counts.set(a.name, (counts.get(a.name) || 0) + a.lines);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const authors = Array.from(counts.entries())
|
|
72
|
+
.map(([name, lines]) => ({ name, lines }))
|
|
73
|
+
.sort((a, b) => b.lines - a.lines);
|
|
74
|
+
return { domain, authors, top_author: authors[0] ? authors[0].name : null };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* crossTeamCoupling({ store, projectRoot }) → warnings
|
|
79
|
+
*
|
|
80
|
+
* For each cross-domain edge in the graph, look up the owner of the
|
|
81
|
+
* source file and the owner of the target file. If they differ AND the
|
|
82
|
+
* source-side owner has never touched the target file, surface a
|
|
83
|
+
* coordination warning.
|
|
84
|
+
*/
|
|
85
|
+
function crossTeamCoupling({ store, projectRoot, maxEdges = 50 }) {
|
|
86
|
+
if (!store) return { warnings: [] };
|
|
87
|
+
const edges = (store.getCrossDomainDeps() || []).slice(0, maxEdges);
|
|
88
|
+
const ownerCache = new Map();
|
|
89
|
+
const warnings = [];
|
|
90
|
+
|
|
91
|
+
for (const e of edges) {
|
|
92
|
+
if (!ownerCache.has(e.from)) ownerCache.set(e.from, ownersForFile({ projectRoot, file: e.from }).top_author);
|
|
93
|
+
if (!ownerCache.has(e.to)) ownerCache.set(e.to, ownersForFile({ projectRoot, file: e.to }).top_author);
|
|
94
|
+
const fromOwner = ownerCache.get(e.from);
|
|
95
|
+
const toOwner = ownerCache.get(e.to);
|
|
96
|
+
if (fromOwner && toOwner && fromOwner !== toOwner) {
|
|
97
|
+
warnings.push({
|
|
98
|
+
from_file: e.from,
|
|
99
|
+
to_file: e.to,
|
|
100
|
+
from_domain: e.fromDomain || null,
|
|
101
|
+
to_domain: e.toDomain || null,
|
|
102
|
+
from_owner: fromOwner,
|
|
103
|
+
to_owner: toOwner,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { warnings };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* aiCostAttribution({ store, hours }) → per-client metrics
|
|
112
|
+
*
|
|
113
|
+
* Cheap proxy for "which AI client is creating the most cross-domain
|
|
114
|
+
* coupling?". Reads `ai_sessions.client_name` + joins to `decisions` and
|
|
115
|
+
* counts violations per client.
|
|
116
|
+
*/
|
|
117
|
+
function aiCostAttribution({ store, hours = 168 } = {}) {
|
|
118
|
+
if (!store || !store.db) return { clients: [] };
|
|
119
|
+
const since = Date.now() - hours * 60 * 60 * 1000;
|
|
120
|
+
const rows = store.db.prepare(`
|
|
121
|
+
SELECT s.client_name as client, COUNT(d.id) as decisions
|
|
122
|
+
FROM ai_sessions s
|
|
123
|
+
LEFT JOIN decisions d ON d.session_id = s.id
|
|
124
|
+
WHERE s.started_at >= ? OR d.ts >= ?
|
|
125
|
+
GROUP BY s.client_name
|
|
126
|
+
`).all(since, since);
|
|
127
|
+
const violations = store.db.prepare(`
|
|
128
|
+
SELECT s.client_name as client, COUNT(i.id) as violations
|
|
129
|
+
FROM ai_sessions s
|
|
130
|
+
LEFT JOIN interventions i ON i.session_id = s.id
|
|
131
|
+
WHERE s.started_at >= ? OR i.ts >= ?
|
|
132
|
+
GROUP BY s.client_name
|
|
133
|
+
`).all(since, since);
|
|
134
|
+
const vMap = new Map(violations.map(r => [r.client, r.violations]));
|
|
135
|
+
return {
|
|
136
|
+
hours,
|
|
137
|
+
clients: rows.map(r => ({
|
|
138
|
+
client: r.client || '(unknown)',
|
|
139
|
+
decisions: r.decisions || 0,
|
|
140
|
+
violations: vMap.get(r.client) || 0,
|
|
141
|
+
})).sort((a, b) => b.decisions - a.decisions),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { ownersForFile, ownersForDomain, crossTeamCoupling, aiCostAttribution };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Predictive risk scoring.
|
|
5
|
+
*
|
|
6
|
+
* Combines temporal + structural + intervention history into a single
|
|
7
|
+
* 0–1 score per file: P(this file causes the next incident).
|
|
8
|
+
*
|
|
9
|
+
* Inputs:
|
|
10
|
+
* - blast_radius how many transitive dependents (centrality column)
|
|
11
|
+
* - commit_count temporal churn
|
|
12
|
+
* - cross_domain is the file in cross-domain edges
|
|
13
|
+
* - intervention_count past HIGH-severity interventions
|
|
14
|
+
* - test_present has a detected test file
|
|
15
|
+
*
|
|
16
|
+
* Formula (additive, normalized to 0–1):
|
|
17
|
+
* risk = 0.30 * normalized_blast
|
|
18
|
+
* + 0.25 * normalized_churn
|
|
19
|
+
* + 0.20 * cross_domain ? 1 : 0
|
|
20
|
+
* + 0.15 * normalized_interventions
|
|
21
|
+
* + 0.10 * test_present ? 0 : 1
|
|
22
|
+
*
|
|
23
|
+
* Each term capped at 1 before weighting. Final score is the weighted sum;
|
|
24
|
+
* naturally in [0, 1] because weights sum to 1.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const path = require('path');
|
|
28
|
+
|
|
29
|
+
const WEIGHTS = {
|
|
30
|
+
blast: 0.30,
|
|
31
|
+
churn: 0.25,
|
|
32
|
+
cross: 0.20,
|
|
33
|
+
intervention: 0.15,
|
|
34
|
+
noTest: 0.10,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function clampUnit(x) {
|
|
38
|
+
if (!Number.isFinite(x)) return 0;
|
|
39
|
+
return Math.max(0, Math.min(1, x));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* scoreFiles({ store, temporalStore, projectRoot, files? }) → ranked Array
|
|
44
|
+
*
|
|
45
|
+
* If `files` is provided, scores only those; otherwise scores all files.
|
|
46
|
+
* Returns [{ path, score, components }] sorted by score DESC.
|
|
47
|
+
*/
|
|
48
|
+
function scoreFiles({ store, temporalStore = null, projectRoot, files = null }) {
|
|
49
|
+
if (!store || !store.db) return [];
|
|
50
|
+
|
|
51
|
+
// Normalization bases.
|
|
52
|
+
const maxBlast = (store.db.prepare('SELECT MAX(centrality) as m FROM files').get() || {}).m || 1;
|
|
53
|
+
const maxChurn = temporalStore && temporalStore.db
|
|
54
|
+
? (temporalStore.db.prepare('SELECT MAX(commit_count) as m FROM file_churn').get() || {}).m || 1
|
|
55
|
+
: 1;
|
|
56
|
+
|
|
57
|
+
// Cross-domain edges set.
|
|
58
|
+
const crossSet = new Set();
|
|
59
|
+
try {
|
|
60
|
+
const rows = store.getCrossDomainDeps() || [];
|
|
61
|
+
for (const r of rows) crossSet.add(r.from);
|
|
62
|
+
} catch {}
|
|
63
|
+
|
|
64
|
+
// Intervention counts per file.
|
|
65
|
+
const ivCounts = new Map();
|
|
66
|
+
try {
|
|
67
|
+
const rows = store.db.prepare(`
|
|
68
|
+
SELECT file, COUNT(*) as c FROM interventions
|
|
69
|
+
WHERE file IS NOT NULL AND severity IN ('HIGH', 'critical')
|
|
70
|
+
GROUP BY file
|
|
71
|
+
`).all();
|
|
72
|
+
for (const r of rows) ivCounts.set(r.file, r.c);
|
|
73
|
+
} catch {}
|
|
74
|
+
const maxIv = Math.max(1, ...ivCounts.values());
|
|
75
|
+
|
|
76
|
+
// Tests-present set, via the files-without-tests detector.
|
|
77
|
+
const { filesWithoutTests } = require('../mcp/files-without-tests');
|
|
78
|
+
let withoutTests = new Set();
|
|
79
|
+
try {
|
|
80
|
+
const allFiles = files || store.db.prepare('SELECT path FROM files LIMIT 2000').all().map(r => r.path);
|
|
81
|
+
const r = filesWithoutTests(projectRoot, allFiles);
|
|
82
|
+
withoutTests = new Set(r.files || []);
|
|
83
|
+
} catch {}
|
|
84
|
+
|
|
85
|
+
// Score each file.
|
|
86
|
+
const rows = files
|
|
87
|
+
? store.db.prepare(
|
|
88
|
+
`SELECT path, centrality FROM files WHERE path IN (${files.map(() => '?').join(',')})`
|
|
89
|
+
).all(...files)
|
|
90
|
+
: store.db.prepare('SELECT path, centrality FROM files').all();
|
|
91
|
+
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const r of rows) {
|
|
94
|
+
const blast = clampUnit((r.centrality || 0) / Math.max(1, maxBlast));
|
|
95
|
+
let churn = 0;
|
|
96
|
+
if (temporalStore && temporalStore.db) {
|
|
97
|
+
const ch = temporalStore.getFileChurn(r.path);
|
|
98
|
+
if (ch) churn = clampUnit((ch.commit_count || 0) / Math.max(1, maxChurn));
|
|
99
|
+
}
|
|
100
|
+
const cross = crossSet.has(r.path) ? 1 : 0;
|
|
101
|
+
const iv = clampUnit((ivCounts.get(r.path) || 0) / Math.max(1, maxIv));
|
|
102
|
+
const noTest = withoutTests.has(r.path) ? 1 : 0;
|
|
103
|
+
|
|
104
|
+
const score =
|
|
105
|
+
WEIGHTS.blast * blast +
|
|
106
|
+
WEIGHTS.churn * churn +
|
|
107
|
+
WEIGHTS.cross * cross +
|
|
108
|
+
WEIGHTS.intervention * iv +
|
|
109
|
+
WEIGHTS.noTest * noTest;
|
|
110
|
+
|
|
111
|
+
out.push({
|
|
112
|
+
path: r.path,
|
|
113
|
+
score: Math.round(score * 1000) / 1000,
|
|
114
|
+
components: { blast, churn, cross, intervention: iv, no_test: noTest },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
out.sort((a, b) => b.score - a.score);
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { scoreFiles, WEIGHTS };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* validate_change — pre-write governance.
|
|
5
|
+
*
|
|
6
|
+
* `validateDiff` validates a unified diff after the edit. `validateChange`
|
|
7
|
+
* is the pre-write counterpart: given a file path plus the full proposed
|
|
8
|
+
* content, synthesize a minimal diff against current on-disk content and
|
|
9
|
+
* run the same validation pipeline. This is the API an IDE extension
|
|
10
|
+
* calls in `onWillSaveTextDocument`.
|
|
11
|
+
*
|
|
12
|
+
* Output: same shape as `validateDiff`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { validateDiff } = require('../mcp/validate');
|
|
18
|
+
|
|
19
|
+
function validateChange({ store, projectRoot, file, content }) {
|
|
20
|
+
if (!store || !file || typeof content !== 'string') {
|
|
21
|
+
return { risk: 'SAFE', files_changed: [], violations: [], suggestions: [], reason: 'invalid_args' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const full = path.resolve(projectRoot, file);
|
|
25
|
+
let prior = '';
|
|
26
|
+
try { prior = fs.readFileSync(full, 'utf-8'); } catch {}
|
|
27
|
+
// If file already at this content: SAFE no-op.
|
|
28
|
+
if (prior === content) {
|
|
29
|
+
return { risk: 'SAFE', files_changed: [], violations: [], suggestions: [], reason: 'no_change' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Synthesize a unified diff. The diff parser only needs added imports
|
|
33
|
+
// + line counts to compute risk; we don't need a fully-correct hunk header.
|
|
34
|
+
const diff = synthesizeDiff(file, prior, content);
|
|
35
|
+
return validateDiff({ store, diff });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* synthesizeDiff(relPath, before, after) — produces a unified diff that
|
|
40
|
+
* `parseDiff` accepts. Hunk headers approximate the line counts.
|
|
41
|
+
*/
|
|
42
|
+
function synthesizeDiff(relPath, before, after) {
|
|
43
|
+
const beforeLines = (before || '').split('\n');
|
|
44
|
+
const afterLines = (after || '').split('\n');
|
|
45
|
+
const lines = [];
|
|
46
|
+
lines.push(`diff --git a/${relPath} b/${relPath}`);
|
|
47
|
+
lines.push(`--- a/${relPath}`);
|
|
48
|
+
lines.push(`+++ b/${relPath}`);
|
|
49
|
+
lines.push(`@@ -1,${beforeLines.length} +1,${afterLines.length} @@`);
|
|
50
|
+
for (const l of beforeLines) lines.push(`-${l}`);
|
|
51
|
+
for (const l of afterLines) lines.push(`+${l}`);
|
|
52
|
+
return lines.join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { validateChange, synthesizeDiff };
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { SQLiteStore } = require('./sqlite-store');
|
|
6
|
-
const {
|
|
6
|
+
const { runSync } = require('./sync');
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* StoreAdapter — V1-compatible API wrapping SQLiteStore.
|
|
@@ -18,7 +18,7 @@ class StoreAdapter {
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* index(projectRoot, opts?)
|
|
21
|
-
* Opens the SQLite DB; runs
|
|
21
|
+
* Opens the SQLite DB; runs runSync if DB is missing.
|
|
22
22
|
* opts.force — re-extract all files
|
|
23
23
|
* opts.writeOutputs — if false, skip AGENTS.md + context file generation (default: false)
|
|
24
24
|
*/
|
|
@@ -28,7 +28,7 @@ class StoreAdapter {
|
|
|
28
28
|
const dbExists = fs.existsSync(dbPath);
|
|
29
29
|
|
|
30
30
|
if (!dbExists || opts.force) {
|
|
31
|
-
await
|
|
31
|
+
await runSync({
|
|
32
32
|
projectRoot,
|
|
33
33
|
output: opts.writeOutputs ? path.resolve(projectRoot, 'AGENTS.md') : null,
|
|
34
34
|
});
|