@alimaandev/ripple 0.6.0 → 0.7.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/dist/bin.js CHANGED
@@ -3,16 +3,18 @@ import { rippleConfigSchema, DEFAULT_CONFIG, formatSchemaIssues } from './chunk-
3
3
  import { CommanderError, Command } from 'commander';
4
4
  import { createJiti } from 'jiti';
5
5
  import path8 from 'path';
6
- import fs from 'fs/promises';
6
+ import fs3 from 'fs/promises';
7
7
  import fs2 from 'fs';
8
8
  import { Project, ts, SyntaxKind } from 'ts-morph';
9
9
  import chalk from 'chalk';
10
10
  import figures from 'figures';
11
11
  import treeify from 'treeify';
12
+ import { createHash } from 'crypto';
12
13
  import fg from 'fast-glob';
13
14
  import ora from 'ora';
14
15
  import picomatch from 'picomatch';
15
16
  import { execFileSync } from 'child_process';
17
+ import { createInterface } from 'readline';
16
18
 
17
19
  // src/output/writer.ts
18
20
  var TerminalWriter = class {
@@ -38,7 +40,7 @@ var ExitCode = {
38
40
  };
39
41
  async function pathExists(filePath) {
40
42
  try {
41
- await fs.access(filePath);
43
+ await fs3.access(filePath);
42
44
  return true;
43
45
  } catch {
44
46
  return false;
@@ -54,7 +56,7 @@ function pathExistsSync(filePath) {
54
56
  }
55
57
  async function readFileSafe(filePath) {
56
58
  try {
57
- return await fs.readFile(filePath, "utf8");
59
+ return await fs3.readFile(filePath, "utf8");
58
60
  } catch {
59
61
  return void 0;
60
62
  }
@@ -269,8 +271,8 @@ async function loadProjectContext(cwd, explicitConfigPath) {
269
271
  function cycleMemberPaths(graph) {
270
272
  const members = /* @__PURE__ */ new Set();
271
273
  for (const cycle of graph.cycles) {
272
- for (const path13 of cycle.members) {
273
- members.add(path13);
274
+ for (const path15 of cycle.members) {
275
+ members.add(path15);
274
276
  }
275
277
  }
276
278
  return members;
@@ -393,6 +395,7 @@ function createCategorizer(options) {
393
395
  };
394
396
  }
395
397
  async function detectEntryPoints(rootDir) {
398
+ const started = Date.now();
396
399
  const entryPoints = /* @__PURE__ */ new Set();
397
400
  const packageJsonPath = await findPackageJson(rootDir);
398
401
  if (packageJsonPath) {
@@ -411,6 +414,9 @@ async function detectEntryPoints(rootDir) {
411
414
  }
412
415
  }
413
416
  }
417
+ if (process.env.RIPPLE_TRACE === "1") {
418
+ console.error(`[trace] detectEntryPoints: ${Date.now() - started}ms`);
419
+ }
414
420
  return entryPoints;
415
421
  }
416
422
 
@@ -974,12 +980,12 @@ function renderGraphReport(input, options, writer) {
974
980
  writer.writeLine();
975
981
  if (input.forward) {
976
982
  writer.writeLine(sectionHeader("Dependants (forward)", style));
977
- writer.writeLine(renderTree(input.forward.map((path13) => ({ label: path13 }))));
983
+ writer.writeLine(renderTree(input.forward.map((path15) => ({ label: path15 }))));
978
984
  writer.writeLine();
979
985
  }
980
986
  if (input.reverse) {
981
987
  writer.writeLine(sectionHeader("Dependents (reverse)", style));
982
- writer.writeLine(renderTree(input.reverse.map((path13) => ({ label: path13 }))));
988
+ writer.writeLine(renderTree(input.reverse.map((path15) => ({ label: path15 }))));
983
989
  writer.writeLine();
984
990
  }
985
991
  writer.writeLine(sectionHeader("Circular dependencies", style));
@@ -1018,6 +1024,158 @@ function serializeJson(report) {
1018
1024
  return `${JSON.stringify(report, null, 2)}
1019
1025
  `;
1020
1026
  }
1027
+ var LEVEL_INDEX = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
1028
+ var GATE_INDEX = { medium: 1, high: 2, critical: 3 };
1029
+ var INFORMATION_URI = "https://github.com/alimaandev/ripple";
1030
+ function riskToSarifLevel(level) {
1031
+ switch (level) {
1032
+ case "CRITICAL":
1033
+ case "HIGH":
1034
+ return "error";
1035
+ case "MEDIUM":
1036
+ return "warning";
1037
+ default:
1038
+ return "note";
1039
+ }
1040
+ }
1041
+ function primaryLocationHash(ruleId, uri, score) {
1042
+ return createHash("sha256").update(`${ruleId}|${uri}|${score.toFixed(1)}`).digest("hex");
1043
+ }
1044
+ var RISK_RULE = {
1045
+ id: "ripple/risk",
1046
+ name: "riskScore",
1047
+ shortDescription: {
1048
+ text: "Risk of changing a file's blast radius"
1049
+ },
1050
+ fullDescription: {
1051
+ text: "Ripple scores how risky a change to a file is \u2014 its impact area, affected files, and circular-dependency membership \u2014 and gates merges on the resulting level."
1052
+ },
1053
+ defaultConfiguration: { level: "warning" },
1054
+ properties: { tags: ["ripple", "impact-analysis"] }
1055
+ };
1056
+ function fileResult(entry, gate) {
1057
+ const { rel, result, allowed } = entry;
1058
+ const level = allowed ? "note" : riskToSarifLevel(result.risk.level);
1059
+ const blocked = LEVEL_INDEX[result.risk.level] >= GATE_INDEX[gate];
1060
+ const score = result.risk.score.toFixed(1);
1061
+ const affected = result.summary.affectedFiles;
1062
+ const message = allowed ? `Allowlisted: ${result.risk.level} risk (${score}/100) - ${affected} affected file(s)` : `${result.risk.level} risk (${score}/100) - ${affected} affected file(s)`;
1063
+ const entryResult = {
1064
+ ruleId: "ripple/risk",
1065
+ level,
1066
+ message: { text: message },
1067
+ locations: [
1068
+ {
1069
+ physicalLocation: {
1070
+ artifactLocation: { uri: rel },
1071
+ region: { startLine: 1 }
1072
+ }
1073
+ }
1074
+ ],
1075
+ partialFingerprints: {
1076
+ primaryLocationLineHash: primaryLocationHash("ripple/risk", rel, result.risk.score)
1077
+ },
1078
+ properties: {
1079
+ ripple: {
1080
+ score: result.risk.score,
1081
+ level: result.risk.level,
1082
+ affectedFiles: result.summary.affectedFiles,
1083
+ targetInCycle: result.targetInCycle,
1084
+ allowed,
1085
+ blocked,
1086
+ gate
1087
+ }
1088
+ }
1089
+ };
1090
+ if (allowed) {
1091
+ entryResult.suppressions = [
1092
+ { kind: "inSource", justification: "Allowlisted via diff.allow in ripple config" }
1093
+ ];
1094
+ }
1095
+ return entryResult;
1096
+ }
1097
+ function buildDiffSarif(options) {
1098
+ const { baseLabel, entries, gate, counts, blocked, durationMs, version } = options;
1099
+ const results = entries.map((entry) => fileResult(entry, gate));
1100
+ return {
1101
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
1102
+ version: "2.1.0",
1103
+ runs: [
1104
+ {
1105
+ tool: {
1106
+ driver: {
1107
+ name: "ripple",
1108
+ fullName: "Ripple \u2014 dependency impact analysis",
1109
+ informationUri: INFORMATION_URI,
1110
+ version,
1111
+ rules: [RISK_RULE]
1112
+ }
1113
+ },
1114
+ results,
1115
+ properties: {
1116
+ ripple: {
1117
+ command: "diff",
1118
+ base: baseLabel,
1119
+ gate,
1120
+ blocked,
1121
+ counts,
1122
+ durationMs
1123
+ }
1124
+ }
1125
+ }
1126
+ ]
1127
+ };
1128
+ }
1129
+ function buildAnalyzeSarif(options) {
1130
+ const { result, cwd, version } = options;
1131
+ const rel = result.targetPath.slice(cwd.length).replace(/^[/\\]+/, "").replace(/\\/g, "/");
1132
+ const uri = rel || result.targetPath;
1133
+ return {
1134
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
1135
+ version: "2.1.0",
1136
+ runs: [
1137
+ {
1138
+ tool: {
1139
+ driver: {
1140
+ name: "ripple",
1141
+ fullName: "Ripple \u2014 dependency impact analysis",
1142
+ informationUri: INFORMATION_URI,
1143
+ version,
1144
+ rules: [RISK_RULE]
1145
+ }
1146
+ },
1147
+ results: [
1148
+ {
1149
+ ruleId: "ripple/risk",
1150
+ level: riskToSarifLevel(result.risk.level),
1151
+ message: {
1152
+ text: `${result.risk.level} risk (${result.risk.score.toFixed(1)}/100) - ${result.summary.affectedFiles} affected file(s)`
1153
+ },
1154
+ locations: [
1155
+ {
1156
+ physicalLocation: {
1157
+ artifactLocation: { uri },
1158
+ region: { startLine: 1 }
1159
+ }
1160
+ }
1161
+ ],
1162
+ partialFingerprints: {
1163
+ primaryLocationLineHash: primaryLocationHash("ripple/risk", uri, result.risk.score)
1164
+ },
1165
+ properties: {
1166
+ ripple: {
1167
+ score: result.risk.score,
1168
+ level: result.risk.level,
1169
+ affectedFiles: result.summary.affectedFiles,
1170
+ targetInCycle: result.targetInCycle
1171
+ }
1172
+ }
1173
+ }
1174
+ ]
1175
+ }
1176
+ ]
1177
+ };
1178
+ }
1021
1179
  function createTsProject() {
1022
1180
  return new Project({
1023
1181
  skipAddingFilesFromTsConfig: true,
@@ -1057,7 +1215,7 @@ function isSourceFile(filePath) {
1057
1215
 
1058
1216
  // src/scanner/discover.ts
1059
1217
  async function discoverSourceFiles(options) {
1060
- const ignoreGlobs = options.ignore.map(toIgnoreGlob).filter((glob) => glob !== "").concat(["**/node_modules/**"]);
1218
+ const ignoreGlobs = options.ignore.map(toIgnoreGlob).filter((glob) => glob !== "").concat(["**/node_modules/**", "**/.ripple/**"]);
1061
1219
  const files = await globFiles({
1062
1220
  cwd: options.rootDir,
1063
1221
  patterns: options.include,
@@ -1199,13 +1357,24 @@ function parseSourceFile(project, filePath) {
1199
1357
  parseError: error instanceof Error ? error.message : String(error)
1200
1358
  };
1201
1359
  }
1202
- return {
1203
- path: filePath,
1204
- kind: sourceFileKind(filePath) ?? "ts",
1205
- imports: extractImports(sourceFile),
1206
- exports: extractExports(sourceFile),
1207
- symbols: extractSymbols(sourceFile)
1208
- };
1360
+ try {
1361
+ return {
1362
+ path: filePath,
1363
+ kind: sourceFileKind(filePath) ?? "ts",
1364
+ imports: extractImports(sourceFile),
1365
+ exports: extractExports(sourceFile),
1366
+ symbols: extractSymbols(sourceFile)
1367
+ };
1368
+ } catch (error) {
1369
+ return {
1370
+ path: filePath,
1371
+ kind: sourceFileKind(filePath) ?? "ts",
1372
+ imports: [],
1373
+ exports: { named: [], hasDefault: false, reExportedFrom: [], reExportedAll: [] },
1374
+ symbols: { functions: [], classes: [], interfaces: [], enums: [], typeAliases: [] },
1375
+ parseError: error instanceof Error ? error.message : String(error)
1376
+ };
1377
+ }
1209
1378
  }
1210
1379
  function parseMany(project, filePaths) {
1211
1380
  return filePaths.map((filePath) => parseSourceFile(project, filePath));
@@ -1271,8 +1440,8 @@ function findCycles(graph) {
1271
1440
  }
1272
1441
  const members = [...component].sort();
1273
1442
  const componentSet = new Set(component);
1274
- const path13 = representativeCyclePath(graph, component, componentSet);
1275
- cycles.push({ members, path: path13 ?? [members[0], members[0]] });
1443
+ const path15 = representativeCyclePath(graph, component, componentSet);
1444
+ cycles.push({ members, path: path15 ?? [members[0], members[0]] });
1276
1445
  }
1277
1446
  cycles.sort(
1278
1447
  (a, b) => a.members.length - b.members.length || a.members[0].localeCompare(b.members[0])
@@ -1281,14 +1450,14 @@ function findCycles(graph) {
1281
1450
  }
1282
1451
  function representativeCyclePath(graph, component, componentSet) {
1283
1452
  const inPath = /* @__PURE__ */ new Set();
1284
- const path13 = [];
1453
+ const path15 = [];
1285
1454
  let current = component[0];
1286
1455
  for (let steps = 0; steps <= component.length * 2 && current !== void 0; steps++) {
1287
1456
  const firstOccurrence = inPath.has(current);
1288
1457
  if (firstOccurrence) {
1289
- const start = path13.indexOf(current);
1458
+ const start = path15.indexOf(current);
1290
1459
  if (start === -1) return void 0;
1291
- const cycle = path13.slice(start);
1460
+ const cycle = path15.slice(start);
1292
1461
  if (cycle.length > 1 || graph.forward.get(current)?.has(current)) {
1293
1462
  cycle.push(current);
1294
1463
  return cycle;
@@ -1296,7 +1465,7 @@ function representativeCyclePath(graph, component, componentSet) {
1296
1465
  return void 0;
1297
1466
  }
1298
1467
  inPath.add(current);
1299
- path13.push(current);
1468
+ path15.push(current);
1300
1469
  const next = [...graph.forward.get(current) ?? []].find(
1301
1470
  (candidate) => componentSet.has(candidate)
1302
1471
  );
@@ -1379,7 +1548,14 @@ function resolveImports(fromPath, importSpecifiers, context) {
1379
1548
 
1380
1549
  // src/graph/build.ts
1381
1550
  function buildGraph(project, filePaths, context) {
1382
- const parsedFiles = parseMany(project, filePaths);
1551
+ return buildGraphFromParsed(parseMany(project, filePaths), context);
1552
+ }
1553
+ function buildGraphFromParsed(parsedFiles, context) {
1554
+ const started = Date.now();
1555
+ const trace = process.env.RIPPLE_TRACE === "1";
1556
+ const mark = (label) => {
1557
+ if (trace) console.error(`[trace] graph.${label}: ${Date.now() - started}ms`);
1558
+ };
1383
1559
  const nodes = /* @__PURE__ */ new Map();
1384
1560
  const forward = /* @__PURE__ */ new Map();
1385
1561
  const reverse = /* @__PURE__ */ new Map();
@@ -1393,6 +1569,7 @@ function buildGraph(project, filePaths, context) {
1393
1569
  nodes.set(key, { path: parsed.path, parsed });
1394
1570
  if (!parsed.parseError) parsedCount++;
1395
1571
  }
1572
+ mark("nodes");
1396
1573
  for (const parsed of parsedFiles) {
1397
1574
  const fromKey = pathKey(parsed.path, context.rootDir);
1398
1575
  const targets = /* @__PURE__ */ new Set();
@@ -1436,26 +1613,206 @@ function buildGraph(project, filePaths, context) {
1436
1613
  cycles: 0
1437
1614
  }
1438
1615
  };
1616
+ mark("edges");
1439
1617
  graph.cycles = findCycles(graph);
1440
1618
  graph.stats.cycles = graph.cycles.length;
1619
+ mark("cycles");
1441
1620
  return graph;
1442
1621
  }
1622
+ var CACHE_SCHEMA = 2;
1623
+ var CACHE_FILE = path8.join(".ripple", "cache", "parsed-v2.json");
1624
+ var cacheEnabled = () => process.env.RIPPLE_NO_CACHE !== "1";
1625
+ async function contentHash(filePath) {
1626
+ const bytes = await fs3.readFile(filePath);
1627
+ return createHash("sha256").update(bytes).digest("hex");
1628
+ }
1629
+ function configCacheHash(config) {
1630
+ const stable = {
1631
+ include: [...config.include].sort(),
1632
+ ignore: [...config.ignore].sort()
1633
+ };
1634
+ return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
1635
+ }
1636
+ function relPosix(rootDir, absPath) {
1637
+ return toPosix(path8.relative(rootDir, absPath));
1638
+ }
1639
+ function cachePath(rootDir) {
1640
+ return path8.join(rootDir, CACHE_FILE);
1641
+ }
1642
+ async function loadParsedCache(rootDir, configHash) {
1643
+ let raw;
1644
+ try {
1645
+ raw = await fs3.readFile(cachePath(rootDir), "utf8");
1646
+ } catch {
1647
+ return /* @__PURE__ */ new Map();
1648
+ }
1649
+ try {
1650
+ const manifest = JSON.parse(raw);
1651
+ if (manifest.schema !== CACHE_SCHEMA || manifest.configHash !== configHash) {
1652
+ return /* @__PURE__ */ new Map();
1653
+ }
1654
+ return new Map(Object.entries(manifest.files ?? {}));
1655
+ } catch {
1656
+ return /* @__PURE__ */ new Map();
1657
+ }
1658
+ }
1659
+ async function saveParsedCache(rootDir, configHash, entries) {
1660
+ const manifest = {
1661
+ schema: CACHE_SCHEMA,
1662
+ configHash,
1663
+ files: Object.fromEntries(entries)
1664
+ };
1665
+ const target = cachePath(rootDir);
1666
+ const tmp = target + ".tmp";
1667
+ try {
1668
+ await fs3.mkdir(path8.dirname(target), { recursive: true });
1669
+ await fs3.writeFile(tmp, JSON.stringify(manifest), "utf8");
1670
+ await fs3.rename(tmp, target);
1671
+ } catch {
1672
+ try {
1673
+ await fs3.rm(tmp, { force: true });
1674
+ } catch {
1675
+ }
1676
+ }
1677
+ }
1678
+ async function loadParsedFiles(options) {
1679
+ const { project, rootDir, filePaths, config } = options;
1680
+ if (!cacheEnabled()) {
1681
+ return {
1682
+ parsedFiles: parseMany(project, filePaths),
1683
+ stats: { hits: 0, misses: filePaths.length }
1684
+ };
1685
+ }
1686
+ const configHash = configCacheHash(config);
1687
+ const cached = await loadParsedCache(rootDir, configHash);
1688
+ const trace = process.env.RIPPLE_TRACE === "1";
1689
+ const traceAt = (label, from) => {
1690
+ if (trace) console.error(`[trace] cache.${label}: ${Date.now() - from}ms`);
1691
+ return Date.now();
1692
+ };
1693
+ let stage = Date.now();
1694
+ const states = await Promise.all(
1695
+ filePaths.map(async (abs) => {
1696
+ try {
1697
+ const stat = await fs3.stat(abs);
1698
+ return { abs, rel: relPosix(rootDir, abs), size: stat.size, mtimeMs: stat.mtimeMs };
1699
+ } catch {
1700
+ return { abs, rel: relPosix(rootDir, abs), size: -1, mtimeMs: -1 };
1701
+ }
1702
+ })
1703
+ );
1704
+ stage = traceAt("load-cache", stage);
1705
+ const hits = /* @__PURE__ */ new Map();
1706
+ const stale = [];
1707
+ const parsed = /* @__PURE__ */ new Map();
1708
+ let dirty = false;
1709
+ for (const state of states) {
1710
+ const entry = cached.get(state.rel);
1711
+ if (entry !== void 0 && entry.size === state.size && entry.mtimeMs === state.mtimeMs && state.mtimeMs > 0) {
1712
+ hits.set(state.rel, entry);
1713
+ parsed.set(state.rel, { ...entry.parsed, path: state.abs });
1714
+ continue;
1715
+ }
1716
+ let hash = "";
1717
+ try {
1718
+ hash = await contentHash(state.abs);
1719
+ } catch {
1720
+ }
1721
+ if (entry !== void 0 && entry.hash === hash) {
1722
+ if (entry.mtimeMs !== state.mtimeMs || entry.size !== state.size) {
1723
+ hits.set(state.rel, { ...entry, size: state.size, mtimeMs: state.mtimeMs });
1724
+ dirty = true;
1725
+ } else {
1726
+ hits.set(state.rel, entry);
1727
+ }
1728
+ parsed.set(state.rel, { ...entry.parsed, path: state.abs });
1729
+ } else {
1730
+ stale.push(state.abs);
1731
+ dirty = true;
1732
+ }
1733
+ }
1734
+ if (stale.length > 0) {
1735
+ for (const filePath of stale) {
1736
+ project.getSourceFile(filePath)?.forget();
1737
+ }
1738
+ for (const parsedFile of parseMany(project, stale)) {
1739
+ parsed.set(relPosix(rootDir, parsedFile.path), parsedFile);
1740
+ }
1741
+ }
1742
+ traceAt("freshness+parse", stage);
1743
+ if (dirty) {
1744
+ const next = new Map(hits);
1745
+ for (const parsedFile of parsed.values()) {
1746
+ const rel = relPosix(rootDir, parsedFile.path);
1747
+ if (next.has(rel)) continue;
1748
+ const state = states.find((candidate) => candidate.rel === rel);
1749
+ let hash = "";
1750
+ try {
1751
+ hash = await contentHash(parsedFile.path);
1752
+ } catch {
1753
+ }
1754
+ next.set(rel, {
1755
+ hash,
1756
+ size: state?.size ?? -1,
1757
+ mtimeMs: state?.mtimeMs ?? -1,
1758
+ parsed: { ...parsedFile, path: rel }
1759
+ });
1760
+ }
1761
+ await saveParsedCache(rootDir, configHash, next);
1762
+ traceAt("save", stage);
1763
+ }
1764
+ const parsedFiles = filePaths.map((abs) => {
1765
+ const rel = relPosix(rootDir, abs);
1766
+ const parsedFile = parsed.get(rel);
1767
+ if (parsedFile === void 0) {
1768
+ return {
1769
+ path: abs,
1770
+ kind: "ts",
1771
+ imports: [],
1772
+ exports: { named: [], hasDefault: false, reExportedFrom: [], reExportedAll: [] },
1773
+ symbols: { functions: [], classes: [], interfaces: [], enums: [], typeAliases: [] },
1774
+ parseError: "file disappeared during discovery"
1775
+ };
1776
+ }
1777
+ return parsedFile;
1778
+ });
1779
+ return {
1780
+ parsedFiles,
1781
+ stats: { hits: hits.size, misses: stale.length }
1782
+ };
1783
+ }
1443
1784
 
1444
1785
  // src/commands/pipeline.ts
1445
1786
  async function runPipeline(context) {
1446
1787
  const started = Date.now();
1788
+ const trace = process.env.RIPPLE_TRACE === "1";
1789
+ const mark = (label) => {
1790
+ if (trace) console.error(`[trace] pipeline.${label}: ${Date.now() - started}ms`);
1791
+ };
1447
1792
  const project = createTsProject();
1793
+ mark("ts-project");
1448
1794
  const filePaths = await discoverSourceFiles({
1449
1795
  rootDir: context.rootDir,
1450
1796
  include: context.config.include,
1451
1797
  ignore: context.config.ignore
1452
1798
  });
1453
- const graph = buildGraph(project, filePaths, {
1799
+ mark("discover");
1800
+ const { parsedFiles, stats } = await loadParsedFiles({
1801
+ project,
1802
+ rootDir: context.rootDir,
1803
+ filePaths,
1804
+ config: context.config
1805
+ });
1806
+ if (trace) console.error(`[trace] cache: ${stats.hits} hits, ${stats.misses} misses`);
1807
+ mark("parsed");
1808
+ const graph = buildGraphFromParsed(parsedFiles, {
1454
1809
  rootDir: context.rootDir,
1455
1810
  aliases: context.aliases,
1456
1811
  fileKeys: new Set(filePaths.map((p) => pathKey(p, context.rootDir)))
1457
1812
  });
1813
+ mark("graph");
1458
1814
  const entryPoints = await detectEntryPoints(context.rootDir);
1815
+ mark("entry-points");
1459
1816
  return { graph, filePaths, entryPoints, durationMs: Date.now() - started };
1460
1817
  }
1461
1818
  function resolveTargetFile(fileArg, cwd) {
@@ -1498,6 +1855,12 @@ async function analyzeCommand(fileArg, options, ctx2) {
1498
1855
  ctx2.writer.write(serializeJson(report));
1499
1856
  return ExitCode.Success;
1500
1857
  }
1858
+ if (options.sarif) {
1859
+ ctx2.writer.write(
1860
+ serializeJson(buildAnalyzeSarif({ result, cwd: ctx2.cwd, version: ctx2.version }))
1861
+ );
1862
+ return ExitCode.Success;
1863
+ }
1501
1864
  renderAnalyzeReport(
1502
1865
  result,
1503
1866
  {
@@ -1952,7 +2315,7 @@ async function initCommand(options, ctx2) {
1952
2315
  const fileName = options.ts ? "ripple.config.ts" : "ripple.config.json";
1953
2316
  const target = path8.join(ctx2.cwd, fileName);
1954
2317
  try {
1955
- await fs.access(target);
2318
+ await fs3.access(target);
1956
2319
  if (!options.force) {
1957
2320
  const mark = style.color ? chalk.red(icon("cross")) : icon("cross");
1958
2321
  ctx2.writer.writeLine(`${mark} ${fileName} already exists. Pass --force to overwrite it.`);
@@ -1963,7 +2326,7 @@ async function initCommand(options, ctx2) {
1963
2326
  const content = options.ts ? tsPayload(Boolean(options.full)) : `${JSON.stringify(jsonPayload(Boolean(options.full)), null, 2)}
1964
2327
  `;
1965
2328
  try {
1966
- await fs.writeFile(target, content, "utf8");
2329
+ await fs3.writeFile(target, content, "utf8");
1967
2330
  const mark = style.color ? chalk.green(icon("tick")) : icon("tick");
1968
2331
  ctx2.writer.writeLine(`${mark} Wrote ${fileName}`);
1969
2332
  ctx2.writer.writeLine(
@@ -2030,8 +2393,8 @@ function changedFiles(cwd, base) {
2030
2393
  }
2031
2394
 
2032
2395
  // src/formatter/annotations.ts
2033
- var LEVEL_INDEX = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
2034
- var GATE_INDEX = { medium: 1, high: 2, critical: 3 };
2396
+ var LEVEL_INDEX2 = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
2397
+ var GATE_INDEX2 = { medium: 1, high: 2, critical: 3 };
2035
2398
  function escapeProperty(value) {
2036
2399
  return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A").replace(/,/g, "%2C");
2037
2400
  }
@@ -2044,7 +2407,7 @@ function renderAnnotation(annotation) {
2044
2407
  }
2045
2408
  function buildFileAnnotations(files, gate) {
2046
2409
  return files.map(({ file, result }) => {
2047
- const blocked = LEVEL_INDEX[result.risk.level] >= GATE_INDEX[gate];
2410
+ const blocked = LEVEL_INDEX2[result.risk.level] >= GATE_INDEX2[gate];
2048
2411
  return {
2049
2412
  level: blocked ? "error" : "warning",
2050
2413
  file,
@@ -2054,12 +2417,12 @@ function buildFileAnnotations(files, gate) {
2054
2417
  });
2055
2418
  }
2056
2419
  function buildGateAnnotation(blocked, gate, counts) {
2057
- const gateIndex = GATE_INDEX[gate];
2420
+ const gateIndex = GATE_INDEX2[gate];
2058
2421
  const parts = [];
2059
- if (LEVEL_INDEX.CRITICAL >= gateIndex && counts.critical > 0)
2422
+ if (LEVEL_INDEX2.CRITICAL >= gateIndex && counts.critical > 0)
2060
2423
  parts.push(`${counts.critical} CRITICAL`);
2061
- if (LEVEL_INDEX.HIGH >= gateIndex && counts.high > 0) parts.push(`${counts.high} HIGH`);
2062
- if (LEVEL_INDEX.MEDIUM >= gateIndex && counts.medium > 0) parts.push(`${counts.medium} MEDIUM`);
2424
+ if (LEVEL_INDEX2.HIGH >= gateIndex && counts.high > 0) parts.push(`${counts.high} HIGH`);
2425
+ if (LEVEL_INDEX2.MEDIUM >= gateIndex && counts.medium > 0) parts.push(`${counts.medium} MEDIUM`);
2063
2426
  const atGate = parts.length > 0 ? parts.join(" \xB7 ") : "none";
2064
2427
  return {
2065
2428
  level: "notice",
@@ -2069,8 +2432,8 @@ function buildGateAnnotation(blocked, gate, counts) {
2069
2432
  }
2070
2433
 
2071
2434
  // src/commands/diff.ts
2072
- var LEVEL_INDEX2 = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
2073
- var GATE_INDEX2 = { medium: 1, high: 2, critical: 3 };
2435
+ var LEVEL_INDEX3 = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
2436
+ var GATE_INDEX3 = { medium: 1, high: 2, critical: 3 };
2074
2437
  var VERDICT_NOTE = {
2075
2438
  medium: "no MEDIUM or worse",
2076
2439
  high: "no HIGH or CRITICAL",
@@ -2083,20 +2446,20 @@ function countLevels(results) {
2083
2446
  }
2084
2447
  return counts;
2085
2448
  }
2086
- function isAllowedByDiffAllowlist(relPath, patterns) {
2449
+ function isAllowedByDiffAllowlist(relPath2, patterns) {
2087
2450
  if (patterns.length === 0) return false;
2088
- const rel = toPosix(relPath);
2451
+ const rel = toPosix(relPath2);
2089
2452
  const matcher = picomatch(patterns.map(toPosix), { dot: true });
2090
2453
  return matcher(rel);
2091
2454
  }
2092
2455
  function riskySummary(counts, gate) {
2093
- const gateIndex = GATE_INDEX2[gate];
2456
+ const gateIndex = GATE_INDEX3[gate];
2094
2457
  const parts = [];
2095
- if (LEVEL_INDEX2.CRITICAL >= gateIndex && counts.critical > 0)
2458
+ if (LEVEL_INDEX3.CRITICAL >= gateIndex && counts.critical > 0)
2096
2459
  parts.push(`${counts.critical} CRITICAL`);
2097
- if (LEVEL_INDEX2.HIGH >= gateIndex && counts.high > 0) parts.push(`${counts.high} HIGH`);
2098
- if (LEVEL_INDEX2.MEDIUM >= gateIndex && counts.medium > 0) parts.push(`${counts.medium} MEDIUM`);
2099
- if (LEVEL_INDEX2.LOW >= gateIndex && counts.low > 0) parts.push(`${counts.low} LOW`);
2460
+ if (LEVEL_INDEX3.HIGH >= gateIndex && counts.high > 0) parts.push(`${counts.high} HIGH`);
2461
+ if (LEVEL_INDEX3.MEDIUM >= gateIndex && counts.medium > 0) parts.push(`${counts.medium} MEDIUM`);
2462
+ if (LEVEL_INDEX3.LOW >= gateIndex && counts.low > 0) parts.push(`${counts.low} LOW`);
2100
2463
  return parts.length > 0 ? parts.join(" \xB7 ") : "none";
2101
2464
  }
2102
2465
  async function diffCommand(options, ctx2) {
@@ -2143,7 +2506,7 @@ async function diffCommand(options, ctx2) {
2143
2506
  const gateResults = entries.filter((entry) => !entry.allowed).map((entry) => entry.result);
2144
2507
  const counts = countLevels(gateResults);
2145
2508
  const allowedCount = entries.filter((entry) => entry.allowed).length;
2146
- const blocked = gateResults.some((result) => LEVEL_INDEX2[result.risk.level] >= GATE_INDEX2[gate]);
2509
+ const blocked = gateResults.some((result) => LEVEL_INDEX3[result.risk.level] >= GATE_INDEX3[gate]);
2147
2510
  const durationMs = Date.now() - started;
2148
2511
  if (format === "json") {
2149
2512
  ctx2.writer.write(
@@ -2164,6 +2527,22 @@ async function diffCommand(options, ctx2) {
2164
2527
  );
2165
2528
  return blocked ? ExitCode.Failure : ExitCode.Success;
2166
2529
  }
2530
+ if (format === "sarif") {
2531
+ ctx2.writer.write(
2532
+ serializeJson(
2533
+ buildDiffSarif({
2534
+ baseLabel: changed.baseLabel,
2535
+ entries,
2536
+ gate,
2537
+ counts,
2538
+ blocked,
2539
+ durationMs,
2540
+ version: ctx2.version
2541
+ })
2542
+ )
2543
+ );
2544
+ return blocked ? ExitCode.Failure : ExitCode.Success;
2545
+ }
2167
2546
  if (format === "github") {
2168
2547
  const annotations = [
2169
2548
  ...buildFileAnnotations(
@@ -2232,7 +2611,7 @@ function renderDiffReport(input, writer) {
2232
2611
  writer.writeLine();
2233
2612
  writer.writeLine(sectionHeader(`Risk analysis (${entries.length} files)`, style));
2234
2613
  for (const { result, allowed } of entries) {
2235
- const risky = LEVEL_INDEX2[result.risk.level] >= GATE_INDEX2[gate];
2614
+ const risky = LEVEL_INDEX3[result.risk.level] >= GATE_INDEX3[gate];
2236
2615
  const fileIcon = risky ? icon("cross") : icon("tick");
2237
2616
  const fileIconText = style.color ? risky ? chalk.red(fileIcon) : chalk.green(fileIcon) : fileIcon;
2238
2617
  const score = result.risk.score.toFixed(1);
@@ -2280,6 +2659,440 @@ function buildDiffJsonReport(baseLabel, changedFileNames, byRel, allowedByRel, g
2280
2659
  };
2281
2660
  }
2282
2661
 
2662
+ // src/mcp/server.ts
2663
+ var PROTOCOL_VERSION = "2025-06-18";
2664
+ var ERR_PARSE = -32700;
2665
+ var ERR_INVALID_REQUEST = -32600;
2666
+ var ERR_INTERNAL = -32603;
2667
+ function isObject(value) {
2668
+ return typeof value === "object" && value !== null;
2669
+ }
2670
+ var McpServer = class {
2671
+ options;
2672
+ tools = /* @__PURE__ */ new Map();
2673
+ constructor(options) {
2674
+ this.options = options;
2675
+ for (const tool of options.tools) {
2676
+ this.tools.set(tool.name, tool);
2677
+ }
2678
+ }
2679
+ /**
2680
+ * Process one inbound message. Returns the JSON-RPC response line to write
2681
+ * back, or `null` for notifications and invalid requests.
2682
+ */
2683
+ async handleLine(line) {
2684
+ let message;
2685
+ try {
2686
+ message = JSON.parse(line);
2687
+ } catch {
2688
+ return this.response(void 0, {
2689
+ code: ERR_PARSE,
2690
+ message: "Parse error: not valid JSON"
2691
+ });
2692
+ }
2693
+ if (!isObject(message) || message.jsonrpc !== "2.0") {
2694
+ return this.response(void 0, {
2695
+ code: ERR_INVALID_REQUEST,
2696
+ message: "Invalid Request: expected a JSON-RPC 2.0 object"
2697
+ });
2698
+ }
2699
+ const hasId = "id" in message;
2700
+ if (message.method === "notifications/initialized") {
2701
+ return null;
2702
+ }
2703
+ if (!hasId) {
2704
+ return null;
2705
+ }
2706
+ const method = message.method;
2707
+ if (typeof method !== "string") {
2708
+ return this.response(message.id, {
2709
+ code: ERR_INVALID_REQUEST,
2710
+ message: "Invalid Request: method must be a string"
2711
+ });
2712
+ }
2713
+ try {
2714
+ const result = await this.dispatch(method, message.params);
2715
+ return this.response(message.id, result);
2716
+ } catch (error) {
2717
+ return this.response(message.id, {
2718
+ code: ERR_INTERNAL,
2719
+ message: error instanceof Error ? error.message : "Internal error"
2720
+ });
2721
+ }
2722
+ }
2723
+ async dispatch(method, params) {
2724
+ switch (method) {
2725
+ case "initialize":
2726
+ return {
2727
+ protocolVersion: this.protocolVersion(params),
2728
+ capabilities: { tools: { listChanged: false } },
2729
+ serverInfo: { name: this.options.name, version: this.options.version }
2730
+ };
2731
+ case "ping":
2732
+ return {};
2733
+ case "tools/list":
2734
+ return {
2735
+ tools: [...this.tools.values()].map((tool) => ({
2736
+ name: tool.name,
2737
+ description: tool.description,
2738
+ inputSchema: tool.inputSchema
2739
+ }))
2740
+ };
2741
+ case "tools/call":
2742
+ return this.callTool(params);
2743
+ case "tools/list_changed":
2744
+ throw new Error("Tool list changes are not supported by this server");
2745
+ default:
2746
+ throw new Error(`Method not found: ${method}`);
2747
+ }
2748
+ }
2749
+ protocolVersion(params) {
2750
+ if (isObject(params) && typeof params.protocolVersion === "string") {
2751
+ return params.protocolVersion;
2752
+ }
2753
+ return PROTOCOL_VERSION;
2754
+ }
2755
+ async callTool(params) {
2756
+ if (!isObject(params) || typeof params.name !== "string") {
2757
+ throw new Error("Invalid params: expected { name: string, arguments?: object }");
2758
+ }
2759
+ const tool = this.tools.get(params.name);
2760
+ if (!tool) {
2761
+ throw new Error(`Unknown tool: ${params.name}`);
2762
+ }
2763
+ const args = isObject(params.arguments) || params.arguments === void 0 ? params.arguments ?? {} : null;
2764
+ if (args === null) {
2765
+ throw new Error(`Invalid arguments for tool ${params.name}: expected an object`);
2766
+ }
2767
+ try {
2768
+ const result = await tool.handler(args);
2769
+ return {
2770
+ content: [{ type: "text", text: result.text }],
2771
+ ...result.isError ? { isError: true } : {}
2772
+ };
2773
+ } catch (error) {
2774
+ return {
2775
+ content: [
2776
+ {
2777
+ type: "text",
2778
+ text: error instanceof Error ? error.message : "Tool failed"
2779
+ }
2780
+ ],
2781
+ isError: true
2782
+ };
2783
+ }
2784
+ }
2785
+ response(id, payload) {
2786
+ if (typeof payload === "object" && payload !== null && "code" in payload && typeof payload.code === "number") {
2787
+ const errorPayload = payload;
2788
+ const error = { code: errorPayload.code, message: String(errorPayload.message) };
2789
+ return `${JSON.stringify({ jsonrpc: "2.0", id: id ?? null, error })}
2790
+ `;
2791
+ }
2792
+ return `${JSON.stringify({ jsonrpc: "2.0", id: id ?? null, result: payload })}
2793
+ `;
2794
+ }
2795
+ };
2796
+ var MAX_DEPENDENTS = 200;
2797
+ function ok(text) {
2798
+ return { text };
2799
+ }
2800
+ function fail(error) {
2801
+ return { text: error instanceof Error ? error.message : "Tool failed", isError: true };
2802
+ }
2803
+ function requireString(args, key, tool) {
2804
+ const value = args[key];
2805
+ if (typeof value !== "string" || value.trim() === "") {
2806
+ throw new RippleError(`Missing required argument "${key}" for ${tool}`, ExitCode.Failure);
2807
+ }
2808
+ return value;
2809
+ }
2810
+ function optionalPositiveInt(args, key, tool) {
2811
+ const value = args[key];
2812
+ if (value === void 0) return void 0;
2813
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
2814
+ throw new RippleError(
2815
+ `Argument "${key}" for ${tool} must be a positive integer`,
2816
+ ExitCode.Failure
2817
+ );
2818
+ }
2819
+ return value;
2820
+ }
2821
+ function resolveFile(cwd, file) {
2822
+ return path8.isAbsolute(file) ? file : path8.join(cwd, file);
2823
+ }
2824
+ function analyzeIn(project, cwd, file, maxDepth) {
2825
+ const { context, graph, entryPoints } = project;
2826
+ const targetPath = resolveFile(cwd, file);
2827
+ const key = pathKey(targetPath, context.rootDir);
2828
+ const node = graph.nodes.get(key);
2829
+ if (!node) {
2830
+ throw new RippleError(
2831
+ `File not found in the project graph: ${file}. Is it a source file covered by ripple's discovery?`,
2832
+ ExitCode.NotFound
2833
+ );
2834
+ }
2835
+ return analyzeFile({
2836
+ graph,
2837
+ context,
2838
+ entryPoints,
2839
+ targetKey: key,
2840
+ targetPath,
2841
+ durationMs: 0,
2842
+ ...maxDepth !== void 0 ? { maxDepth } : {}
2843
+ });
2844
+ }
2845
+ function relPath(targetPath, cwd) {
2846
+ const rel = targetPath.slice(cwd.length).replace(/^[/\\]+/, "");
2847
+ return (rel || targetPath).replace(/\\/g, "/");
2848
+ }
2849
+ var IMPACT_SCHEMA = {
2850
+ type: "object",
2851
+ properties: {
2852
+ file: {
2853
+ type: "string",
2854
+ description: "Project-relative or absolute path of the file to analyze."
2855
+ },
2856
+ maxDepth: {
2857
+ type: "integer",
2858
+ minimum: 1,
2859
+ description: "Cap the reverse traversal depth (default: unlimited)."
2860
+ }
2861
+ },
2862
+ required: ["file"]
2863
+ };
2864
+ var DEPENDENTS_SCHEMA = {
2865
+ type: "object",
2866
+ properties: {
2867
+ file: {
2868
+ type: "string",
2869
+ description: "Project-relative or absolute path of the file."
2870
+ },
2871
+ depth: {
2872
+ type: "integer",
2873
+ minimum: 1,
2874
+ description: "Max dependency depth to report (default: 1, direct dependents only)."
2875
+ }
2876
+ },
2877
+ required: ["file"]
2878
+ };
2879
+ var RISK_SCHEMA = {
2880
+ type: "object",
2881
+ properties: {
2882
+ file: {
2883
+ type: "string",
2884
+ description: "Project-relative or absolute path of the file."
2885
+ }
2886
+ },
2887
+ required: ["file"]
2888
+ };
2889
+ var GATE_SCHEMA = {
2890
+ type: "object",
2891
+ properties: {
2892
+ base: {
2893
+ type: "string",
2894
+ description: "Git ref to diff against (default: origin/main, main, or HEAD~1)."
2895
+ },
2896
+ gate: {
2897
+ type: "string",
2898
+ enum: ["medium", "high", "critical"],
2899
+ description: "Risk level that blocks the merge (default: high)."
2900
+ }
2901
+ }
2902
+ };
2903
+ var LEVEL_INDEX4 = { LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 };
2904
+ function createMcpTools(deps) {
2905
+ const { cwd, loadProject, getChanged } = deps;
2906
+ return [
2907
+ {
2908
+ name: "impact",
2909
+ description: "Blast-radius analysis of a file: how many files, routes, tests and components would be affected by changing it, plus its risk score. Call before editing or refactoring a file so the change set stays low-risk.",
2910
+ inputSchema: IMPACT_SCHEMA,
2911
+ handler: async (args) => {
2912
+ try {
2913
+ const file = requireString(args, "file", "impact");
2914
+ const maxDepth = optionalPositiveInt(args, "maxDepth", "impact");
2915
+ const project = await loadProject();
2916
+ const result = analyzeIn(project, cwd, file, maxDepth);
2917
+ return ok(
2918
+ JSON.stringify(
2919
+ {
2920
+ file: relPath(result.targetPath, cwd),
2921
+ risk: { score: result.risk.score, level: result.risk.level },
2922
+ summary: result.summary,
2923
+ targetInCycle: result.targetInCycle
2924
+ },
2925
+ null,
2926
+ 2
2927
+ )
2928
+ );
2929
+ } catch (error) {
2930
+ return fail(error);
2931
+ }
2932
+ }
2933
+ },
2934
+ {
2935
+ name: "dependents",
2936
+ description: "List the files that depend on a file (its importers), up to a depth. Depth 1 is direct dependents; deeper levels are transitive. Useful to know who breaks when the file changes.",
2937
+ inputSchema: DEPENDENTS_SCHEMA,
2938
+ handler: async (args) => {
2939
+ try {
2940
+ const file = requireString(args, "file", "dependents");
2941
+ const depth = optionalPositiveInt(args, "depth", "dependents") ?? 1;
2942
+ const project = await loadProject();
2943
+ const result = analyzeIn(project, cwd, file);
2944
+ const dependents = [...result.affected.values()].filter((affected) => affected.depth <= depth).sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path)).map((affected) => ({
2945
+ path: relPath(affected.path, cwd),
2946
+ depth: affected.depth,
2947
+ direct: affected.direct,
2948
+ inCycle: affected.inCycle,
2949
+ categories: affected.categories
2950
+ }));
2951
+ return ok(
2952
+ JSON.stringify(
2953
+ {
2954
+ file: relPath(result.targetPath, cwd),
2955
+ depth,
2956
+ count: dependents.length,
2957
+ truncated: dependents.length > MAX_DEPENDENTS,
2958
+ dependents: dependents.slice(0, MAX_DEPENDENTS)
2959
+ },
2960
+ null,
2961
+ 2
2962
+ )
2963
+ );
2964
+ } catch (error) {
2965
+ return fail(error);
2966
+ }
2967
+ }
2968
+ },
2969
+ {
2970
+ name: "risk",
2971
+ description: "The risk score (0-100) of changing a file and the factor breakdown behind it. Cheap way to compare the danger of different refactor targets.",
2972
+ inputSchema: RISK_SCHEMA,
2973
+ handler: async (args) => {
2974
+ try {
2975
+ const file = requireString(args, "file", "risk");
2976
+ const project = await loadProject();
2977
+ const result = analyzeIn(project, cwd, file);
2978
+ return ok(
2979
+ JSON.stringify(
2980
+ {
2981
+ file: relPath(result.targetPath, cwd),
2982
+ score: result.risk.score,
2983
+ level: result.risk.level,
2984
+ targetInCycle: result.targetInCycle,
2985
+ factors: result.risk.factors
2986
+ },
2987
+ null,
2988
+ 2
2989
+ )
2990
+ );
2991
+ } catch (error) {
2992
+ return fail(error);
2993
+ }
2994
+ }
2995
+ },
2996
+ {
2997
+ name: "gate_status",
2998
+ description: "Check the current change set against the ripple merge gate: which files changed since the base ref, their risk levels, and whether the gate passes or blocks. Call after editing files to verify the changes are safe to merge.",
2999
+ inputSchema: GATE_SCHEMA,
3000
+ handler: async (args) => {
3001
+ try {
3002
+ const base = args.base;
3003
+ const gate = args.gate;
3004
+ if (base !== void 0 && typeof base !== "string") {
3005
+ return fail(
3006
+ new RippleError('Argument "base" for gate_status must be a string', ExitCode.Failure)
3007
+ );
3008
+ }
3009
+ if (gate !== void 0 && gate !== "medium" && gate !== "high" && gate !== "critical") {
3010
+ return fail(
3011
+ new RippleError(
3012
+ 'Argument "gate" for gate_status must be medium | high | critical',
3013
+ ExitCode.Failure
3014
+ )
3015
+ );
3016
+ }
3017
+ const changed = getChanged(base);
3018
+ const project = await loadProject();
3019
+ const resolvedGate = gate ?? project.context.config.diff.gate ?? "high";
3020
+ const gateIndex = LEVEL_INDEX4[resolvedGate.toUpperCase()] ?? 3;
3021
+ const files = [];
3022
+ const counts = { low: 0, medium: 0, high: 0, critical: 0 };
3023
+ let blocked = false;
3024
+ for (const rel of changed.files) {
3025
+ try {
3026
+ const result = analyzeIn(project, cwd, rel);
3027
+ const level = result.risk.level;
3028
+ counts[level.toLowerCase()] += 1;
3029
+ if ((LEVEL_INDEX4[level] ?? 1) >= gateIndex) blocked = true;
3030
+ files.push({
3031
+ file: rel,
3032
+ analyzed: true,
3033
+ level,
3034
+ score: result.risk.score,
3035
+ affectedFiles: result.summary.affectedFiles,
3036
+ targetInCycle: result.targetInCycle
3037
+ });
3038
+ } catch {
3039
+ files.push({ file: rel, analyzed: false });
3040
+ }
3041
+ }
3042
+ return ok(
3043
+ JSON.stringify(
3044
+ {
3045
+ base: changed.baseLabel,
3046
+ changedFiles: changed.files.length,
3047
+ files,
3048
+ counts,
3049
+ gate: { level: resolvedGate, blocked, verdict: blocked ? "block" : "pass" }
3050
+ },
3051
+ null,
3052
+ 2
3053
+ )
3054
+ );
3055
+ } catch (error) {
3056
+ return fail(error);
3057
+ }
3058
+ }
3059
+ }
3060
+ ];
3061
+ }
3062
+
3063
+ // src/commands/mcp.ts
3064
+ async function mcpCommand(options, ctx2) {
3065
+ let snapshot = null;
3066
+ const loadProject = async () => {
3067
+ if (!snapshot) {
3068
+ const context = await loadProjectContext(ctx2.cwd, options.config);
3069
+ const { graph, entryPoints } = await runPipeline(context);
3070
+ snapshot = { context, graph, entryPoints };
3071
+ }
3072
+ return snapshot;
3073
+ };
3074
+ const server = new McpServer({
3075
+ name: "ripple",
3076
+ version: ctx2.version,
3077
+ tools: createMcpTools({
3078
+ cwd: ctx2.cwd,
3079
+ loadProject,
3080
+ getChanged: (base) => changedFiles(ctx2.cwd, base)
3081
+ })
3082
+ });
3083
+ const stdin = createInterface({ input: process.stdin, crlfDelay: Infinity });
3084
+ await new Promise((resolve) => {
3085
+ stdin.on("line", (line) => {
3086
+ if (!line.trim()) return;
3087
+ void server.handleLine(line).then((response) => {
3088
+ if (response) process.stdout.write(response);
3089
+ });
3090
+ });
3091
+ stdin.on("close", () => resolve());
3092
+ });
3093
+ return ExitCode.Success;
3094
+ }
3095
+
2283
3096
  // src/cli/program.ts
2284
3097
  var PROGRAM_NAME = "ripple";
2285
3098
  function helpExamples(examples) {
@@ -2297,11 +3110,12 @@ function createProgram(ctx2) {
2297
3110
  "ripple analyze src/authentication/login.ts",
2298
3111
  "ripple analyze src/index.ts --depth 3"
2299
3112
  ])
2300
- ).option("-j, --json", "emit a machine-readable JSON report").option("-v, --verbose", "include the risk factor breakdown").option("-d, --depth <number>", "cap the reverse traversal depth", parsePositiveInt).option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (file, options) => {
3113
+ ).option("-j, --json", "emit a machine-readable JSON report").option("--sarif", "emit a SARIF 2.1.0 report for code scanning").option("-v, --verbose", "include the risk factor breakdown").option("-d, --depth <number>", "cap the reverse traversal depth", parsePositiveInt).option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (file, options) => {
2301
3114
  const exitCode2 = await analyzeCommand(
2302
3115
  file,
2303
3116
  {
2304
3117
  json: Boolean(options.json),
3118
+ sarif: Boolean(options.sarif),
2305
3119
  verbose: Boolean(options.verbose),
2306
3120
  ...options.depth !== void 0 ? { depth: options.depth } : {},
2307
3121
  ...options.config !== void 0 ? { config: options.config } : {},
@@ -2345,7 +3159,11 @@ function createProgram(ctx2) {
2345
3159
  program.command("diff").description("Analyze changed files since a base ref and gate risky changes").addHelpText(
2346
3160
  "after",
2347
3161
  helpExamples(["ripple diff --base main --gate critical", "ripple diff --format github"])
2348
- ).option("-j, --json", "emit a machine-readable JSON report").option("-f, --format <format>", "output format: terminal | json | github", parseDiffFormat).option("-v, --verbose", "include extra sections").option("-b, --base <ref>", "git ref to diff against (default: origin/main, main, HEAD~1)").option(
3162
+ ).option("-j, --json", "emit a machine-readable JSON report").option(
3163
+ "-f, --format <format>",
3164
+ "output format: terminal | json | github | sarif",
3165
+ parseDiffFormat
3166
+ ).option("-v, --verbose", "include extra sections").option("-b, --base <ref>", "git ref to diff against (default: origin/main, main, HEAD~1)").option(
2349
3167
  "-g, --gate <level>",
2350
3168
  "risk level that blocks the gate (medium | high | critical)",
2351
3169
  parseGateLevel
@@ -2395,6 +3213,20 @@ function createProgram(ctx2) {
2395
3213
  throw new ExitError(exitCode2);
2396
3214
  }
2397
3215
  });
3216
+ program.command("mcp").description("Serve Ripple analysis tools over the Model Context Protocol (stdio)").addHelpText(
3217
+ "after",
3218
+ helpExamples(['ripple mcp # point an MCP client (Claude, Cursor, ...) at "ripple mcp"'])
3219
+ ).option("-c, --config <path>", "path to a ripple config file").action(async (options) => {
3220
+ const exitCode2 = await mcpCommand(
3221
+ {
3222
+ config: options.config
3223
+ },
3224
+ ctx2
3225
+ );
3226
+ if (exitCode2 !== ExitCode.Success) {
3227
+ throw new ExitError(exitCode2);
3228
+ }
3229
+ });
2398
3230
  program.command("version").description("Print the Ripple version").addHelpText("after", helpExamples(["ripple version"])).action(() => {
2399
3231
  ctx2.writer.writeLine(ctx2.version);
2400
3232
  });
@@ -2435,13 +3267,13 @@ function parseGateLevel(value) {
2435
3267
  );
2436
3268
  }
2437
3269
  function parseDiffFormat(value) {
2438
- if (value === "terminal" || value === "json" || value === "github") {
3270
+ if (value === "terminal" || value === "json" || value === "github" || value === "sarif") {
2439
3271
  return value;
2440
3272
  }
2441
3273
  throw new CommanderError(
2442
3274
  1,
2443
3275
  "commander.invalidArgument",
2444
- `expected one of terminal | json | github, got "${value}"`
3276
+ `expected one of terminal | json | github | sarif, got "${value}"`
2445
3277
  );
2446
3278
  }
2447
3279
  function parseGraphFormat(value) {
@@ -2487,7 +3319,7 @@ async function run({ argv, ctx: ctx2 }) {
2487
3319
 
2488
3320
  // package.json
2489
3321
  var package_default = {
2490
- version: "0.6.0"};
3322
+ version: "0.7.0"};
2491
3323
 
2492
3324
  // src/cli/bin.ts
2493
3325
  var ctx = {