@alimaandev/ripple 0.5.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
@@ -1,18 +1,20 @@
1
1
  #!/usr/bin/env node
2
- import { DEFAULT_CONFIG, rippleConfigSchema, formatSchemaIssues } from './chunk-LDCFNORX.js';
2
+ import { rippleConfigSchema, DEFAULT_CONFIG, formatSchemaIssues } from './chunk-CFH4UW2E.js';
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 boxen from 'boxen';
12
+ import { createHash } from 'crypto';
13
13
  import fg from 'fast-glob';
14
14
  import ora from 'ora';
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
 
@@ -507,7 +513,7 @@ function reverseTraverse(graph, targetKey, options) {
507
513
  const { maxDepth, categorize } = options;
508
514
  const affected = /* @__PURE__ */ new Map();
509
515
  const visited = /* @__PURE__ */ new Set([targetKey]);
510
- const cycleMembers = cycleMemberPaths(graph);
516
+ const cycleMembers2 = cycleMemberPaths(graph);
511
517
  if (!graph.reverse.has(targetKey)) {
512
518
  return { affected, maxDepth: 0 };
513
519
  }
@@ -529,7 +535,7 @@ function reverseTraverse(graph, targetKey, options) {
529
535
  depth: current.depth,
530
536
  direct: current.depth === 1,
531
537
  categories: categorize(filePath),
532
- inCycle: cycleMembers.has(current.key)
538
+ inCycle: cycleMembers2.has(current.key)
533
539
  });
