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,554 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `carto pr-impact` — pull-request-shaped impact report.
|
|
6
|
+
*
|
|
7
|
+
* Computes the impact of a pull request's diff against the current
|
|
8
|
+
* project graph and renders either:
|
|
9
|
+
* - Markdown wrapped in a `<!-- carto-impact-report -->` HTML marker
|
|
10
|
+
* so the GitHub Action can detect-and-update sticky comments.
|
|
11
|
+
* - JSON for programmatic consumers (CI dashboards, custom workflows).
|
|
12
|
+
*
|
|
13
|
+
* Composition:
|
|
14
|
+
* 1. `git diff --unified=3 <base>...<head>` → unified diff text.
|
|
15
|
+
* 2. `validateDiff(store, sidecar, diff)` → violations, blast radius,
|
|
16
|
+
* risk roll-up. (Same engine the MCP `validate_diff` tool uses.)
|
|
17
|
+
* 3. Per-file `StoreAdapter.getBlastRadius(file)` → routes affected,
|
|
18
|
+
* domains impacted (validateDiff returns counts; the comment
|
|
19
|
+
* template wants the actual route list).
|
|
20
|
+
* 4. Render.
|
|
21
|
+
*
|
|
22
|
+
* Exit code:
|
|
23
|
+
* - 0 by default (the comment surfaces the risk; failing the build
|
|
24
|
+
* is opt-in).
|
|
25
|
+
* - Non-zero when `--fail-on HIGH|MEDIUM` is supplied AND the rolled-up
|
|
26
|
+
* risk meets or exceeds the threshold.
|
|
27
|
+
*
|
|
28
|
+
* The command is read-only: it does not write to the SQLite store and
|
|
29
|
+
* does not record episodic-memory rows — those persistence concerns
|
|
30
|
+
* belong to the MCP `validate_diff` tool, not the CI surface.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
const { execFileSync } = require('child_process');
|
|
36
|
+
const { SQLiteStore } = require('../store/sqlite-store');
|
|
37
|
+
const { StoreAdapter } = require('../store/store-adapter');
|
|
38
|
+
const { ensureBitmapFresh } = require('../bitmap/index');
|
|
39
|
+
const { validateDiff } = require('../mcp/validate');
|
|
40
|
+
const bitmapTools = require('../bitmap/tools');
|
|
41
|
+
const { filesWithoutTests } = require('../mcp/files-without-tests');
|
|
42
|
+
|
|
43
|
+
const MARKER = '<!-- carto-impact-report -->';
|
|
44
|
+
|
|
45
|
+
function parseArgs(argv) {
|
|
46
|
+
const args = {
|
|
47
|
+
base: null,
|
|
48
|
+
head: 'HEAD',
|
|
49
|
+
format: 'markdown',
|
|
50
|
+
failOn: null, // null | 'HIGH' | 'MEDIUM' | 'LOW'
|
|
51
|
+
diffFile: null, // for tests: read diff from a file instead of git
|
|
52
|
+
projectRoot: process.cwd(),
|
|
53
|
+
help: false,
|
|
54
|
+
};
|
|
55
|
+
for (let i = 0; i < argv.length; i++) {
|
|
56
|
+
const a = argv[i];
|
|
57
|
+
switch (a) {
|
|
58
|
+
case '--base': args.base = argv[++i]; break;
|
|
59
|
+
case '--head': args.head = argv[++i]; break;
|
|
60
|
+
case '--format': args.format = argv[++i]; break;
|
|
61
|
+
case '--fail-on': args.failOn = (argv[++i] || '').toUpperCase(); break;
|
|
62
|
+
case '--diff-file': args.diffFile = argv[++i]; break;
|
|
63
|
+
case '--project': args.projectRoot = path.resolve(argv[++i]); break;
|
|
64
|
+
case '--help':
|
|
65
|
+
case '-h': args.help = true; break;
|
|
66
|
+
default:
|
|
67
|
+
// Unknown flag — fail loudly so typos in CI are visible.
|
|
68
|
+
if (a.startsWith('--')) {
|
|
69
|
+
throw new Error(`unknown flag: ${a}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!['markdown', 'json'].includes(args.format)) {
|
|
74
|
+
throw new Error(`--format must be 'markdown' or 'json' (got '${args.format}')`);
|
|
75
|
+
}
|
|
76
|
+
if (args.failOn && !['HIGH', 'MEDIUM', 'LOW'].includes(args.failOn)) {
|
|
77
|
+
throw new Error(`--fail-on must be HIGH | MEDIUM | LOW (got '${args.failOn}')`);
|
|
78
|
+
}
|
|
79
|
+
return args;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function printUsage() {
|
|
83
|
+
process.stdout.write(`
|
|
84
|
+
Usage: carto pr-impact [options]
|
|
85
|
+
|
|
86
|
+
Computes the impact of a PR's diff against the current carto index and
|
|
87
|
+
renders a markdown or JSON report. Designed for the carto GitHub Action
|
|
88
|
+
but usable standalone.
|
|
89
|
+
|
|
90
|
+
Options:
|
|
91
|
+
--base <ref> Git ref the PR branched from (e.g. origin/main)
|
|
92
|
+
--head <ref> Git ref of the PR head (default: HEAD)
|
|
93
|
+
--format <fmt> markdown (default) | json
|
|
94
|
+
--fail-on <severity> Exit non-zero if risk >= severity (HIGH | MEDIUM | LOW)
|
|
95
|
+
--diff-file <path> Read diff text from a file instead of running git diff
|
|
96
|
+
(primarily for testing)
|
|
97
|
+
--project <path> Project root (default: cwd)
|
|
98
|
+
--help, -h Show this help
|
|
99
|
+
|
|
100
|
+
Exit codes:
|
|
101
|
+
0 Normal — comment rendered.
|
|
102
|
+
1 Misuse, missing index, or git failure.
|
|
103
|
+
2 --fail-on threshold tripped.
|
|
104
|
+
|
|
105
|
+
`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* runGitDiff(projectRoot, base, head) → diffText
|
|
110
|
+
*
|
|
111
|
+
* Captures the unified diff between two refs. Three-dot syntax (`base...head`)
|
|
112
|
+
* matches what GitHub uses for PR diffs — only the changes introduced on
|
|
113
|
+
* the head branch since it diverged from base, not changes that landed on
|
|
114
|
+
* base in the meantime.
|
|
115
|
+
*/
|
|
116
|
+
function runGitDiff(projectRoot, base, head) {
|
|
117
|
+
if (!base) {
|
|
118
|
+
throw new Error('--base is required (or use --diff-file to supply a diff manually)');
|
|
119
|
+
}
|
|
120
|
+
let out;
|
|
121
|
+
try {
|
|
122
|
+
out = execFileSync('git', ['diff', '--unified=3', `${base}...${head}`], {
|
|
123
|
+
cwd: projectRoot,
|
|
124
|
+
encoding: 'utf8',
|
|
125
|
+
maxBuffer: 64 * 1024 * 1024, // 64 MB — large PRs are rare but real
|
|
126
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
127
|
+
});
|
|
128
|
+
} catch (err) {
|
|
129
|
+
const stderr = (err.stderr || '').toString().trim();
|
|
130
|
+
throw new Error(
|
|
131
|
+
`git diff ${base}...${head} failed${stderr ? `: ${stderr}` : ''}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const RISK_BADGE = {
|
|
138
|
+
HIGH: '🔴 HIGH',
|
|
139
|
+
MEDIUM: '🟡 MEDIUM',
|
|
140
|
+
LOW: '🟢 LOW',
|
|
141
|
+
SAFE: '✅ SAFE',
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const RISK_RANK = { SAFE: 0, LOW: 1, MEDIUM: 2, HIGH: 3 };
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* collectImpact(projectRoot, diffText) → { result, perFile, domains, highImpact }
|
|
148
|
+
*
|
|
149
|
+
* - `result` — full validateDiff(...) output (violations + blast
|
|
150
|
+
* radius + risk).
|
|
151
|
+
* - `perFile` — Map<path, { blastRadius, routes, domains }> for files
|
|
152
|
+
* in the diff. Skipped for files not in the index
|
|
153
|
+
* (pure adds, renames where the new path didn't exist
|
|
154
|
+
* at sync time).
|
|
155
|
+
* - `domains` — sorted Array<string> of all domains touched by the
|
|
156
|
+
* changed files (used for the headline sentence).
|
|
157
|
+
* - `highImpact` — { file, dependents } for the changed file with the
|
|
158
|
+
* most direct dependents, or null if all are 0.
|
|
159
|
+
*/
|
|
160
|
+
function collectImpact(projectRoot, diffText) {
|
|
161
|
+
const cartoDir = path.join(projectRoot, '.carto');
|
|
162
|
+
const dbPath = path.join(cartoDir, 'carto.db');
|
|
163
|
+
if (!fs.existsSync(dbPath)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`No carto index at ${dbPath}. Run \`carto init\` (or \`carto sync\` ` +
|
|
166
|
+
`if the index already exists) before \`carto pr-impact\`.`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const store = new SQLiteStore(projectRoot);
|
|
171
|
+
store.open({ readonly: true });
|
|
172
|
+
let sidecar = null;
|
|
173
|
+
try {
|
|
174
|
+
sidecar = ensureBitmapFresh(cartoDir, store);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
process.stderr.write(
|
|
177
|
+
`[CARTO] bitmap unavailable, validation will use SQLite-only path: ` +
|
|
178
|
+
`${err.message || err}\n`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const result = validateDiff(store, sidecar, diffText);
|
|
183
|
+
|
|
184
|
+
// Per-file rich detail via StoreAdapter (matches what `carto impact <file>`
|
|
185
|
+
// returns — routes, domain). We open a second adapter pointed at the same
|
|
186
|
+
// DB rather than re-implementing the formatter here.
|
|
187
|
+
const adapter = new StoreAdapter();
|
|
188
|
+
// Skip the indexer — we know the DB exists. Open the store directly.
|
|
189
|
+
adapter._store = store;
|
|
190
|
+
adapter._projectRoot = projectRoot;
|
|
191
|
+
|
|
192
|
+
const perFile = new Map();
|
|
193
|
+
const domainSet = new Set();
|
|
194
|
+
let highImpact = null;
|
|
195
|
+
for (const f of result.diff) {
|
|
196
|
+
if (f.kind === 'delete') continue;
|
|
197
|
+
let br = null;
|
|
198
|
+
try {
|
|
199
|
+
br = adapter.getBlastRadius(f.path);
|
|
200
|
+
} catch {
|
|
201
|
+
// File not in index — most often a pure add. Leave perFile entry
|
|
202
|
+
// off the map; renderer skips files it doesn't have detail for.
|
|
203
|
+
}
|
|
204
|
+
if (!br) continue;
|
|
205
|
+
perFile.set(f.path, {
|
|
206
|
+
blastRadius: br.dependentFiles.length,
|
|
207
|
+
directlyAffected: br.directlyAffected.files,
|
|
208
|
+
routes: br.routesImpacted,
|
|
209
|
+
domains: br.domainsImpacted,
|
|
210
|
+
});
|
|
211
|
+
for (const d of br.domainsImpacted) domainSet.add(d);
|
|
212
|
+
if (
|
|
213
|
+
!highImpact ||
|
|
214
|
+
br.directlyAffected.files > highImpact.dependents
|
|
215
|
+
) {
|
|
216
|
+
highImpact = {
|
|
217
|
+
file: f.path,
|
|
218
|
+
dependents: br.directlyAffected.files,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
store.close();
|
|
224
|
+
|
|
225
|
+
// Files-without-tests metric.
|
|
226
|
+
//
|
|
227
|
+
// We compute it over the *union blast radius* (every changed file
|
|
228
|
+
// plus every transitive dependent) — that's the set of files at risk
|
|
229
|
+
// of regression. The detector is local-filesystem-walk only, so it
|
|
230
|
+
// works on PRs touching files that aren't in the index yet.
|
|
231
|
+
const seedPaths = result.diff
|
|
232
|
+
.filter((f) => f.kind !== 'delete')
|
|
233
|
+
.map((f) => f.path);
|
|
234
|
+
let unionFiles = seedPaths.slice();
|
|
235
|
+
if (sidecar && seedPaths.length > 0) {
|
|
236
|
+
try {
|
|
237
|
+
const sim = bitmapTools.simulateChangeImpact(sidecar, seedPaths);
|
|
238
|
+
// sim.files is [{ file, hop_distance }]. Combine with seeds; dedupe via Set.
|
|
239
|
+
const set = new Set(seedPaths);
|
|
240
|
+
for (const row of sim.files || []) set.add(row.file);
|
|
241
|
+
unionFiles = [...set];
|
|
242
|
+
} catch {
|
|
243
|
+
// Bitmap missing — fall through with seedPaths only.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const filesWithoutTestsReport = filesWithoutTests(projectRoot, unionFiles);
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
result,
|
|
250
|
+
perFile,
|
|
251
|
+
domains: [...domainSet].sort(),
|
|
252
|
+
highImpact: highImpact && highImpact.dependents > 0 ? highImpact : null,
|
|
253
|
+
filesWithoutTests: filesWithoutTestsReport,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* renderMarkdown(impact) → string
|
|
259
|
+
*
|
|
260
|
+
* Renders the PR comment body. Wraps the entire body in a
|
|
261
|
+
* `<!-- carto-impact-report -->` HTML comment marker so the GitHub
|
|
262
|
+
* Action can find-and-update its previous comment instead of posting
|
|
263
|
+
* a duplicate every commit.
|
|
264
|
+
*
|
|
265
|
+
* Sections:
|
|
266
|
+
* - Headline sentence ("This PR touches X and Y domains.")
|
|
267
|
+
* - Metric table (Risk · Blast radius · Files changed · Violations
|
|
268
|
+
* introduced · High-impact file)
|
|
269
|
+
* - Affected routes (collapsible) — only if any
|
|
270
|
+
* - Cross-domain violations (collapsible) — only if any
|
|
271
|
+
* - Suggestions (collapsible) — only if any
|
|
272
|
+
*/
|
|
273
|
+
function renderMarkdown(impact) {
|
|
274
|
+
const { result, perFile, domains, highImpact, filesWithoutTests: fwt } = impact;
|
|
275
|
+
const out = [];
|
|
276
|
+
out.push(MARKER);
|
|
277
|
+
out.push('## 🗺️ Carto Impact Report');
|
|
278
|
+
out.push('');
|
|
279
|
+
|
|
280
|
+
if (result.diff.length === 0) {
|
|
281
|
+
out.push('_No file changes detected in this diff._');
|
|
282
|
+
out.push('');
|
|
283
|
+
return out.join('\n');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Headline sentence.
|
|
287
|
+
if (domains.length === 0) {
|
|
288
|
+
out.push(`This PR touches **${result.diff.length} file(s)** in unmapped domains.`);
|
|
289
|
+
} else if (domains.length === 1) {
|
|
290
|
+
out.push(`This PR touches the **${domains[0]}** domain.`);
|
|
291
|
+
} else if (domains.length === 2) {
|
|
292
|
+
out.push(`This PR touches **${domains[0]}** and **${domains[1]}** domains.`);
|
|
293
|
+
} else {
|
|
294
|
+
const head = domains.slice(0, -1).map((d) => `**${d}**`).join(', ');
|
|
295
|
+
const tail = `**${domains[domains.length - 1]}**`;
|
|
296
|
+
out.push(`This PR touches ${head}, and ${tail} domains.`);
|
|
297
|
+
}
|
|
298
|
+
out.push('');
|
|
299
|
+
|
|
300
|
+
// Metric table.
|
|
301
|
+
const crossDomainCount = result.violations.filter(
|
|
302
|
+
(v) => v.kind === 'cross_domain'
|
|
303
|
+
).length;
|
|
304
|
+
out.push('| Metric | Value |');
|
|
305
|
+
out.push('|--------|-------|');
|
|
306
|
+
out.push(`| **Risk** | ${RISK_BADGE[result.risk] || result.risk} |`);
|
|
307
|
+
out.push(`| Blast radius (union) | ${result.blast_radius.union} files |`);
|
|
308
|
+
out.push(`| Files changed | ${result.diff.length} |`);
|
|
309
|
+
out.push(`| Cross-domain violations introduced | ${crossDomainCount} |`);
|
|
310
|
+
if (fwt && typeof fwt.count === 'number') {
|
|
311
|
+
// Only render the row when the detector actually considered files
|
|
312
|
+
// (skip when nothing testable was in the union — keeps the table
|
|
313
|
+
// honest for non-source PRs).
|
|
314
|
+
if (fwt.considered > 0) {
|
|
315
|
+
out.push(`| Files without tests in blast radius | ${fwt.count} of ${fwt.considered} |`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (highImpact) {
|
|
319
|
+
out.push(
|
|
320
|
+
`| High-impact file changed | \`${highImpact.file}\` (${highImpact.dependents} direct dependents) |`
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
out.push('');
|
|
324
|
+
|
|
325
|
+
// Routes section — flatten per-file routes, dedupe by method+path.
|
|
326
|
+
const routeRows = [];
|
|
327
|
+
const seenRoutes = new Set();
|
|
328
|
+
for (const detail of perFile.values()) {
|
|
329
|
+
for (const r of detail.routes || []) {
|
|
330
|
+
const key = `${r.method} ${r.path}`;
|
|
331
|
+
if (seenRoutes.has(key)) continue;
|
|
332
|
+
seenRoutes.add(key);
|
|
333
|
+
routeRows.push(r);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (routeRows.length > 0) {
|
|
337
|
+
out.push(`<details>`);
|
|
338
|
+
out.push(`<summary>Affected routes (${routeRows.length})</summary>`);
|
|
339
|
+
out.push('');
|
|
340
|
+
for (const r of routeRows) {
|
|
341
|
+
out.push(`- \`${r.method} ${r.path}\` — risk: ${r.risk}`);
|
|
342
|
+
}
|
|
343
|
+
out.push('');
|
|
344
|
+
out.push(`</details>`);
|
|
345
|
+
out.push('');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Cross-domain violations.
|
|
349
|
+
const crossViolations = result.violations.filter(
|
|
350
|
+
(v) => v.kind === 'cross_domain'
|
|
351
|
+
);
|
|
352
|
+
if (crossViolations.length > 0) {
|
|
353
|
+
out.push(`<details>`);
|
|
354
|
+
out.push(
|
|
355
|
+
`<summary>Cross-domain violations (${crossViolations.length})</summary>`
|
|
356
|
+
);
|
|
357
|
+
out.push('');
|
|
358
|
+
for (const v of crossViolations) {
|
|
359
|
+
out.push(
|
|
360
|
+
`- \`${v.file}\` now imports from \`${v.toFile}\` (${v.fromDomain}→${v.toDomain})`
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
out.push('');
|
|
364
|
+
out.push(`</details>`);
|
|
365
|
+
out.push('');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// High-blast violations as a separate section so reviewers see them
|
|
369
|
+
// even when no cross-domain edges exist.
|
|
370
|
+
const blastViolations = result.violations.filter(
|
|
371
|
+
(v) => v.kind === 'high_blast'
|
|
372
|
+
);
|
|
373
|
+
if (blastViolations.length > 0) {
|
|
374
|
+
out.push(`<details>`);
|
|
375
|
+
out.push(
|
|
376
|
+
`<summary>High-blast files modified (${blastViolations.length})</summary>`
|
|
377
|
+
);
|
|
378
|
+
out.push('');
|
|
379
|
+
for (const v of blastViolations) {
|
|
380
|
+
out.push(`- \`${v.file}\` — ${v.blast_radius} dependents (${v.severity})`);
|
|
381
|
+
}
|
|
382
|
+
out.push('');
|
|
383
|
+
out.push(`</details>`);
|
|
384
|
+
out.push('');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Suggestions.
|
|
388
|
+
if (result.suggestions.length > 0) {
|
|
389
|
+
out.push(`<details>`);
|
|
390
|
+
out.push(`<summary>Suggestions (${result.suggestions.length})</summary>`);
|
|
391
|
+
out.push('');
|
|
392
|
+
for (const s of result.suggestions) {
|
|
393
|
+
out.push(`- ${s.message}`);
|
|
394
|
+
}
|
|
395
|
+
out.push('');
|
|
396
|
+
out.push(`</details>`);
|
|
397
|
+
out.push('');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Files without tests in blast radius — collapsible list.
|
|
401
|
+
// Show up to 10 names; the metric row already shows the total.
|
|
402
|
+
if (fwt && fwt.count > 0) {
|
|
403
|
+
out.push(`<details>`);
|
|
404
|
+
out.push(`<summary>Files without tests in blast radius (${fwt.count})</summary>`);
|
|
405
|
+
out.push('');
|
|
406
|
+
const shown = fwt.files.slice(0, 10);
|
|
407
|
+
for (const file of shown) {
|
|
408
|
+
out.push(`- \`${file}\``);
|
|
409
|
+
}
|
|
410
|
+
if (fwt.files.length > shown.length) {
|
|
411
|
+
out.push(`- _… and ${fwt.files.length - shown.length} more_`);
|
|
412
|
+
}
|
|
413
|
+
out.push('');
|
|
414
|
+
out.push(`</details>`);
|
|
415
|
+
out.push('');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
out.push(`<sub>Generated by [carto-md](https://www.npmjs.com/package/carto-md).</sub>`);
|
|
419
|
+
return out.join('\n');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* renderJson(impact) → object
|
|
424
|
+
*
|
|
425
|
+
* Stable contract for programmatic consumers. Locked-in shape (any
|
|
426
|
+
* additions go at the end of the object, no key removals before a
|
|
427
|
+
* major version bump):
|
|
428
|
+
*
|
|
429
|
+
* {
|
|
430
|
+
* marker: string,
|
|
431
|
+
* risk: 'SAFE' | 'LOW' | 'MEDIUM' | 'HIGH',
|
|
432
|
+
* files_changed: number,
|
|
433
|
+
* blast_radius_union: number,
|
|
434
|
+
* domains_touched: string[],
|
|
435
|
+
* high_impact_file: { file, dependents } | null,
|
|
436
|
+
* violations: [{ kind, severity, file, message, ... }],
|
|
437
|
+
* suggestions: [{ kind, file, message, ... }],
|
|
438
|
+
* per_file: {
|
|
439
|
+
* [path]: {
|
|
440
|
+
* blast_radius: number,
|
|
441
|
+
* directly_affected: number,
|
|
442
|
+
* domains: string[],
|
|
443
|
+
* routes: [{ method, path, risk }],
|
|
444
|
+
* }
|
|
445
|
+
* }
|
|
446
|
+
* }
|
|
447
|
+
*/
|
|
448
|
+
function renderJson(impact) {
|
|
449
|
+
const { result, perFile, domains, highImpact, filesWithoutTests: fwt } = impact;
|
|
450
|
+
const per_file = {};
|
|
451
|
+
for (const [path_, detail] of perFile) {
|
|
452
|
+
per_file[path_] = {
|
|
453
|
+
blast_radius: detail.blastRadius,
|
|
454
|
+
directly_affected: detail.directlyAffected,
|
|
455
|
+
domains: detail.domains,
|
|
456
|
+
routes: detail.routes,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
marker: MARKER,
|
|
461
|
+
risk: result.risk,
|
|
462
|
+
files_changed: result.diff.length,
|
|
463
|
+
blast_radius_union: result.blast_radius.union,
|
|
464
|
+
domains_touched: domains,
|
|
465
|
+
high_impact_file: highImpact,
|
|
466
|
+
violations: result.violations,
|
|
467
|
+
suggestions: result.suggestions,
|
|
468
|
+
per_file,
|
|
469
|
+
// Files-without-tests metric. Always present so the JSON shape is
|
|
470
|
+
// stable; values are null/0 when not computable.
|
|
471
|
+
files_without_tests: fwt
|
|
472
|
+
? { count: fwt.count, considered: fwt.considered, files: fwt.files }
|
|
473
|
+
: { count: 0, considered: 0, files: [] },
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Decide exit code based on --fail-on threshold.
|
|
479
|
+
* - failOn null → always 0
|
|
480
|
+
* - failOn set → 2 if risk >= threshold, else 0
|
|
481
|
+
*/
|
|
482
|
+
function decideExitCode(risk, failOn) {
|
|
483
|
+
if (!failOn) return 0;
|
|
484
|
+
return RISK_RANK[risk] >= RISK_RANK[failOn] ? 2 : 0;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* run({ argv, stdout, stderr }) → exitCode
|
|
489
|
+
*
|
|
490
|
+
* Pure function — no side effects on `process`. Tests pass an in-memory
|
|
491
|
+
* argv + capture stdout via a writable stream.
|
|
492
|
+
*/
|
|
493
|
+
function run({ argv, stdout, stderr } = {}) {
|
|
494
|
+
argv = argv || process.argv.slice(3);
|
|
495
|
+
stdout = stdout || process.stdout;
|
|
496
|
+
stderr = stderr || process.stderr;
|
|
497
|
+
|
|
498
|
+
let args;
|
|
499
|
+
try {
|
|
500
|
+
args = parseArgs(argv);
|
|
501
|
+
} catch (err) {
|
|
502
|
+
stderr.write(`[CARTO] ${err.message}\n`);
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (args.help) {
|
|
507
|
+
printUsage();
|
|
508
|
+
return 0;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
let diffText;
|
|
512
|
+
try {
|
|
513
|
+
if (args.diffFile) {
|
|
514
|
+
diffText = fs.readFileSync(args.diffFile, 'utf8');
|
|
515
|
+
} else {
|
|
516
|
+
diffText = runGitDiff(args.projectRoot, args.base, args.head);
|
|
517
|
+
}
|
|
518
|
+
} catch (err) {
|
|
519
|
+
stderr.write(`[CARTO] ${err.message}\n`);
|
|
520
|
+
return 1;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
let impact;
|
|
524
|
+
try {
|
|
525
|
+
impact = collectImpact(args.projectRoot, diffText);
|
|
526
|
+
} catch (err) {
|
|
527
|
+
stderr.write(`[CARTO] ${err.message}\n`);
|
|
528
|
+
return 1;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (args.format === 'json') {
|
|
532
|
+
stdout.write(JSON.stringify(renderJson(impact), null, 2) + '\n');
|
|
533
|
+
} else {
|
|
534
|
+
stdout.write(renderMarkdown(impact) + '\n');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return decideExitCode(impact.result.risk, args.failOn);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
module.exports = {
|
|
541
|
+
run,
|
|
542
|
+
// Exported for tests:
|
|
543
|
+
parseArgs,
|
|
544
|
+
collectImpact,
|
|
545
|
+
renderMarkdown,
|
|
546
|
+
renderJson,
|
|
547
|
+
decideExitCode,
|
|
548
|
+
MARKER,
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// CLI entry — when invoked directly (not required for tests):
|
|
552
|
+
if (require.main === module) {
|
|
553
|
+
process.exit(run());
|
|
554
|
+
}
|