534
540
  if (maxDepth === void 0 || current.depth < maxDepth) {
535
541
  for (const importer of graph.reverse.get(current.key) ?? []) {
@@ -786,24 +792,17 @@ function renderTree(nodes) {
786
792
  }
787
793
  return treeify.asTree(root, false, false).trimEnd();
788
794
  }
789
- var DIVIDER_WIDTH = 38;
790
795
  function brandHeader(options) {
791
796
  const { label, version, style } = options;
792
797
  if (!style.color) {
793
798
  return `ripple \u2014 ${label}${version ? ` \xB7 v${version}` : ""}`;
794
799
  }
795
- const tagline = `${icon("pointer")} ${label}${version ? ` \xB7 v${version}` : ""}`;
796
- return boxen(` ${tagline}`, {
797
- padding: { left: 1, right: 1 },
798
- title: "ripple",
799
- borderStyle: "round",
800
- borderColor: "cyan"
801
- });
800
+ const title = chalk.bold.cyan(`ripple \xB7 ${label}`);
801
+ return `${title}${version ? ` ${chalk.dim(`\xB7 v${version}`)}` : ""}`;
802
802
  }
803
803
  function sectionHeader(title, style) {
804
804
  if (!style.color) return title;
805
- return `${chalk.dim("\u2500".repeat(DIVIDER_WIDTH))}
806
- ${chalk.bold(title)}`;
805
+ return `${chalk.dim("\u203A")} ${chalk.bold(title)}`;
807
806
  }
808
807
 
809
808
  // src/ui/gauge.ts
@@ -981,12 +980,12 @@ function renderGraphReport(input, options, writer) {
981
980
  writer.writeLine();
982
981
  if (input.forward) {
983
982
  writer.writeLine(sectionHeader("Dependants (forward)", style));
984
- writer.writeLine(renderTree(input.forward.map((path13) => ({ label: path13 }))));
983
+ writer.writeLine(renderTree(input.forward.map((path15) => ({ label: path15 }))));
985
984
  writer.writeLine();
986
985
  }
987
986
  if (input.reverse) {
988
987
  writer.writeLine(sectionHeader("Dependents (reverse)", style));
989
- writer.writeLine(renderTree(input.reverse.map((path13) => ({ label: path13 }))));
988
+ writer.writeLine(renderTree(input.reverse.map((path15) => ({ label: path15 }))));
990
989
  writer.writeLine();
991
990
  }
992
991
  writer.writeLine(sectionHeader("Circular dependencies", style));
@@ -1025,6 +1024,158 @@ function serializeJson(report) {
1025
1024
  return `${JSON.stringify(report, null, 2)}
1026
1025
  `;
1027
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
+ }
1028
1179
  function createTsProject() {
1029
1180
  return new Project({
1030
1181
  skipAddingFilesFromTsConfig: true,
@@ -1064,7 +1215,7 @@ function isSourceFile(filePath) {
1064
1215
 
1065
1216
  // src/scanner/discover.ts
1066
1217
  async function discoverSourceFiles(options) {
1067
- 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/**"]);
1068
1219
  const files = await globFiles({
1069
1220
  cwd: options.rootDir,
1070
1221
  patterns: options.include,
@@ -1206,13 +1357,24 @@ function parseSourceFile(project, filePath) {
1206
1357
  parseError: error instanceof Error ? error.message : String(error)
1207
1358
  };
1208
1359
  }
1209
- return {
1210
- path: filePath,
1211
- kind: sourceFileKind(filePath) ?? "ts",
1212
- imports: extractImports(sourceFile),
1213
- exports: extractExports(sourceFile),
1214
- symbols: extractSymbols(sourceFile)
1215
- };
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
+ }
1216
1378
  }
1217
1379
  function parseMany(project, filePaths) {
1218
1380
  return filePaths.map((filePath) => parseSourceFile(project, filePath));
@@ -1278,8 +1440,8 @@ function findCycles(graph) {
1278
1440
  }
1279
1441
  const members = [...component].sort();
1280
1442
  const componentSet = new Set(component);
1281
- const path13 = representativeCyclePath(graph, component, componentSet);
1282
- 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]] });
1283
1445
  }
1284
1446
  cycles.sort(
1285
1447
  (a, b) => a.members.length - b.members.length || a.members[0].localeCompare(b.members[0])
@@ -1288,14 +1450,14 @@ function findCycles(graph) {
1288
1450
  }
1289
1451
  function representativeCyclePath(graph, component, componentSet) {
1290
1452
  const inPath = /* @__PURE__ */ new Set();
1291
- const path13 = [];
1453
+ const path15 = [];
1292
1454
  let current = component[0];
1293
1455
  for (let steps = 0; steps <= component.length * 2 && current !== void 0; steps++) {
1294
1456
  const firstOccurrence = inPath.has(current);
1295
1457
  if (firstOccurrence) {
1296
- const start = path13.indexOf(current);
1458
+ const start = path15.indexOf(current);
1297
1459
  if (start === -1) return void 0;
1298
- const cycle = path13.slice(start);
1460
+ const cycle = path15.slice(start);
1299
1461
  if (cycle.length > 1 || graph.forward.get(current)?.has(current)) {
1300
1462
  cycle.push(current);
1301
1463
  return cycle;
@@ -1303,7 +1465,7 @@ function representativeCyclePath(graph, component, componentSet) {
1303
1465
  return void 0;
1304
1466
  }
1305
1467
  inPath.add(current);
1306
- path13.push(current);
1468
+ path15.push(current);
1307
1469
  const next = [...graph.forward.get(current) ?? []].find(
1308
1470
  (candidate) => componentSet.has(candidate)
1309
1471
  );
@@ -1386,13 +1548,20 @@ function resolveImports(fromPath, importSpecifiers, context) {
1386
1548
 
1387
1549
  // src/graph/build.ts
1388
1550
  function buildGraph(project, filePaths, context) {
1389
- 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
+ };
1390
1559
  const nodes = /* @__PURE__ */ new Map();
1391
1560
  const forward = /* @__PURE__ */ new Map();
1392
1561
  const reverse = /* @__PURE__ */ new Map();
1393
1562
  const external = /* @__PURE__ */ new Map();
1394
1563
  let parsedCount = 0;
1395
- let internalEdges = 0;
1564
+ let internalEdges2 = 0;
1396
1565
  let unresolvedEdges = 0;
1397
1566
  let externalEdges = 0;
1398
1567
  for (const parsed of parsedFiles) {
@@ -1400,6 +1569,7 @@ function buildGraph(project, filePaths, context) {
1400
1569
  nodes.set(key, { path: parsed.path, parsed });
1401
1570
  if (!parsed.parseError) parsedCount++;
1402
1571
  }
1572
+ mark("nodes");
1403
1573
  for (const parsed of parsedFiles) {
1404
1574
  const fromKey = pathKey(parsed.path, context.rootDir);
1405
1575
  const targets = /* @__PURE__ */ new Set();
@@ -1412,7 +1582,7 @@ function buildGraph(project, filePaths, context) {
1412
1582
  parsed.imports.map((decl) => decl.raw),
1413
1583
  context
1414
1584
  );
1415
- internalEdges += internal.length;
1585
+ internalEdges2 += internal.length;
1416
1586
  unresolvedEdges += unresolved.length;
1417
1587
  externalEdges += externalSpecifiers.length;
1418
1588
  for (const target of internal) {
@@ -1437,32 +1607,212 @@ function buildGraph(project, filePaths, context) {
1437
1607
  stats: {
1438
1608
  files: parsedFiles.length,
1439
1609
  parsed: parsedCount,
1440
- internalEdges,
1610
+ internalEdges: internalEdges2,
1441
1611
  unresolvedEdges,
1442
1612
  externalEdges,
1443
1613
  cycles: 0
1444
1614
  }
1445
1615
  };
1616
+ mark("edges");
1446
1617
  graph.cycles = findCycles(graph);
1447
1618
  graph.stats.cycles = graph.cycles.length;
1619
+ mark("cycles");
1448
1620
  return graph;
1449
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
+ }
1450
1784
 
1451
1785
  // src/commands/pipeline.ts
1452
1786
  async function runPipeline(context) {
1453
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
+ };
1454
1792
  const project = createTsProject();
1793
+ mark("ts-project");
1455
1794
  const filePaths = await discoverSourceFiles({
1456
1795
  rootDir: context.rootDir,
1457
1796
  include: context.config.include,
1458
1797
  ignore: context.config.ignore
1459
1798
  });
1460
- 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, {
1461
1809
  rootDir: context.rootDir,
1462
1810
  aliases: context.aliases,
1463
1811
  fileKeys: new Set(filePaths.map((p) => pathKey(p, context.rootDir)))
1464
1812
  });
1813
+ mark("graph");
1465
1814
  const entryPoints = await detectEntryPoints(context.rootDir);
1815
+ mark("entry-points");
1466
1816
  return { graph, filePaths, entryPoints, durationMs: Date.now() - started };
1467
1817
  }
1468
1818
  function resolveTargetFile(fileArg, cwd) {
@@ -1505,6 +1855,12 @@ async function analyzeCommand(fileArg, options, ctx2) {
1505
1855
  ctx2.writer.write(serializeJson(report));
1506
1856
  return ExitCode.Success;
1507
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
+ }
1508
1864
  renderAnalyzeReport(
1509
1865
  result,
1510
1866
  {
@@ -1518,6 +1874,150 @@ async function analyzeCommand(fileArg, options, ctx2) {
1518
1874
  return ExitCode.Success;
1519
1875
  }
1520
1876
 
1877
+ // src/formatter/graph-export.ts
1878
+ var escapeDot = (value) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1879
+ var escapeMermaid = (value) => value.replace(/#/g, "#35;").replace(/"/g, "#quot;");
1880
+ var escapeHtml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1881
+ function sortedKeys(input) {
1882
+ return [...input.nodes.keys()].sort((a, b) => {
1883
+ const left = input.nodes.get(a) ?? a;
1884
+ const right = input.nodes.get(b) ?? b;
1885
+ return left.localeCompare(right);
1886
+ });
1887
+ }
1888
+ var nodeLabel = (input, key) => displayPath(input.nodes.get(key) ?? key, input.cwd);
1889
+ function cycleMembers(input) {
1890
+ const present = /* @__PURE__ */ new Set();
1891
+ for (const cycle of input.cycles) {
1892
+ for (const member of cycle.members) {
1893
+ if (input.nodes.has(member)) present.add(member);
1894
+ }
1895
+ }
1896
+ return [...present].sort((a, b) => nodeLabel(input, a).localeCompare(nodeLabel(input, b)));
1897
+ }
1898
+ function internalEdges(input) {
1899
+ const pairs = [];
1900
+ for (const [source, targets] of input.edges) {
1901
+ if (!input.nodes.has(source)) continue;
1902
+ for (const target of targets) {
1903
+ if (!input.nodes.has(target)) continue;
1904
+ pairs.push([source, target]);
1905
+ }
1906
+ }
1907
+ return pairs.sort((a, b) => {
1908
+ const left = nodeLabel(input, a[0]).localeCompare(nodeLabel(input, b[0]));
1909
+ return left !== 0 ? left : nodeLabel(input, a[1]).localeCompare(nodeLabel(input, b[1]));
1910
+ });
1911
+ }
1912
+ function renderMermaidGraph(input) {
1913
+ const keys = sortedKeys(input);
1914
+ const idByKey = new Map(keys.map((key, index) => [key, `n${index}`]));
1915
+ const lines = ["flowchart LR"];
1916
+ lines.push(" classDef cycle fill:#4a2525,stroke:#f85149,color:#f0b3b3;");
1917
+ for (const key of keys) {
1918
+ lines.push(` ${idByKey.get(key)}["${escapeMermaid(nodeLabel(input, key))}"]`);
1919
+ }
1920
+ for (const [source, target] of internalEdges(input)) {
1921
+ lines.push(` ${idByKey.get(source)} --> ${idByKey.get(target)}`);
1922
+ }
1923
+ const members = cycleMembers(input).map((key) => idByKey.get(key)).filter((id) => id !== void 0);
1924
+ if (members.length > 0) {
1925
+ lines.push(` class ${members.join(",")} cycle;`);
1926
+ }
1927
+ return `${lines.join("\n")}
1928
+ `;
1929
+ }
1930
+ function renderDotGraph(input) {
1931
+ const lines = [
1932
+ "digraph ripple {",
1933
+ " rankdir=LR;",
1934
+ ' node [shape=box, style="rounded,filled", fillcolor="#161b22", color="#30363d", fontcolor="#e6edf3"];',
1935
+ ' edge [color="#8b949e"];'
1936
+ ];
1937
+ for (const key of sortedKeys(input)) {
1938
+ const label = nodeLabel(input, key);
1939
+ lines.push(` "${escapeDot(label)}" [label="${escapeDot(label)}"];`);
1940
+ }
1941
+ for (const [source, target] of internalEdges(input)) {
1942
+ lines.push(
1943
+ ` "${escapeDot(nodeLabel(input, source))}" -> "${escapeDot(nodeLabel(input, target))}";`
1944
+ );
1945
+ }
1946
+ for (const key of cycleMembers(input)) {
1947
+ const label = nodeLabel(input, key);
1948
+ lines.push(` "${escapeDot(label)}" [color="#f85149", penwidth=1.5];`);
1949
+ }
1950
+ lines.push("}");
1951
+ return `${lines.join("\n")}
1952
+ `;
1953
+ }
1954
+ function renderHtmlGraph(input) {
1955
+ const keys = sortedKeys(input);
1956
+ const edges = internalEdges(input);
1957
+ const members = cycleMembers(input);
1958
+ const cycleLines = input.cycles.map(
1959
+ (cycle) => cycle.path.filter((key) => input.nodes.has(key)).map((key) => escapeHtml(nodeLabel(input, key))).join(" &#8594; ")
1960
+ ).filter((line) => line.length > 0);
1961
+ const edgeRows = edges.map(
1962
+ ([source, target]) => ` <tr><td>${escapeHtml(nodeLabel(input, source))}</td><td>&#8594;</td><td>${escapeHtml(nodeLabel(input, target))}</td></tr>`
1963
+ ).join("\n");
1964
+ const cycleRows = cycleLines.length > 0 ? cycleLines.map((line) => ` <li><code>${line}</code></li>`).join("\n") : " <li><em>none</em></li>";
1965
+ return `<!DOCTYPE html>
1966
+ <html lang="en">
1967
+ <head>
1968
+ <meta charset="utf-8">
1969
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1970
+ <title>Ripple \u2014 dependency graph</title>
1971
+ <style>
1972
+ :root { color-scheme: dark; }
1973
+ body { margin: 0; padding: 2rem; background: #0d1117; color: #e6edf3;
1974
+ font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
1975
+ h1 { font-size: 1.4rem; margin: 0 0 .25rem; }
1976
+ h2 { font-size: 1.05rem; margin: 2rem 0 .5rem; color: #79c0ff; }
1977
+ code, pre { font-family: SFMono-Regular, Consolas, monospace; }
1978
+ pre { background: #161b22; border: 1px solid #30363d; border-radius: 8px;
1979
+ padding: 1rem; overflow-x: auto; font-size: .85rem; }
1980
+ table { border-collapse: collapse; width: 100%; font-size: .9rem; }
1981
+ th, td { text-align: left; padding: .35rem .6rem; border-bottom: 1px solid #21262d; }
1982
+ th { color: #8b949e; font-weight: 600; }
1983
+ .stats { display: flex; gap: 1.5rem; margin-top: 1rem; }
1984
+ .stat b { display: block; font-size: 1.6rem; color: #79c0ff; }
1985
+ .stat span { color: #8b949e; font-size: .8rem; }
1986
+ .muted { color: #8b949e; }
1987
+ a { color: #79c0ff; }
1988
+ </style>
1989
+ </head>
1990
+ <body>
1991
+ <h1>Ripple dependency graph</h1>
1992
+ <p class="muted">Exported with <code>ripple graph --format html</code> \u2014 deterministic and offline.</p>
1993
+ <div class="stats">
1994
+ <div class="stat"><b>${keys.length}</b><span>files</span></div>
1995
+ <div class="stat"><b>${edges.length}</b><span>edges</span></div>
1996
+ <div class="stat"><b>${input.cycles.length}</b><span>cycle groups</span></div>
1997
+ </div>
1998
+
1999
+ <h2>Circular dependencies</h2>
2000
+ <ul>${cycleRows}</ul>
2001
+ <p class="muted">Cycle members: ${members.map(escapeHtml).join(", ")}</p>
2002
+
2003
+ <h2>Graph</h2>
2004
+ <pre><code class="language-mermaid">${escapeHtml(renderMermaidGraph(input)).trimEnd()}</code></pre>
2005
+ <p class="muted">Render this diagram with the
2006
+ <a href="https://mermaid.live">Mermaid live editor</a> or a tool that
2007
+ understands Mermaid (GitHub markdown, docs sites, IDEs).</p>
2008
+
2009
+ <h2>Edges (${edges.length})</h2>
2010
+ <table>
2011
+ <thead><tr><th>Source</th><th></th><th>Target</th></tr></thead>
2012
+ <tbody>
2013
+ ${edgeRows}
2014
+ </tbody>
2015
+ </table>
2016
+ </body>
2017
+ </html>
2018
+ `;
2019
+ }
2020
+
1521
2021
  // src/commands/graph.ts
1522
2022
  function walk(graph, startKey, edges, maxDepth, cwd) {
1523
2023
  const seen = /* @__PURE__ */ new Set([startKey]);
@@ -1539,8 +2039,36 @@ function walk(graph, startKey, edges, maxDepth, cwd) {
1539
2039
  }
1540
2040
  return entries.sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path)).map((entry) => entry.path);
1541
2041
  }
2042
+ function reachableSubgraph(graph, startKey, reverse, maxDepth, cwd) {
2043
+ const edges = reverse ? graph.reverse : graph.forward;
2044
+ const nodes = /* @__PURE__ */ new Map();
2045
+ const visited = /* @__PURE__ */ new Set([startKey]);
2046
+ const queue = [{ key: startKey, depth: 0 }];
2047
+ const collectedEdges = /* @__PURE__ */ new Map();
2048
+ while (queue.length > 0) {
2049
+ const current = queue.shift();
2050
+ if (!current) continue;
2051
+ nodes.set(current.key, graph.nodes.get(current.key)?.path ?? current.key);
2052
+ if (maxDepth !== void 0 && current.depth >= maxDepth) continue;
2053
+ const targets = /* @__PURE__ */ new Set();
2054
+ for (const candidate of edges.get(current.key) ?? []) {
2055
+ targets.add(candidate);
2056
+ if (!visited.has(candidate)) {
2057
+ visited.add(candidate);
2058
+ queue.push({ key: candidate, depth: current.depth + 1 });
2059
+ }
2060
+ }
2061
+ if (targets.size > 0) collectedEdges.set(current.key, targets);
2062
+ }
2063
+ return { nodes, edges: collectedEdges, cycles: graph.cycles, cwd };
2064
+ }
2065
+ function writeExport(format, input, writer) {
2066
+ const rendered = format === "mermaid" ? renderMermaidGraph(input) : format === "dot" ? renderDotGraph(input) : renderHtmlGraph(input);
2067
+ writer.write(rendered);
2068
+ }
1542
2069
  async function graphCommand(fileArg, options, ctx2) {
1543
- const tracker = createStageTracker(!options.json);
2070
+ const format = options.format ?? (options.json ? "json" : "terminal");
2071
+ const tracker = createStageTracker(format === "terminal");
1544
2072
  tracker.next("Loading config");
1545
2073
  const cwd = ctx2.cwd;
1546
2074
  try {
@@ -1553,9 +2081,24 @@ async function graphCommand(fileArg, options, ctx2) {
1553
2081
  nodes: graph.nodes,
1554
2082
  cwd
1555
2083
  };
2084
+ if (format === "mermaid" || format === "dot" || format === "html") {
2085
+ let exportInput;
2086
+ if (fileArg) {
2087
+ const targetPath2 = resolveTargetFile(fileArg, cwd);
2088
+ const { key: key2 } = requireNode(graph, targetPath2, context.rootDir);
2089
+ exportInput = reachableSubgraph(graph, key2, options.reverse, options.depth, cwd);
2090
+ } else {
2091
+ const nodes = /* @__PURE__ */ new Map();
2092
+ for (const [key2, node] of graph.nodes) nodes.set(key2, node.path);
2093
+ exportInput = { nodes, edges: graph.forward, cycles: graph.cycles, cwd };
2094
+ }
2095
+ tracker.done();
2096
+ writeExport(format, exportInput, ctx2.writer);
2097
+ return ExitCode.Success;
2098
+ }
1556
2099
  if (!fileArg) {
1557
2100
  tracker.done();
1558
- if (options.json) {
2101
+ if (format === "json") {
1559
2102
  const report = buildGraphJsonReport(input, ctx2.version);
1560
2103
  ctx2.writer.write(serializeJson(report));
1561
2104
  } else {
@@ -1577,7 +2120,7 @@ async function graphCommand(fileArg, options, ctx2) {
1577
2120
  const dependants = walk(graph, key, graph.forward, options.depth, cwd);
1578
2121
  const dependents = walk(graph, key, graph.reverse, options.depth, cwd);
1579
2122
  tracker.done();
1580
- if (options.json) {
2123
+ if (format === "json") {
1581
2124
  const report = buildGraphJsonReport(
1582
2125
  {
1583
2126
  ...input,
@@ -1720,29 +2263,72 @@ function renderResults(results, ctx2, color) {
1720
2263
  }
1721
2264
  ctx2.writer.writeLine();
1722
2265
  }
1723
- var INIT_FILENAME = "ripple.config.json";
2266
+ var SCHEMA_REF = "./node_modules/@alimaandev/ripple/ripple.schema.json";
2267
+ function jsonPayload(full) {
2268
+ const payload = full ? { ...DEFAULT_CONFIG } : { include: DEFAULT_CONFIG.include };
2269
+ return { ...payload, $schema: SCHEMA_REF };
2270
+ }
2271
+ function tsValue(value, indent) {
2272
+ if (value === void 0) return "undefined";
2273
+ if (Array.isArray(value)) {
2274
+ if (value.length === 0) return "[]";
2275
+ const items = value.map((item) => tsValue(item, `${indent} `)).join(`,
2276
+ ${indent} `);
2277
+ return `[
2278
+ ${indent} ${items}
2279
+ ${indent}]`;
2280
+ }
2281
+ if (typeof value === "object" && value !== null) {
2282
+ const entries = Object.entries(value).filter(
2283
+ ([, entry]) => entry !== void 0
2284
+ );
2285
+ if (entries.length === 0) return "{}";
2286
+ const items = entries.map(([key, entry]) => `${key}: ${tsValue(entry, `${indent} `)}`).join(`,
2287
+ ${indent} `);
2288
+ return `{
2289
+ ${indent} ${items}
2290
+ ${indent}}`;
2291
+ }
2292
+ return JSON.stringify(value);
2293
+ }
2294
+ function tsPayload(full) {
2295
+ if (!full) {
2296
+ return `import type { RippleConfig } from "@alimaandev/ripple";
2297
+
2298
+ const config: RippleConfig = {
2299
+ include: ${JSON.stringify(DEFAULT_CONFIG.include)},
2300
+ };
2301
+
2302
+ export default config;
2303
+ `;
2304
+ }
2305
+ const body = tsValue({ ...DEFAULT_CONFIG }, " ");
2306
+ return `import type { RippleConfig } from "@alimaandev/ripple";
2307
+
2308
+ const config: RippleConfig = ${body};
2309
+
2310
+ export default config;
2311
+ `;
2312
+ }
1724
2313
  async function initCommand(options, ctx2) {
1725
2314
  const style = { color: resolveColor(options.color) };
1726
- const target = path8.join(ctx2.cwd, INIT_FILENAME);
2315
+ const fileName = options.ts ? "ripple.config.ts" : "ripple.config.json";
2316
+ const target = path8.join(ctx2.cwd, fileName);
1727
2317
  try {
1728
- await fs.access(target);
2318
+ await fs3.access(target);
1729
2319
  if (!options.force) {
1730
2320
  const mark = style.color ? chalk.red(icon("cross")) : icon("cross");
1731
- ctx2.writer.writeLine(
1732
- `${mark} ${INIT_FILENAME} already exists. Pass --force to overwrite it.`
1733
- );
2321
+ ctx2.writer.writeLine(`${mark} ${fileName} already exists. Pass --force to overwrite it.`);
1734
2322
  return ExitCode.Failure;
1735
2323
  }
1736
2324
  } catch {
1737
2325
  }
1738
- const payload = {
1739
- ...DEFAULT_CONFIG
1740
- };
2326
+ const content = options.ts ? tsPayload(Boolean(options.full)) : `${JSON.stringify(jsonPayload(Boolean(options.full)), null, 2)}
2327
+ `;
1741
2328
  try {
1742
- await fs.writeFile(target, `${JSON.stringify(payload, null, 2)}
1743
- `, "utf8");
2329
+ await fs3.writeFile(target, content, "utf8");
1744
2330
  const mark = style.color ? chalk.green(icon("tick")) : icon("tick");
1745
- ctx2.writer.writeLine(`${mark} Wrote ${INIT_FILENAME}`);
2331
+ ctx2.writer.writeLine(`${mark} Wrote ${fileName}`);
1746
2332
  ctx2.writer.writeLine(
1747
2333
  ` ${dim("Edit it to tune include globs, aliases and risk weights.", style)}`
1748
2334
  );
@@ -1750,7 +2336,7 @@ async function initCommand(options, ctx2) {
1750
2336
  } catch (error) {
1751
2337
  const mark = style.color ? chalk.red(icon("cross")) : icon("cross");
1752
2338
  ctx2.writer.writeLine(
1753
- `${mark} Could not write ${INIT_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
2339
+ `${mark} Could not write ${fileName}: ${error instanceof Error ? error.message : String(error)}`
1754
2340
  );
1755
2341
  return ExitCode.Failure;
1756
2342
  }
@@ -1807,8 +2393,8 @@ function changedFiles(cwd, base) {
1807
2393
  }
1808
2394
 
1809
2395
  // src/formatter/annotations.ts
1810
- var LEVEL_INDEX = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
1811
- 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 };
1812
2398
  function escapeProperty(value) {
1813
2399
  return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A").replace(/,/g, "%2C");
1814
2400
  }
@@ -1821,7 +2407,7 @@ function renderAnnotation(annotation) {
1821
2407
  }
1822
2408
  function buildFileAnnotations(files, gate) {
1823
2409
  return files.map(({ file, result }) => {
1824
- const blocked = LEVEL_INDEX[result.risk.level] >= GATE_INDEX[gate];
2410
+ const blocked = LEVEL_INDEX2[result.risk.level] >= GATE_INDEX2[gate];
1825
2411
  return {
1826
2412
  level: blocked ? "error" : "warning",
1827
2413
  file,
@@ -1831,12 +2417,12 @@ function buildFileAnnotations(files, gate) {
1831
2417
  });
1832
2418
  }
1833
2419
  function buildGateAnnotation(blocked, gate, counts) {
1834
- const gateIndex = GATE_INDEX[gate];
2420
+ const gateIndex = GATE_INDEX2[gate];
1835
2421
  const parts = [];
1836
- if (LEVEL_INDEX.CRITICAL >= gateIndex && counts.critical > 0)
2422
+ if (LEVEL_INDEX2.CRITICAL >= gateIndex && counts.critical > 0)
1837
2423
  parts.push(`${counts.critical} CRITICAL`);
1838
- if (LEVEL_INDEX.HIGH >= gateIndex && counts.high > 0) parts.push(`${counts.high} HIGH`);
1839
- 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`);
1840
2426
  const atGate = parts.length > 0 ? parts.join(" \xB7 ") : "none";
1841
2427
  return {
1842
2428
  level: "notice",
@@ -1846,8 +2432,8 @@ function buildGateAnnotation(blocked, gate, counts) {
1846
2432
  }
1847
2433
 
1848
2434
  // src/commands/diff.ts
1849
- var LEVEL_INDEX2 = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
1850
- 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 };
1851
2437
  var VERDICT_NOTE = {
1852
2438
  medium: "no MEDIUM or worse",
1853
2439
  high: "no HIGH or CRITICAL",
@@ -1860,14 +2446,20 @@ function countLevels(results) {
1860
2446
  }
1861
2447
  return counts;
1862
2448
  }
2449
+ function isAllowedByDiffAllowlist(relPath2, patterns) {
2450
+ if (patterns.length === 0) return false;
2451
+ const rel = toPosix(relPath2);
2452
+ const matcher = picomatch(patterns.map(toPosix), { dot: true });
2453
+ return matcher(rel);
2454
+ }
1863
2455
  function riskySummary(counts, gate) {
1864
- const gateIndex = GATE_INDEX2[gate];
2456
+ const gateIndex = GATE_INDEX3[gate];
1865
2457
  const parts = [];
1866
- if (LEVEL_INDEX2.CRITICAL >= gateIndex && counts.critical > 0)
2458
+ if (LEVEL_INDEX3.CRITICAL >= gateIndex && counts.critical > 0)
1867
2459
  parts.push(`${counts.critical} CRITICAL`);
1868
- if (LEVEL_INDEX2.HIGH >= gateIndex && counts.high > 0) parts.push(`${counts.high} HIGH`);
1869
- if (LEVEL_INDEX2.MEDIUM >= gateIndex && counts.medium > 0) parts.push(`${counts.medium} MEDIUM`);
1870
- 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`);
1871
2463
  return parts.length > 0 ? parts.join(" \xB7 ") : "none";
1872
2464
  }
1873
2465
  async function diffCommand(options, ctx2) {
@@ -1905,9 +2497,16 @@ async function diffCommand(options, ctx2) {
1905
2497
  );
1906
2498
  }
1907
2499
  tracker.done();
1908
- const results = [...byRel.values()].sort((a, b) => b.risk.score - a.risk.score);
1909
- const counts = countLevels(results);
1910
- const blocked = results.some((result) => LEVEL_INDEX2[result.risk.level] >= GATE_INDEX2[gate]);
2500
+ const allowPatterns = [...options.allow ?? [], ...context.config.diff.allow ?? []];
2501
+ const allowedByRel = /* @__PURE__ */ new Map();
2502
+ for (const rel of byRel.keys()) {
2503
+ allowedByRel.set(rel, isAllowedByDiffAllowlist(rel, allowPatterns));
2504
+ }
2505
+ const entries = [...byRel.entries()].map(([rel, result]) => ({ rel, result, allowed: allowedByRel.get(rel) ?? false })).sort((a, b) => b.result.risk.score - a.result.risk.score);
2506
+ const gateResults = entries.filter((entry) => !entry.allowed).map((entry) => entry.result);
2507
+ const counts = countLevels(gateResults);
2508
+ const allowedCount = entries.filter((entry) => entry.allowed).length;
2509
+ const blocked = gateResults.some((result) => LEVEL_INDEX3[result.risk.level] >= GATE_INDEX3[gate]);
1911
2510
  const durationMs = Date.now() - started;
1912
2511
  if (format === "json") {
1913
2512
  ctx2.writer.write(
@@ -1916,9 +2515,11 @@ async function diffCommand(options, ctx2) {
1916
2515
  changed.baseLabel,
1917
2516
  changed.files,
1918
2517
  byRel,
2518
+ allowedByRel,
1919
2519
  gate,
1920
2520
  blocked,
1921
2521
  counts,
2522
+ allowedCount,
1922
2523
  durationMs,
1923
2524
  ctx2.version
1924
2525
  )
@@ -1926,10 +2527,26 @@ async function diffCommand(options, ctx2) {
1926
2527
  );
1927
2528
  return blocked ? ExitCode.Failure : ExitCode.Success;
1928
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
+ }
1929
2546
  if (format === "github") {
1930
2547
  const annotations = [
1931
2548
  ...buildFileAnnotations(
1932
- [...byRel.entries()].map(([file, result]) => ({ file, result })),
2549
+ [...byRel.entries()].filter(([rel]) => !(allowedByRel.get(rel) ?? false)).map(([file, result]) => ({ file, result })),
1933
2550
  gate
1934
2551
  ),
1935
2552
  buildGateAnnotation(blocked, gate, counts)
@@ -1943,9 +2560,10 @@ async function diffCommand(options, ctx2) {
1943
2560
  cwd: ctx2.cwd,
1944
2561
  baseLabel: changed.baseLabel,
1945
2562
  files: changed.files,
1946
- results,
2563
+ entries,
1947
2564
  skipped,
1948
2565
  counts,
2566
+ allowedCount,
1949
2567
  gate,
1950
2568
  blocked,
1951
2569
  durationMs,
@@ -1956,12 +2574,25 @@ async function diffCommand(options, ctx2) {
1956
2574
  return blocked ? ExitCode.Failure : ExitCode.Success;
1957
2575
  }
1958
2576
  function renderDiffReport(input, writer) {
1959
- const { cwd, baseLabel, files, results, skipped, counts, gate, blocked, durationMs, style } = input;
2577
+ const {
2578
+ cwd,
2579
+ baseLabel,
2580
+ files,
2581
+ entries,
2582
+ skipped,
2583
+ counts,
2584
+ allowedCount,
2585
+ gate,
2586
+ blocked,
2587
+ durationMs,
2588
+ style
2589
+ } = input;
1960
2590
  writer.writeLine(brandHeader({ label: `diff vs ${baseLabel}`, style }));
1961
2591
  writer.writeLine();
1962
2592
  const rows = [
1963
2593
  ["Changed", pluralize(files.length, "file")],
1964
- ["Source", pluralize(results.length, "file")],
2594
+ ["Source", pluralize(entries.length, "file")],
2595
+ ["Allowed", pluralize(allowedCount, "file")],
1965
2596
  ["Duration", dim(formatDuration(durationMs), style)]
1966
2597
  ];
1967
2598
  const width = Math.max(...rows.map(([key]) => key.length));
@@ -1978,16 +2609,17 @@ function renderDiffReport(input, writer) {
1978
2609
  `${markText} ${verdictText} \u2014 ${riskySummary(counts, gate)} ${dim(`(${VERDICT_NOTE[gate]})`, style)}`
1979
2610
  );
1980
2611
  writer.writeLine();
1981
- writer.writeLine(sectionHeader(`Risk analysis (${results.length} files)`, style));
1982
- for (const result of results) {
1983
- const risky = LEVEL_INDEX2[result.risk.level] >= GATE_INDEX2[gate];
2612
+ writer.writeLine(sectionHeader(`Risk analysis (${entries.length} files)`, style));
2613
+ for (const { result, allowed } of entries) {
2614
+ const risky = LEVEL_INDEX3[result.risk.level] >= GATE_INDEX3[gate];
1984
2615
  const fileIcon = risky ? icon("cross") : icon("tick");
1985
2616
  const fileIconText = style.color ? risky ? chalk.red(fileIcon) : chalk.green(fileIcon) : fileIcon;
1986
2617
  const score = result.risk.score.toFixed(1);
1987
2618
  const gauge = riskGauge(result.risk.score, result.risk.level, style);
1988
2619
  const file = displayPath(result.targetPath, cwd);
2620
+ const allowedTag = allowed ? ` ${dim("(allowed)", style)}` : "";
1989
2621
  writer.writeLine(
1990
- `${fileIconText} ${file} ${riskBadge(result.risk.level, style)} \xB7 ${score}/100${gauge ? ` ${gauge}` : ""}`
2622
+ `${fileIconText} ${file} ${riskBadge(result.risk.level, style)} \xB7 ${score}/100${gauge ? ` ${gauge}` : ""}${allowedTag}`
1991
2623
  );
1992
2624
  }
1993
2625
  for (const rel of skipped) {
@@ -1997,7 +2629,7 @@ function renderDiffReport(input, writer) {
1997
2629
  }
1998
2630
  writer.writeLine();
1999
2631
  }
2000
- function buildDiffJsonReport(baseLabel, changedFileNames, byRel, gate, blocked, counts, durationMs, version) {
2632
+ function buildDiffJsonReport(baseLabel, changedFileNames, byRel, allowedByRel, gate, blocked, counts, allowedCount, durationMs, version) {
2001
2633
  const files = [];
2002
2634
  for (const rel of changedFileNames) {
2003
2635
  const result = byRel.get(rel);
@@ -2007,7 +2639,8 @@ function buildDiffJsonReport(baseLabel, changedFileNames, byRel, gate, blocked,
2007
2639
  analyzed: true,
2008
2640
  risk: result.risk,
2009
2641
  affectedFiles: result.summary.affectedFiles,
2010
- targetInCycle: result.targetInCycle
2642
+ targetInCycle: result.targetInCycle,
2643
+ allowed: allowedByRel.get(rel) ?? false
2011
2644
  });
2012
2645
  } else {
2013
2646
  files.push({ file: rel, analyzed: false });
@@ -2021,20 +2654,468 @@ function buildDiffJsonReport(baseLabel, changedFileNames, byRel, gate, blocked,
2021
2654
  base: baseLabel,
2022
2655
  changedFiles: changedFileNames.length,
2023
2656
  files,
2024
- gate: { level: gate, blocked, counts },
2657
+ gate: { level: gate, blocked, counts, allowed: allowedCount },
2025
2658
  durationMs
2026
2659
  };
2027
2660
  }
2028
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
+
2029
3096
  // src/cli/program.ts
2030
3097
  var PROGRAM_NAME = "ripple";
3098
+ function helpExamples(examples) {
3099
+ const lines = examples.map((example) => ` ${example}`);
3100
+ return `
3101
+ Examples:
3102
+ ${lines.join("\n")}
3103
+ `;
3104
+ }
2031
3105
  function createProgram(ctx2) {
2032
3106
  const program = new Command().name(PROGRAM_NAME).description("Dependency impact analysis for TypeScript and JavaScript projects.").version(ctx2.version, "-V, --version", "print the Ripple version").showHelpAfterError();
2033
- program.command("analyze <file>").description("Analyze the impact of changing a file").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) => {
3107
+ program.command("analyze <file>").description("Analyze the impact of changing a file").addHelpText(
3108
+ "after",
3109
+ helpExamples([
3110
+ "ripple analyze src/authentication/login.ts",
3111
+ "ripple analyze src/index.ts --depth 3"
3112
+ ])
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) => {
2034
3114
  const exitCode2 = await analyzeCommand(
2035
3115
  file,
2036
3116
  {
2037
3117
  json: Boolean(options.json),
3118
+ sarif: Boolean(options.sarif),
2038
3119
  verbose: Boolean(options.verbose),
2039
3120
  ...options.depth !== void 0 ? { depth: options.depth } : {},
2040
3121
  ...options.config !== void 0 ? { config: options.config } : {},
@@ -2046,11 +3127,23 @@ function createProgram(ctx2) {
2046
3127
  throw new ExitError(exitCode2);
2047
3128
  }
2048
3129
  });
2049
- program.command("graph [file]").description("Show project graph stats, or the dependency tree of one file").option("-j, --json", "emit a machine-readable JSON report").option("-v, --verbose", "include extra sections").option("-r, --reverse", "show dependents (what imports the file) instead of dependants").option("-d, --depth <number>", "cap the tree depth", parsePositiveInt).option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (file, options) => {
3130
+ program.command("graph [file]").description("Show project graph stats, or the dependency tree of one file").addHelpText(
3131
+ "after",
3132
+ helpExamples([
3133
+ "ripple graph",
3134
+ "ripple graph src/index.ts --reverse --depth 3",
3135
+ "ripple graph --format mermaid"
3136
+ ])
3137
+ ).option("-j, --json", "emit a machine-readable JSON report").option(
3138
+ "-f, --format <format>",
3139
+ "output format: terminal | json | mermaid | dot | html",
3140
+ parseGraphFormat
3141
+ ).option("-v, --verbose", "include extra sections").option("-r, --reverse", "show dependents (what imports the file) instead of dependants").option("-d, --depth <number>", "cap the tree depth", parsePositiveInt).option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (file, options) => {
2050
3142
  const exitCode2 = await graphCommand(
2051
3143
  file,
2052
3144
  {
2053
3145
  json: Boolean(options.json),
3146
+ format: options.format ?? (options.json ? "json" : "terminal"),
2054
3147
  verbose: Boolean(options.verbose),
2055
3148
  reverse: Boolean(options.reverse),
2056
3149
  ...options.depth !== void 0 ? { depth: options.depth } : {},
@@ -2063,11 +3156,18 @@ function createProgram(ctx2) {
2063
3156
  throw new ExitError(exitCode2);
2064
3157
  }
2065
3158
  });
2066
- program.command("diff").description("Analyze changed files since a base ref and gate risky changes").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(
3159
+ program.command("diff").description("Analyze changed files since a base ref and gate risky changes").addHelpText(
3160
+ "after",
3161
+ helpExamples(["ripple diff --base main --gate critical", "ripple diff --format github"])
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(
2067
3167
  "-g, --gate <level>",
2068
3168
  "risk level that blocks the gate (medium | high | critical)",
2069
3169
  parseGateLevel
2070
- ).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 (options) => {
3170
+ ).option("-d, --depth <number>", "cap the reverse traversal depth", parsePositiveInt).option("-a, --allow <glob...>", "changed files matching these globs never block the gate").option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (options) => {
2071
3171
  const exitCode2 = await diffCommand(
2072
3172
  {
2073
3173
  json: Boolean(options.json),
@@ -2076,6 +3176,7 @@ function createProgram(ctx2) {
2076
3176
  ...options.base !== void 0 ? { base: options.base } : {},
2077
3177
  ...options.gate !== void 0 ? { gate: options.gate } : {},
2078
3178
  ...options.depth !== void 0 ? { depth: options.depth } : {},
3179
+ ...options.allow !== void 0 ? { allow: options.allow } : {},
2079
3180
  ...options.config !== void 0 ? { config: options.config } : {},
2080
3181
  color: options.color
2081
3182
  },
@@ -2085,7 +3186,7 @@ function createProgram(ctx2) {
2085
3186
  throw new ExitError(exitCode2);
2086
3187
  }
2087
3188
  });
2088
- program.command("doctor").description("Check the project and environment for Ripple readiness").option("-v, --verbose", "print detailed diagnostics").option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (options) => {
3189
+ program.command("doctor").description("Check the project and environment for Ripple readiness").addHelpText("after", helpExamples(["ripple doctor", "ripple doctor -v"])).option("-v, --verbose", "print detailed diagnostics").option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (options) => {
2089
3190
  const exitCode2 = await doctorCommand(
2090
3191
  {
2091
3192
  verbose: Boolean(options.verbose),
@@ -2098,16 +3199,35 @@ function createProgram(ctx2) {
2098
3199
  throw new ExitError(exitCode2);
2099
3200
  }
2100
3201
  });
2101
- program.command("init").description("Create a ripple.config.json with the default settings").option("-f, --force", "overwrite an existing config file").option("--no-color", "disable ANSI colors").action(async (options) => {
3202
+ program.command("init").description("Create a ripple.config with the default settings").addHelpText("after", helpExamples(["ripple init --force", "ripple init --ts --full"])).option("-f, --force", "overwrite an existing config file").option("--full", "write every built-in default instead of a minimal config").option("--ts", "write a typed ripple.config.ts instead of JSON").option("--no-color", "disable ANSI colors").action(async (options) => {
2102
3203
  const exitCode2 = await initCommand(
2103
- { force: Boolean(options.force), color: options.color },
3204
+ {
3205
+ force: Boolean(options.force),
3206
+ full: Boolean(options.full),
3207
+ ts: Boolean(options.ts),
3208
+ color: options.color
3209
+ },
3210
+ ctx2
3211
+ );
3212
+ if (exitCode2 !== ExitCode.Success) {
3213
+ throw new ExitError(exitCode2);
3214
+ }
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
+ },
2104
3224
  ctx2
2105
3225
  );
2106
3226
  if (exitCode2 !== ExitCode.Success) {
2107
3227
  throw new ExitError(exitCode2);
2108
3228
  }
2109
3229
  });
2110
- program.command("version").description("Print the Ripple version").action(() => {
3230
+ program.command("version").description("Print the Ripple version").addHelpText("after", helpExamples(["ripple version"])).action(() => {
2111
3231
  ctx2.writer.writeLine(ctx2.version);
2112
3232
  });
2113
3233
  program.addHelpText(
@@ -2147,13 +3267,23 @@ function parseGateLevel(value) {
2147
3267
  );
2148
3268
  }
2149
3269
  function parseDiffFormat(value) {
2150
- if (value === "terminal" || value === "json" || value === "github") {
3270
+ if (value === "terminal" || value === "json" || value === "github" || value === "sarif") {
3271
+ return value;
3272
+ }
3273
+ throw new CommanderError(
3274
+ 1,
3275
+ "commander.invalidArgument",
3276
+ `expected one of terminal | json | github | sarif, got "${value}"`
3277
+ );
3278
+ }
3279
+ function parseGraphFormat(value) {
3280
+ if (value === "terminal" || value === "json" || value === "mermaid" || value === "dot" || value === "html") {
2151
3281
  return value;
2152
3282
  }
2153
3283
  throw new CommanderError(
2154
3284
  1,
2155
3285
  "commander.invalidArgument",
2156
- `expected one of terminal | json | github, got "${value}"`
3286
+ `expected one of terminal | json | mermaid | dot | html, got "${value}"`
2157
3287
  );
2158
3288
  }
2159
3289
  function printError(error, ctx2) {
@@ -2189,7 +3319,7 @@ async function run({ argv, ctx: ctx2 }) {
2189
3319
 
2190
3320
  // package.json
2191
3321
  var package_default = {
2192
- version: "0.5.0"};
3322
+ version: "0.7.0"};
2193
3323
 
2194
3324
  // src/cli/bin.ts
2195
3325
  var ctx = {