@alimaandev/ripple 0.6.0 → 0.7.1

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 { rippleConfigSchema, DEFAULT_CONFIG, formatSchemaIssues } from './chunk-CFH4UW2E.js';
2
+ import { rippleConfigSchema, DEFAULT_CONFIG, formatSchemaIssues } from './chunk-VSLCAPAH.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';
7
- import fs2 from 'fs';
6
+ import fs4 from 'fs/promises';
7
+ import fs3 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, randomUUID } from 'crypto';
12
13
  import fg from 'fast-glob';
13
14
  import ora from 'ora';
14
- import picomatch from 'picomatch';
15
15
  import { execFileSync } from 'child_process';
16
+ import picomatch from 'picomatch';
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 fs4.access(filePath);
42
44
  return true;
43
45
  } catch {
44
46
  return false;
@@ -46,7 +48,7 @@ async function pathExists(filePath) {
46
48
  }
47
49
  function pathExistsSync(filePath) {
48
50
  try {
49
- fs2.accessSync(filePath);
51
+ fs3.accessSync(filePath);
50
52
  return true;
51
53
  } catch {
52
54
  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 fs4.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;
@@ -285,14 +287,31 @@ function computeConfidence(stats, targetInCycle) {
285
287
  if (targetInCycle) confidence *= 0.95;
286
288
  return Math.round(Math.min(1, Math.max(0, confidence)) * 100);
287
289
  }
288
- var SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
289
- var RESOLVABLE_EXTENSIONS = [...SOURCE_EXTENSIONS, ".mjs", ".cjs", ".d.ts"];
290
+ var SOURCE_EXTENSIONS = [
291
+ ".ts",
292
+ ".tsx",
293
+ ".js",
294
+ ".jsx",
295
+ ".mts",
296
+ ".cts",
297
+ ".mjs",
298
+ ".cjs"
299
+ ];
300
+ var RESOLVABLE_EXTENSIONS = [...SOURCE_EXTENSIONS, ".d.ts"];
290
301
  function toPosix(filePath) {
291
302
  return filePath.replace(/\\/g, "/");
292
303
  }
293
304
  function normalizeAbsolute(filePath, cwd) {
294
305
  return path8.resolve(cwd, filePath);
295
306
  }
307
+ function realPath(filePath, cwd) {
308
+ const absolute = path8.resolve(cwd, filePath);
309
+ try {
310
+ return fs3.realpathSync.native(absolute);
311
+ } catch {
312
+ return absolute;
313
+ }
314
+ }
296
315
  function pathKey(filePath, cwd) {
297
316
  const absolute = normalizeAbsolute(filePath, cwd);
298
317
  const posix = toPosix(absolute);
@@ -306,13 +325,22 @@ function sourceFileKind(filePath) {
306
325
  function extensionCandidates(basePath) {
307
326
  return RESOLVABLE_EXTENSIONS.map((ext) => basePath + ext);
308
327
  }
309
- function jsToTsCandidate(candidate) {
310
- const ext = path8.extname(candidate);
311
- if (ext === ".js") return candidate.slice(0, -3) + ".ts";
312
- if (ext === ".jsx") return candidate.slice(0, -4) + ".tsx";
313
- if (ext === ".mjs") return candidate.slice(0, -4) + ".mts";
314
- if (ext === ".cjs") return candidate.slice(0, -4) + ".cts";
315
- return candidate;
328
+ function jsToTsCandidates(candidate) {
329
+ const ext = path8.extname(candidate).toLowerCase();
330
+ if (ext === "") return [];
331
+ const stem = candidate.slice(0, -ext.length);
332
+ switch (ext) {
333
+ case ".js":
334
+ return [`${stem}.ts`, `${stem}.tsx`];
335
+ case ".jsx":
336
+ return [`${stem}.tsx`];
337
+ case ".mjs":
338
+ return [`${stem}.mts`];
339
+ case ".cjs":
340
+ return [`${stem}.cts`];
341
+ default:
342
+ return [];
343
+ }
316
344
  }
317
345
  function displayPath(filePath, cwd) {
318
346
  const relative = path8.relative(cwd, filePath);
@@ -393,6 +421,7 @@ function createCategorizer(options) {
393
421
  };
394
422
  }
395
423
  async function detectEntryPoints(rootDir) {
424
+ const started = Date.now();
396
425
  const entryPoints = /* @__PURE__ */ new Set();
397
426
  const packageJsonPath = await findPackageJson(rootDir);
398
427
  if (packageJsonPath) {
@@ -411,6 +440,9 @@ async function detectEntryPoints(rootDir) {
411
440
  }
412
441
  }
413
442
  }
443
+ if (process.env.RIPPLE_TRACE === "1") {
444
+ console.error(`[trace] detectEntryPoints: ${Date.now() - started}ms`);
445
+ }
414
446
  return entryPoints;
415
447
  }
416
448
 
@@ -827,11 +859,9 @@ function impactLine(result, style) {
827
859
  if (summary.utilities > 0) parts.push(pluralize(summary.utilities, "utility"));
828
860
  return parts.length > 0 ? parts.join(" \xB7 ") : dim("none", style);
829
861
  }
830
- function buildAffectedTree(result, cwd, style) {
862
+ function buildAffectedTree(result, cwd, style, limit) {
831
863
  const { affected, graph } = result;
832
- const sorted = [...affected.entries()].sort(
833
- ([, a], [, b]) => a.depth - b.depth || a.path.localeCompare(b.path)
834
- );
864
+ const sorted = [...affected.entries()].sort(([, a], [, b]) => a.depth - b.depth || a.path.localeCompare(b.path)).slice(0, limit);
835
865
  const nodesByKey = /* @__PURE__ */ new Map();
836
866
  const depthByKey = /* @__PURE__ */ new Map();
837
867
  for (const [key, file] of sorted) {
@@ -905,9 +935,9 @@ function renderAnalyzeReport(result, options, writer) {
905
935
  writer.writeLine();
906
936
  if (summary.affectedFiles > 0) {
907
937
  writer.writeLine(sectionHeader(`Affected files (${summary.affectedFiles})`, style));
908
- const tree = buildAffectedTree(result, options.cwd, style);
909
- writer.writeLine(renderTree(tree));
910
938
  const limit = options.listLimit ?? DEFAULT_LIST_LIMIT;
939
+ const tree = buildAffectedTree(result, options.cwd, style, limit);
940
+ writer.writeLine(renderTree(tree));
911
941
  if (summary.affectedFiles > limit) {
912
942
  writer.writeLine(
913
943
  dim(`${summary.affectedFiles - limit} more\u2026 use --json for the full list`, style)
@@ -939,7 +969,7 @@ function renderAnalyzeReport(result, options, writer) {
939
969
  }
940
970
  }
941
971
  function buildAnalyzeJsonReport(result, version, cwd) {
942
- const realPath = (key) => result.graph.nodes.get(key)?.path ?? key;
972
+ const realPath2 = (key) => result.graph.nodes.get(key)?.path ?? key;
943
973
  const affected = [...result.affected.values()].sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path)).map((file) => ({ ...file, path: displayPath(file.path, cwd) }));
944
974
  return {
945
975
  tool: "ripple",
@@ -950,8 +980,8 @@ function buildAnalyzeJsonReport(result, version, cwd) {
950
980
  summary: result.summary,
951
981
  affected,
952
982
  cycles: result.graph.cycles.map((cycle) => ({
953
- members: cycle.members.map((key) => displayPath(realPath(key), cwd)),
954
- path: cycle.path.map((key) => displayPath(realPath(key), cwd))
983
+ members: cycle.members.map((key) => displayPath(realPath2(key), cwd)),
984
+ path: cycle.path.map((key) => displayPath(realPath2(key), cwd))
955
985
  })),
956
986
  targetInCycle: result.targetInCycle,
957
987
  durationMs: result.durationMs
@@ -974,12 +1004,12 @@ function renderGraphReport(input, options, writer) {
974
1004
  writer.writeLine();
975
1005
  if (input.forward) {
976
1006
  writer.writeLine(sectionHeader("Dependants (forward)", style));
977
- writer.writeLine(renderTree(input.forward.map((path13) => ({ label: path13 }))));
1007
+ writer.writeLine(renderTree(input.forward.map((path15) => ({ label: path15 }))));
978
1008
  writer.writeLine();
979
1009
  }
980
1010
  if (input.reverse) {
981
1011
  writer.writeLine(sectionHeader("Dependents (reverse)", style));
982
- writer.writeLine(renderTree(input.reverse.map((path13) => ({ label: path13 }))));
1012
+ writer.writeLine(renderTree(input.reverse.map((path15) => ({ label: path15 }))));
983
1013
  writer.writeLine();
984
1014
  }
985
1015
  writer.writeLine(sectionHeader("Circular dependencies", style));
@@ -996,7 +1026,7 @@ function renderGraphReport(input, options, writer) {
996
1026
  }
997
1027
  }
998
1028
  function buildGraphJsonReport(input, version) {
999
- const realPath = (key) => input.nodes.get(key)?.path ?? key;
1029
+ const realPath2 = (key) => input.nodes.get(key)?.path ?? key;
1000
1030
  return {
1001
1031
  tool: "ripple",
1002
1032
  version,
@@ -1005,8 +1035,8 @@ function buildGraphJsonReport(input, version) {
1005
1035
  edgeCount: input.stats.internalEdges,
1006
1036
  externalEdgeCount: input.stats.externalEdges,
1007
1037
  cycles: input.cycles.map((cycle) => ({
1008
- members: cycle.members.map((key) => displayPath(realPath(key), input.cwd)),
1009
- path: cycle.path.map((key) => displayPath(realPath(key), input.cwd))
1038
+ members: cycle.members.map((key) => displayPath(realPath2(key), input.cwd)),
1039
+ path: cycle.path.map((key) => displayPath(realPath2(key), input.cwd))
1010
1040
  })),
1011
1041
  ...input.forward ? { forward: input.forward } : {},
1012
1042
  ...input.reverse ? { reverse: input.reverse } : {}
@@ -1018,6 +1048,159 @@ function serializeJson(report) {
1018
1048
  return `${JSON.stringify(report, null, 2)}
1019
1049
  `;
1020
1050
  }
1051
+ var LEVEL_INDEX = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
1052
+ var GATE_INDEX = { medium: 1, high: 2, critical: 3 };
1053
+ var INFORMATION_URI = "https://github.com/alimaandev/ripple";
1054
+ function riskToSarifLevel(level) {
1055
+ switch (level) {
1056
+ case "CRITICAL":
1057
+ case "HIGH":
1058
+ return "error";
1059
+ case "MEDIUM":
1060
+ return "warning";
1061
+ default:
1062
+ return "note";
1063
+ }
1064
+ }
1065
+ function primaryLocationHash(ruleId, uri) {
1066
+ return createHash("sha256").update(`${ruleId}|${uri}`).digest("hex");
1067
+ }
1068
+ var RISK_RULE = {
1069
+ id: "ripple/risk",
1070
+ name: "riskScore",
1071
+ shortDescription: {
1072
+ text: "Risk of changing a file's blast radius"
1073
+ },
1074
+ fullDescription: {
1075
+ 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."
1076
+ },
1077
+ defaultConfiguration: { level: "warning" },
1078
+ properties: { tags: ["ripple", "impact-analysis"] }
1079
+ };
1080
+ function fileResult(entry, gate) {
1081
+ const { rel, result, allowed } = entry;
1082
+ const uri = entry.repoRel ?? rel;
1083
+ const level = allowed ? "note" : riskToSarifLevel(result.risk.level);
1084
+ const blocked = LEVEL_INDEX[result.risk.level] >= GATE_INDEX[gate];
1085
+ const score = result.risk.score.toFixed(1);
1086
+ const affected = result.summary.affectedFiles;
1087
+ const message = allowed ? `Allowlisted: ${result.risk.level} risk (${score}/100) - ${affected} affected file(s)` : `${result.risk.level} risk (${score}/100) - ${affected} affected file(s)`;
1088
+ const entryResult = {
1089
+ ruleId: "ripple/risk",
1090
+ level,
1091
+ message: { text: message },
1092
+ locations: [
1093
+ {
1094
+ physicalLocation: {
1095
+ artifactLocation: { uri },
1096
+ region: { startLine: 1 }
1097
+ }
1098
+ }
1099
+ ],
1100
+ partialFingerprints: {
1101
+ primaryLocationLineHash: primaryLocationHash("ripple/risk", uri)
1102
+ },
1103
+ properties: {
1104
+ ripple: {
1105
+ score: result.risk.score,
1106
+ level: result.risk.level,
1107
+ affectedFiles: result.summary.affectedFiles,
1108
+ targetInCycle: result.targetInCycle,
1109
+ allowed,
1110
+ blocked,
1111
+ gate
1112
+ }
1113
+ }
1114
+ };
1115
+ if (allowed) {
1116
+ entryResult.suppressions = [
1117
+ { kind: "inSource", justification: "Allowlisted via diff.allow in ripple config" }
1118
+ ];
1119
+ }
1120
+ return entryResult;
1121
+ }
1122
+ function buildDiffSarif(options) {
1123
+ const { baseLabel, entries, gate, counts, blocked, durationMs, version } = options;
1124
+ const results = entries.map((entry) => fileResult(entry, gate));
1125
+ return {
1126
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
1127
+ version: "2.1.0",
1128
+ runs: [
1129
+ {
1130
+ tool: {
1131
+ driver: {
1132
+ name: "ripple",
1133
+ fullName: "Ripple \u2014 dependency impact analysis",
1134
+ informationUri: INFORMATION_URI,
1135
+ version,
1136
+ rules: [RISK_RULE]
1137
+ }
1138
+ },
1139
+ results,
1140
+ properties: {
1141
+ ripple: {
1142
+ command: "diff",
1143
+ base: baseLabel,
1144
+ gate,
1145
+ blocked,
1146
+ counts,
1147
+ durationMs
1148
+ }
1149
+ }
1150
+ }
1151
+ ]
1152
+ };
1153
+ }
1154
+ function buildAnalyzeSarif(options) {
1155
+ const { result, cwd, version } = options;
1156
+ const rel = result.targetPath.slice(cwd.length).replace(/^[/\\]+/, "").replace(/\\/g, "/");
1157
+ const uri = rel || result.targetPath;
1158
+ return {
1159
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
1160
+ version: "2.1.0",
1161
+ runs: [
1162
+ {
1163
+ tool: {
1164
+ driver: {
1165
+ name: "ripple",
1166
+ fullName: "Ripple \u2014 dependency impact analysis",
1167
+ informationUri: INFORMATION_URI,
1168
+ version,
1169
+ rules: [RISK_RULE]
1170
+ }
1171
+ },
1172
+ results: [
1173
+ {
1174
+ ruleId: "ripple/risk",
1175
+ level: riskToSarifLevel(result.risk.level),
1176
+ message: {
1177
+ text: `${result.risk.level} risk (${result.risk.score.toFixed(1)}/100) - ${result.summary.affectedFiles} affected file(s)`
1178
+ },
1179
+ locations: [
1180
+ {
1181
+ physicalLocation: {
1182
+ artifactLocation: { uri },
1183
+ region: { startLine: 1 }
1184
+ }
1185
+ }
1186
+ ],
1187
+ partialFingerprints: {
1188
+ primaryLocationLineHash: primaryLocationHash("ripple/risk", uri)
1189
+ },
1190
+ properties: {
1191
+ ripple: {
1192
+ score: result.risk.score,
1193
+ level: result.risk.level,
1194
+ affectedFiles: result.summary.affectedFiles,
1195
+ targetInCycle: result.targetInCycle
1196
+ }
1197
+ }
1198
+ }
1199
+ ]
1200
+ }
1201
+ ]
1202
+ };
1203
+ }
1021
1204
  function createTsProject() {
1022
1205
  return new Project({
1023
1206
  skipAddingFilesFromTsConfig: true,
@@ -1057,7 +1240,7 @@ function isSourceFile(filePath) {
1057
1240
 
1058
1241
  // src/scanner/discover.ts
1059
1242
  async function discoverSourceFiles(options) {
1060
- const ignoreGlobs = options.ignore.map(toIgnoreGlob).filter((glob) => glob !== "").concat(["**/node_modules/**"]);
1243
+ const ignoreGlobs = options.ignore.map(toIgnoreGlob).filter((glob) => glob !== "").concat(["**/node_modules/**", "**/.ripple/**"]);
1061
1244
  const files = await globFiles({
1062
1245
  cwd: options.rootDir,
1063
1246
  patterns: options.include,
@@ -1185,30 +1368,80 @@ function extractSymbols(sourceFile) {
1185
1368
  }
1186
1369
 
1187
1370
  // src/parser/parse.ts
1188
- function parseSourceFile(project, filePath) {
1371
+ var MAX_REPORTED_DIAGNOSTICS = 3;
1372
+ function diagnosticText(diagnostic) {
1373
+ const message = diagnostic.getMessageText();
1374
+ return typeof message === "string" ? message : message.getMessageText();
1375
+ }
1376
+ function formatDiagnostics(diagnostics) {
1377
+ const parts = diagnostics.slice(0, MAX_REPORTED_DIAGNOSTICS).map((diagnostic) => {
1378
+ const line = diagnostic.getLineNumber();
1379
+ return `${line !== void 0 ? `line ${line}: ` : ""}${diagnosticText(diagnostic)}`;
1380
+ });
1381
+ const suffix = diagnostics.length > MAX_REPORTED_DIAGNOSTICS ? ` (+${diagnostics.length - MAX_REPORTED_DIAGNOSTICS} more)` : "";
1382
+ return `${parts.join("; ")}${suffix}`;
1383
+ }
1384
+ function collectSyntacticErrors(project, filePaths) {
1385
+ const errors = /* @__PURE__ */ new Map();
1386
+ if (filePaths.length === 0) return errors;
1387
+ const targets = new Set(filePaths.map((filePath) => pathKey(filePath, process.cwd())));
1388
+ let diagnostics;
1389
+ try {
1390
+ diagnostics = project.getProgram().getSyntacticDiagnostics();
1391
+ } catch {
1392
+ return errors;
1393
+ }
1394
+ const byFile = /* @__PURE__ */ new Map();
1395
+ for (const diagnostic of diagnostics) {
1396
+ const key = pathKey(diagnostic.getSourceFile().getFilePath(), process.cwd());
1397
+ if (!targets.has(key)) continue;
1398
+ const list = byFile.get(key) ?? [];
1399
+ list.push(diagnostic);
1400
+ byFile.set(key, list);
1401
+ }
1402
+ for (const [key, list] of byFile) {
1403
+ errors.set(key, formatDiagnostics(list));
1404
+ }
1405
+ return errors;
1406
+ }
1407
+ function emptySurface(filePath, kind, parseError) {
1408
+ return {
1409
+ path: filePath,
1410
+ kind,
1411
+ imports: [],
1412
+ exports: { named: [], hasDefault: false, reExportedFrom: [], reExportedAll: [] },
1413
+ symbols: { functions: [], classes: [], interfaces: [], enums: [], typeAliases: [] },
1414
+ parseError
1415
+ };
1416
+ }
1417
+ function parseSurface(project, filePath) {
1418
+ const kind = sourceFileKind(filePath) ?? "ts";
1189
1419
  let sourceFile;
1190
1420
  try {
1191
1421
  sourceFile = project.addSourceFileAtPath(filePath);
1192
1422
  } catch (error) {
1423
+ return emptySurface(filePath, kind, error instanceof Error ? error.message : String(error));
1424
+ }
1425
+ try {
1193
1426
  return {
1194
1427
  path: filePath,
1195
- kind: sourceFileKind(filePath) ?? "ts",
1196
- imports: [],
1197
- exports: { named: [], hasDefault: false, reExportedFrom: [], reExportedAll: [] },
1198
- symbols: { functions: [], classes: [], interfaces: [], enums: [], typeAliases: [] },
1199
- parseError: error instanceof Error ? error.message : String(error)
1428
+ kind,
1429
+ imports: extractImports(sourceFile),
1430
+ exports: extractExports(sourceFile),
1431
+ symbols: extractSymbols(sourceFile)
1200
1432
  };
1433
+ } catch (error) {
1434
+ return emptySurface(filePath, kind, error instanceof Error ? error.message : String(error));
1201
1435
  }
1202
- return {
1203
- path: filePath,
1204
- kind: sourceFileKind(filePath) ?? "ts",
1205
- imports: extractImports(sourceFile),
1206
- exports: extractExports(sourceFile),
1207
- symbols: extractSymbols(sourceFile)
1208
- };
1209
1436
  }
1210
1437
  function parseMany(project, filePaths) {
1211
- return filePaths.map((filePath) => parseSourceFile(project, filePath));
1438
+ const parsed = filePaths.map((filePath) => parseSurface(project, filePath));
1439
+ const syntaxErrors = collectSyntacticErrors(project, filePaths);
1440
+ return parsed.map((parsedFile) => {
1441
+ if (parsedFile.parseError !== void 0) return parsedFile;
1442
+ const error = syntaxErrors.get(pathKey(parsedFile.path, process.cwd()));
1443
+ return error === void 0 ? parsedFile : emptySurface(parsedFile.path, parsedFile.kind, error);
1444
+ });
1212
1445
  }
1213
1446
 
1214
1447
  // src/graph/cycles.ts
@@ -1271,8 +1504,8 @@ function findCycles(graph) {
1271
1504
  }
1272
1505
  const members = [...component].sort();
1273
1506
  const componentSet = new Set(component);
1274
- const path13 = representativeCyclePath(graph, component, componentSet);
1275
- cycles.push({ members, path: path13 ?? [members[0], members[0]] });
1507
+ const path15 = representativeCyclePath(graph, component, componentSet);
1508
+ cycles.push({ members, path: path15 ?? [members[0], members[0]] });
1276
1509
  }
1277
1510
  cycles.sort(
1278
1511
  (a, b) => a.members.length - b.members.length || a.members[0].localeCompare(b.members[0])
@@ -1281,14 +1514,14 @@ function findCycles(graph) {
1281
1514
  }
1282
1515
  function representativeCyclePath(graph, component, componentSet) {
1283
1516
  const inPath = /* @__PURE__ */ new Set();
1284
- const path13 = [];
1517
+ const path15 = [];
1285
1518
  let current = component[0];
1286
1519
  for (let steps = 0; steps <= component.length * 2 && current !== void 0; steps++) {
1287
1520
  const firstOccurrence = inPath.has(current);
1288
1521
  if (firstOccurrence) {
1289
- const start = path13.indexOf(current);
1522
+ const start = path15.indexOf(current);
1290
1523
  if (start === -1) return void 0;
1291
- const cycle = path13.slice(start);
1524
+ const cycle = path15.slice(start);
1292
1525
  if (cycle.length > 1 || graph.forward.get(current)?.has(current)) {
1293
1526
  cycle.push(current);
1294
1527
  return cycle;
@@ -1296,7 +1529,7 @@ function representativeCyclePath(graph, component, componentSet) {
1296
1529
  return void 0;
1297
1530
  }
1298
1531
  inPath.add(current);
1299
- path13.push(current);
1532
+ path15.push(current);
1300
1533
  const next = [...graph.forward.get(current) ?? []].find(
1301
1534
  (candidate) => componentSet.has(candidate)
1302
1535
  );
@@ -1309,16 +1542,15 @@ function probeCandidates(base, fileKeys, rootDir) {
1309
1542
  const key = pathKey(candidate, rootDir);
1310
1543
  if (fileKeys.has(key)) return path8.normalize(candidate);
1311
1544
  }
1312
- const jsSwapped = jsToTsCandidate(base);
1313
- if (jsSwapped !== base) {
1314
- const swappedKey = pathKey(jsSwapped, rootDir);
1315
- if (fileKeys.has(swappedKey)) return path8.normalize(jsSwapped);
1545
+ for (const swapped of jsToTsCandidates(base)) {
1546
+ const swappedKey = pathKey(swapped, rootDir);
1547
+ if (fileKeys.has(swappedKey)) return path8.normalize(swapped);
1316
1548
  }
1317
1549
  return void 0;
1318
1550
  }
1319
1551
  function existsOnDisk(filePath) {
1320
1552
  try {
1321
- fs2.accessSync(filePath);
1553
+ fs3.accessSync(filePath);
1322
1554
  return true;
1323
1555
  } catch {
1324
1556
  return false;
@@ -1342,7 +1574,7 @@ function resolveToFile(base, context) {
1342
1574
  if (fileKeys.has(exactKey)) return path8.normalize(base);
1343
1575
  const probed = probeCandidates(base, fileKeys, rootDir);
1344
1576
  if (probed) return probed;
1345
- if (existsOnDisk(base) && fs2.statSync(base).isDirectory()) {
1577
+ if (existsOnDisk(base) && fs3.statSync(base).isDirectory()) {
1346
1578
  const indexProbed = probeCandidates(path8.join(base, "index"), fileKeys, rootDir);
1347
1579
  if (indexProbed) return indexProbed;
1348
1580
  }
@@ -1379,7 +1611,14 @@ function resolveImports(fromPath, importSpecifiers, context) {
1379
1611
 
1380
1612
  // src/graph/build.ts
1381
1613
  function buildGraph(project, filePaths, context) {
1382
- const parsedFiles = parseMany(project, filePaths);
1614
+ return buildGraphFromParsed(parseMany(project, filePaths), context);
1615
+ }
1616
+ function buildGraphFromParsed(parsedFiles, context) {
1617
+ const started = Date.now();
1618
+ const trace = process.env.RIPPLE_TRACE === "1";
1619
+ const mark = (label) => {
1620
+ if (trace) console.error(`[trace] graph.${label}: ${Date.now() - started}ms`);
1621
+ };
1383
1622
  const nodes = /* @__PURE__ */ new Map();
1384
1623
  const forward = /* @__PURE__ */ new Map();
1385
1624
  const reverse = /* @__PURE__ */ new Map();
@@ -1393,6 +1632,7 @@ function buildGraph(project, filePaths, context) {
1393
1632
  nodes.set(key, { path: parsed.path, parsed });
1394
1633
  if (!parsed.parseError) parsedCount++;
1395
1634
  }
1635
+ mark("nodes");
1396
1636
  for (const parsed of parsedFiles) {
1397
1637
  const fromKey = pathKey(parsed.path, context.rootDir);
1398
1638
  const targets = /* @__PURE__ */ new Set();
@@ -1436,26 +1676,212 @@ function buildGraph(project, filePaths, context) {
1436
1676
  cycles: 0
1437
1677
  }
1438
1678
  };
1679
+ mark("edges");
1439
1680
  graph.cycles = findCycles(graph);
1440
1681
  graph.stats.cycles = graph.cycles.length;
1682
+ mark("cycles");
1441
1683
  return graph;
1442
1684
  }
1443
1685
 
1686
+ // package.json
1687
+ var package_default = {
1688
+ version: "0.7.1"};
1689
+
1690
+ // src/cache/parsed.ts
1691
+ var CACHE_SCHEMA = 3;
1692
+ var CACHE_FILE = path8.join(".ripple", "cache", "parsed-v3.json");
1693
+ var cacheEnabled = () => process.env.RIPPLE_NO_CACHE !== "1";
1694
+ async function contentHash(filePath) {
1695
+ const bytes = await fs4.readFile(filePath);
1696
+ return createHash("sha256").update(bytes).digest("hex");
1697
+ }
1698
+ function configCacheHash(config) {
1699
+ const stable = {
1700
+ parser: `${CACHE_SCHEMA}:${package_default.version}`,
1701
+ include: [...config.include].sort(),
1702
+ ignore: [...config.ignore].sort()
1703
+ };
1704
+ return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
1705
+ }
1706
+ function relPosix(rootDir, absPath) {
1707
+ return toPosix(path8.relative(rootDir, absPath));
1708
+ }
1709
+ function cachePath(rootDir) {
1710
+ return path8.join(rootDir, CACHE_FILE);
1711
+ }
1712
+ async function loadParsedCache(rootDir, configHash) {
1713
+ let raw;
1714
+ try {
1715
+ raw = await fs4.readFile(cachePath(rootDir), "utf8");
1716
+ } catch {
1717
+ return /* @__PURE__ */ new Map();
1718
+ }
1719
+ try {
1720
+ const manifest = JSON.parse(raw);
1721
+ if (manifest.schema !== CACHE_SCHEMA || manifest.configHash !== configHash) {
1722
+ return /* @__PURE__ */ new Map();
1723
+ }
1724
+ return new Map(Object.entries(manifest.files ?? {}));
1725
+ } catch {
1726
+ return /* @__PURE__ */ new Map();
1727
+ }
1728
+ }
1729
+ async function saveParsedCache(rootDir, configHash, entries) {
1730
+ const manifest = {
1731
+ schema: CACHE_SCHEMA,
1732
+ configHash,
1733
+ files: Object.fromEntries(entries)
1734
+ };
1735
+ const target = cachePath(rootDir);
1736
+ const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
1737
+ try {
1738
+ await fs4.mkdir(path8.dirname(target), { recursive: true });
1739
+ await fs4.writeFile(tmp, JSON.stringify(manifest), "utf8");
1740
+ await fs4.rename(tmp, target);
1741
+ } catch {
1742
+ try {
1743
+ await fs4.rm(tmp, { force: true });
1744
+ } catch {
1745
+ }
1746
+ }
1747
+ }
1748
+ async function loadParsedFiles(options) {
1749
+ const { project, rootDir, filePaths, config } = options;
1750
+ if (!cacheEnabled()) {
1751
+ return {
1752
+ parsedFiles: parseMany(project, filePaths),
1753
+ stats: { hits: 0, misses: filePaths.length }
1754
+ };
1755
+ }
1756
+ const configHash = configCacheHash(config);
1757
+ const cached = await loadParsedCache(rootDir, configHash);
1758
+ const trace = process.env.RIPPLE_TRACE === "1";
1759
+ const traceAt = (label, from) => {
1760
+ if (trace) console.error(`[trace] cache.${label}: ${Date.now() - from}ms`);
1761
+ return Date.now();
1762
+ };
1763
+ let stage = Date.now();
1764
+ const states = await Promise.all(
1765
+ filePaths.map(async (abs) => {
1766
+ try {
1767
+ const stat = await fs4.stat(abs);
1768
+ return { abs, rel: relPosix(rootDir, abs), size: stat.size, mtimeMs: stat.mtimeMs };
1769
+ } catch {
1770
+ return { abs, rel: relPosix(rootDir, abs), size: -1, mtimeMs: -1 };
1771
+ }
1772
+ })
1773
+ );
1774
+ stage = traceAt("load-cache", stage);
1775
+ const stateByRel = new Map(states.map((state) => [state.rel, state]));
1776
+ const hashByRel = /* @__PURE__ */ new Map();
1777
+ const hits = /* @__PURE__ */ new Map();
1778
+ const stale = [];
1779
+ const parsed = /* @__PURE__ */ new Map();
1780
+ let dirty = false;
1781
+ for (const state of states) {
1782
+ const entry = cached.get(state.rel);
1783
+ if (entry !== void 0 && entry.size === state.size && entry.mtimeMs === state.mtimeMs && state.mtimeMs > 0) {
1784
+ hits.set(state.rel, entry);
1785
+ parsed.set(state.rel, { ...entry.parsed, path: state.abs });
1786
+ continue;
1787
+ }
1788
+ let hash = "";
1789
+ try {
1790
+ hash = await contentHash(state.abs);
1791
+ } catch {
1792
+ }
1793
+ hashByRel.set(state.rel, hash);
1794
+ if (entry !== void 0 && entry.hash === hash) {
1795
+ if (entry.mtimeMs !== state.mtimeMs || entry.size !== state.size) {
1796
+ hits.set(state.rel, { ...entry, size: state.size, mtimeMs: state.mtimeMs });
1797
+ dirty = true;
1798
+ } else {
1799
+ hits.set(state.rel, entry);
1800
+ }
1801
+ parsed.set(state.rel, { ...entry.parsed, path: state.abs });
1802
+ } else {
1803
+ stale.push(state.abs);
1804
+ dirty = true;
1805
+ }
1806
+ }
1807
+ if (stale.length > 0) {
1808
+ for (const filePath of stale) {
1809
+ project.getSourceFile(filePath)?.forget();
1810
+ }
1811
+ for (const parsedFile of parseMany(project, stale)) {
1812
+ parsed.set(relPosix(rootDir, parsedFile.path), parsedFile);
1813
+ }
1814
+ }
1815
+ traceAt("freshness+parse", stage);
1816
+ if (dirty) {
1817
+ const next = new Map(hits);
1818
+ for (const stalePath of stale) {
1819
+ const rel = relPosix(rootDir, stalePath);
1820
+ const parsedFile = parsed.get(rel);
1821
+ if (parsedFile === void 0) continue;
1822
+ const state = stateByRel.get(rel);
1823
+ next.set(rel, {
1824
+ hash: hashByRel.get(rel) ?? "",
1825
+ size: state?.size ?? -1,
1826
+ mtimeMs: state?.mtimeMs ?? -1,
1827
+ parsed: { ...parsedFile, path: rel }
1828
+ });
1829
+ }
1830
+ await saveParsedCache(rootDir, configHash, next);
1831
+ traceAt("save", stage);
1832
+ }
1833
+ const parsedFiles = filePaths.map((abs) => {
1834
+ const rel = relPosix(rootDir, abs);
1835
+ const parsedFile = parsed.get(rel);
1836
+ if (parsedFile === void 0) {
1837
+ return {
1838
+ path: abs,
1839
+ kind: "ts",
1840
+ imports: [],
1841
+ exports: { named: [], hasDefault: false, reExportedFrom: [], reExportedAll: [] },
1842
+ symbols: { functions: [], classes: [], interfaces: [], enums: [], typeAliases: [] },
1843
+ parseError: "file disappeared during discovery"
1844
+ };
1845
+ }
1846
+ return parsedFile;
1847
+ });
1848
+ return {
1849
+ parsedFiles,
1850
+ stats: { hits: hits.size, misses: stale.length }
1851
+ };
1852
+ }
1853
+
1444
1854
  // src/commands/pipeline.ts
1445
1855
  async function runPipeline(context) {
1446
1856
  const started = Date.now();
1857
+ const trace = process.env.RIPPLE_TRACE === "1";
1858
+ const mark = (label) => {
1859
+ if (trace) console.error(`[trace] pipeline.${label}: ${Date.now() - started}ms`);
1860
+ };
1447
1861
  const project = createTsProject();
1862
+ mark("ts-project");
1448
1863
  const filePaths = await discoverSourceFiles({
1449
1864
  rootDir: context.rootDir,
1450
1865
  include: context.config.include,
1451
1866
  ignore: context.config.ignore
1452
1867
  });
1453
- const graph = buildGraph(project, filePaths, {
1868
+ mark("discover");
1869
+ const { parsedFiles, stats } = await loadParsedFiles({
1870
+ project,
1871
+ rootDir: context.rootDir,
1872
+ filePaths,
1873
+ config: context.config
1874
+ });
1875
+ if (trace) console.error(`[trace] cache: ${stats.hits} hits, ${stats.misses} misses`);
1876
+ mark("parsed");
1877
+ const graph = buildGraphFromParsed(parsedFiles, {
1454
1878
  rootDir: context.rootDir,
1455
1879
  aliases: context.aliases,
1456
1880
  fileKeys: new Set(filePaths.map((p) => pathKey(p, context.rootDir)))
1457
1881
  });
1882
+ mark("graph");
1458
1883
  const entryPoints = await detectEntryPoints(context.rootDir);
1884
+ mark("entry-points");
1459
1885
  return { graph, filePaths, entryPoints, durationMs: Date.now() - started };
1460
1886
  }
1461
1887
  function resolveTargetFile(fileArg, cwd) {
@@ -1475,29 +1901,35 @@ async function analyzeCommand(fileArg, options, ctx2) {
1475
1901
  if (!await pathExists(targetPath)) {
1476
1902
  throw new RippleError(`File not found: ${fileArg}`, ExitCode.NotFound);
1477
1903
  }
1478
- const tracker = createStageTracker(!options.json);
1904
+ const tracker = createStageTracker(!options.json && !options.sarif);
1479
1905
  tracker.next("Loading config");
1480
1906
  const started = Date.now();
1481
1907
  const context = await loadProjectContext(ctx2.cwd, options.config);
1482
1908
  tracker.next("Building dependency graph");
1483
- const { graph, entryPoints, durationMs: pipelineMs } = await runPipeline(context);
1909
+ const { graph, entryPoints } = await runPipeline(context);
1484
1910
  tracker.next("Analyzing impact");
1485
- const completed = Date.now();
1486
1911
  const result = analyzeFile({
1487
1912
  graph,
1488
1913
  context,
1489
1914
  entryPoints,
1490
1915
  targetKey: requireNode(graph, targetPath, context.rootDir).key,
1491
1916
  targetPath,
1492
- durationMs: pipelineMs + (completed - started),
1917
+ durationMs: 0,
1493
1918
  ...options.depth !== void 0 ? { maxDepth: options.depth } : {}
1494
1919
  });
1920
+ result.durationMs = Date.now() - started;
1495
1921
  tracker.done();
1496
1922
  if (options.json) {
1497
1923
  const report = buildAnalyzeJsonReport(result, ctx2.version, ctx2.cwd);
1498
1924
  ctx2.writer.write(serializeJson(report));
1499
1925
  return ExitCode.Success;
1500
1926
  }
1927
+ if (options.sarif) {
1928
+ ctx2.writer.write(
1929
+ serializeJson(buildAnalyzeSarif({ result, cwd: ctx2.cwd, version: ctx2.version }))
1930
+ );
1931
+ return ExitCode.Success;
1932
+ }
1501
1933
  renderAnalyzeReport(
1502
1934
  result,
1503
1935
  {
@@ -1811,7 +2243,11 @@ async function doctorCommand(options, ctx2) {
1811
2243
  results.push({ label, state, detail });
1812
2244
  };
1813
2245
  try {
1814
- check("Node.js runtime", "ok", process.version);
2246
+ check(
2247
+ "Node.js runtime",
2248
+ "ok",
2249
+ options.verbose ? `${process.version} \xB7 ${process.platform}/${process.arch}` : process.version
2250
+ );
1815
2251
  const packageJson = await findPackageJson(ctx2.cwd);
1816
2252
  if (packageJson) {
1817
2253
  check("package.json", "ok", path8.relative(ctx2.cwd, packageJson));
@@ -1875,6 +2311,13 @@ async function doctorCommand(options, ctx2) {
1875
2311
  } else {
1876
2312
  check("circular dependencies", "warn", `${stats.cycles} cycle group(s)`);
1877
2313
  }
2314
+ if (options.verbose) {
2315
+ check(
2316
+ "graph",
2317
+ "ok",
2318
+ `${stats.files} files \xB7 ${stats.internalEdges} internal edges \xB7 ${stats.externalEdges} external \xB7 ${stats.unresolvedEdges} unresolved`
2319
+ );
2320
+ }
1878
2321
  } finally {
1879
2322
  spinner.stop();
1880
2323
  }
@@ -1900,7 +2343,7 @@ function renderResults(results, ctx2, color) {
1900
2343
  }
1901
2344
  ctx2.writer.writeLine();
1902
2345
  }
1903
- var SCHEMA_REF = "./node_modules/@alimaandev/ripple/ripple.schema.json";
2346
+ var SCHEMA_REF = "https://raw.githubusercontent.com/alimaandev/ripple/main/ripple.schema.json";
1904
2347
  function jsonPayload(full) {
1905
2348
  const payload = full ? { ...DEFAULT_CONFIG } : { include: DEFAULT_CONFIG.include };
1906
2349
  return { ...payload, $schema: SCHEMA_REF };
@@ -1952,7 +2395,7 @@ async function initCommand(options, ctx2) {
1952
2395
  const fileName = options.ts ? "ripple.config.ts" : "ripple.config.json";
1953
2396
  const target = path8.join(ctx2.cwd, fileName);
1954
2397
  try {
1955
- await fs.access(target);
2398
+ await fs4.access(target);
1956
2399
  if (!options.force) {
1957
2400
  const mark = style.color ? chalk.red(icon("cross")) : icon("cross");
1958
2401
  ctx2.writer.writeLine(`${mark} ${fileName} already exists. Pass --force to overwrite it.`);
@@ -1963,7 +2406,7 @@ async function initCommand(options, ctx2) {
1963
2406
  const content = options.ts ? tsPayload(Boolean(options.full)) : `${JSON.stringify(jsonPayload(Boolean(options.full)), null, 2)}
1964
2407
  `;
1965
2408
  try {
1966
- await fs.writeFile(target, content, "utf8");
2409
+ await fs4.writeFile(target, content, "utf8");
1967
2410
  const mark = style.color ? chalk.green(icon("tick")) : icon("tick");
1968
2411
  ctx2.writer.writeLine(`${mark} Wrote ${fileName}`);
1969
2412
  ctx2.writer.writeLine(
@@ -1978,22 +2421,54 @@ async function initCommand(options, ctx2) {
1978
2421
  return ExitCode.Failure;
1979
2422
  }
1980
2423
  }
1981
- var DEFAULT_BASES = ["origin/main", "main", "HEAD~1"];
1982
- function git(args, cwd) {
2424
+ var DEFAULT_BASES = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
2425
+ function gitRaw(args, cwd) {
1983
2426
  return execFileSync("git", args, {
1984
2427
  cwd,
1985
2428
  encoding: "utf8",
1986
2429
  stdio: ["ignore", "pipe", "pipe"]
1987
- }).trim();
2430
+ });
2431
+ }
2432
+ function chomp(text) {
2433
+ return text.replace(/\r?\n$/, "");
2434
+ }
2435
+ function git(args, cwd) {
2436
+ return gitRaw(args, cwd).trim();
2437
+ }
2438
+ function repoRootFor(cwd) {
2439
+ try {
2440
+ const root = chomp(gitRaw(["rev-parse", "--show-toplevel"], cwd));
2441
+ if (root) return realPath(root, cwd);
2442
+ } catch {
2443
+ }
2444
+ throw new RippleError(
2445
+ `Not a git repository: ${cwd}`,
2446
+ ExitCode.NotFound,
2447
+ "ripple diff needs a git repository. Run it from inside one, or pass --base only in a repo."
2448
+ );
1988
2449
  }
1989
2450
  function refExists(ref, cwd) {
1990
2451
  try {
1991
- const out = git(["rev-parse", "--verify", ref], cwd);
2452
+ const out = git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd);
1992
2453
  return out.length > 0;
1993
2454
  } catch {
1994
2455
  return false;
1995
2456
  }
1996
2457
  }
2458
+ function sameCommit(left, right, cwd) {
2459
+ try {
2460
+ return git(["rev-parse", `${left}^{commit}`], cwd) === git(["rev-parse", `${right}^{commit}`], cwd);
2461
+ } catch {
2462
+ return false;
2463
+ }
2464
+ }
2465
+ function hasUncommittedChanges(cwd) {
2466
+ try {
2467
+ return git(["status", "--porcelain", "--untracked-files=normal"], cwd).length > 0;
2468
+ } catch {
2469
+ return false;
2470
+ }
2471
+ }
1997
2472
  function resolveBase(cwd, requested) {
1998
2473
  if (requested) {
1999
2474
  if (!refExists(requested, cwd)) {
@@ -2001,37 +2476,77 @@ function resolveBase(cwd, requested) {
2001
2476
  }
2002
2477
  return requested;
2003
2478
  }
2004
- for (const candidate of DEFAULT_BASES) {
2005
- if (refExists(candidate, cwd)) return candidate;
2479
+ const candidates = [];
2480
+ const githubBase = process.env.GITHUB_BASE_REF?.trim();
2481
+ if (githubBase) {
2482
+ candidates.push(`origin/${githubBase}`, githubBase);
2483
+ }
2484
+ candidates.push(...DEFAULT_BASES);
2485
+ const seen = /* @__PURE__ */ new Set();
2486
+ let headCandidate;
2487
+ for (const candidate of candidates) {
2488
+ if (seen.has(candidate)) continue;
2489
+ seen.add(candidate);
2490
+ if (!refExists(candidate, cwd)) continue;
2491
+ if (sameCommit(candidate, "HEAD", cwd)) {
2492
+ headCandidate ??= candidate;
2493
+ continue;
2494
+ }
2495
+ return candidate;
2496
+ }
2497
+ if (headCandidate !== void 0) {
2498
+ const detail = hasUncommittedChanges(cwd) ? "Only uncommitted changes are analyzed" : "The working tree is clean, so the change set is empty";
2499
+ process.stderr.write(
2500
+ `ripple: warning: base ref "${headCandidate}" resolves to HEAD. ${detail} \u2014 pass --base <ref> to compare against a branch.
2501
+ `
2502
+ );
2503
+ return headCandidate;
2504
+ }
2505
+ if (refExists("HEAD~1", cwd)) {
2506
+ process.stderr.write(
2507
+ "ripple: warning: no branch base found; falling back to HEAD~1. Only the last commit will be analyzed \u2014 pass --base <ref> for a full change set.\n"
2508
+ );
2509
+ return "HEAD~1";
2006
2510
  }
2007
2511
  throw new RippleError(
2008
- "No git base ref found (tried origin/main, main, HEAD~1). Pass --base <ref> explicitly.",
2512
+ `No git base ref found (tried ${candidates.join(", ")}). Pass --base <ref> explicitly.`,
2009
2513
  ExitCode.NotFound
2010
2514
  );
2011
2515
  }
2516
+ function nulPaths(output) {
2517
+ return output.split("\0").filter((entry) => entry.length > 0);
2518
+ }
2519
+ function isRippleArtifact(repoPath) {
2520
+ return repoPath.replace(/\\/g, "/").split("/").includes(".ripple");
2521
+ }
2012
2522
  function changedFiles(cwd, base) {
2013
- const baseLabel = resolveBase(cwd, base);
2523
+ const repoRoot = repoRootFor(cwd);
2524
+ const baseLabel = resolveBase(repoRoot, base);
2014
2525
  let baseCommit = baseLabel;
2015
2526
  try {
2016
- const mergeBase = git(["merge-base", baseLabel, "HEAD"], cwd);
2527
+ const mergeBase = git(["merge-base", baseLabel, "HEAD"], repoRoot).split(/\r?\n/)[0]?.trim();
2017
2528
  if (mergeBase) baseCommit = mergeBase;
2018
2529
  } catch {
2019
2530
  }
2020
2531
  const files = /* @__PURE__ */ new Set();
2021
- const tracked = git(["diff", "--name-only", "--diff-filter=ACMRT", baseCommit], cwd);
2022
- for (const line of tracked.split("\n")) {
2023
- if (line.trim()) files.add(line.trim());
2532
+ const tracked = nulPaths(
2533
+ gitRaw(["diff", "--name-only", "-z", "--diff-filter=ACMRT", baseCommit, "--"], repoRoot)
2534
+ );
2535
+ for (const file of tracked) {
2536
+ if (!isRippleArtifact(file)) files.add(file);
2024
2537
  }
2025
- const untracked = git(["ls-files", "--others", "--exclude-standard"], cwd);
2026
- for (const line of untracked.split("\n")) {
2027
- if (line.trim()) files.add(line.trim());
2538
+ const untracked = nulPaths(
2539
+ gitRaw(["ls-files", "--others", "--exclude-standard", "-z"], repoRoot)
2540
+ );
2541
+ for (const file of untracked) {
2542
+ if (!isRippleArtifact(file)) files.add(file);
2028
2543
  }
2029
- return { baseLabel, files: [...files].sort() };
2544
+ return { baseLabel, repoRoot, files: [...files].sort() };
2030
2545
  }
2031
2546
 
2032
2547
  // 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 };
2548
+ var LEVEL_INDEX2 = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
2549
+ var GATE_INDEX2 = { medium: 1, high: 2, critical: 3 };
2035
2550
  function escapeProperty(value) {
2036
2551
  return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A").replace(/,/g, "%2C");
2037
2552
  }
@@ -2044,7 +2559,7 @@ function renderAnnotation(annotation) {
2044
2559
  }
2045
2560
  function buildFileAnnotations(files, gate) {
2046
2561
  return files.map(({ file, result }) => {
2047
- const blocked = LEVEL_INDEX[result.risk.level] >= GATE_INDEX[gate];
2562
+ const blocked = LEVEL_INDEX2[result.risk.level] >= GATE_INDEX2[gate];
2048
2563
  return {
2049
2564
  level: blocked ? "error" : "warning",
2050
2565
  file,
@@ -2054,12 +2569,12 @@ function buildFileAnnotations(files, gate) {
2054
2569
  });
2055
2570
  }
2056
2571
  function buildGateAnnotation(blocked, gate, counts) {
2057
- const gateIndex = GATE_INDEX[gate];
2572
+ const gateIndex = GATE_INDEX2[gate];
2058
2573
  const parts = [];
2059
- if (LEVEL_INDEX.CRITICAL >= gateIndex && counts.critical > 0)
2574
+ if (LEVEL_INDEX2.CRITICAL >= gateIndex && counts.critical > 0)
2060
2575
  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`);
2576
+ if (LEVEL_INDEX2.HIGH >= gateIndex && counts.high > 0) parts.push(`${counts.high} HIGH`);
2577
+ if (LEVEL_INDEX2.MEDIUM >= gateIndex && counts.medium > 0) parts.push(`${counts.medium} MEDIUM`);
2063
2578
  const atGate = parts.length > 0 ? parts.join(" \xB7 ") : "none";
2064
2579
  return {
2065
2580
  level: "notice",
@@ -2067,10 +2582,13 @@ function buildGateAnnotation(blocked, gate, counts) {
2067
2582
  message: blocked ? `Gate blocked: ${atGate} at or above ${gate}` : `Gate passed: nothing at ${gate} or above`
2068
2583
  };
2069
2584
  }
2070
-
2071
- // 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 };
2585
+ var LEVEL_INDEX3 = {
2586
+ LOW: 0,
2587
+ MEDIUM: 1,
2588
+ HIGH: 2,
2589
+ CRITICAL: 3
2590
+ };
2591
+ var GATE_INDEX3 = { medium: 1, high: 2, critical: 3 };
2074
2592
  var VERDICT_NOTE = {
2075
2593
  medium: "no MEDIUM or worse",
2076
2594
  high: "no HIGH or CRITICAL",
@@ -2083,22 +2601,68 @@ function countLevels(results) {
2083
2601
  }
2084
2602
  return counts;
2085
2603
  }
2086
- function isAllowedByDiffAllowlist(relPath, patterns) {
2604
+ function isAllowedByDiffAllowlist(relPath2, patterns) {
2087
2605
  if (patterns.length === 0) return false;
2088
- const rel = toPosix(relPath);
2606
+ const rel = toPosix(relPath2);
2089
2607
  const matcher = picomatch(patterns.map(toPosix), { dot: true });
2090
2608
  return matcher(rel);
2091
2609
  }
2092
2610
  function riskySummary(counts, gate) {
2093
- const gateIndex = GATE_INDEX2[gate];
2611
+ const gateIndex = GATE_INDEX3[gate];
2094
2612
  const parts = [];
2095
- if (LEVEL_INDEX2.CRITICAL >= gateIndex && counts.critical > 0)
2613
+ if (LEVEL_INDEX3.CRITICAL >= gateIndex && counts.critical > 0)
2096
2614
  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`);
2615
+ if (LEVEL_INDEX3.HIGH >= gateIndex && counts.high > 0) parts.push(`${counts.high} HIGH`);
2616
+ if (LEVEL_INDEX3.MEDIUM >= gateIndex && counts.medium > 0) parts.push(`${counts.medium} MEDIUM`);
2617
+ if (LEVEL_INDEX3.LOW >= gateIndex && counts.low > 0) parts.push(`${counts.low} LOW`);
2100
2618
  return parts.length > 0 ? parts.join(" \xB7 ") : "none";
2101
2619
  }
2620
+ function projectRelativePath(absPath, rootDir) {
2621
+ const rel = toPosix(path8.relative(realPath(rootDir, rootDir), realPath(absPath, rootDir)));
2622
+ return rel === "" ? "." : rel;
2623
+ }
2624
+ function evaluateGate(input) {
2625
+ const { context, graph, entryPoints, changed, gate, allowPatterns, maxDepth } = input;
2626
+ const changes = [];
2627
+ const seen = /* @__PURE__ */ new Set();
2628
+ for (const repoPath of changed.files) {
2629
+ const gitPath = path8.resolve(changed.repoRoot, repoPath);
2630
+ const file = projectRelativePath(gitPath, context.rootDir);
2631
+ const absPath = file === "." ? context.rootDir : path8.resolve(context.rootDir, file);
2632
+ const key = pathKey(absPath, context.rootDir);
2633
+ if (seen.has(key)) continue;
2634
+ seen.add(key);
2635
+ if (!graph.nodes.has(key)) {
2636
+ changes.push({ file, repoPath, absPath, allowed: false });
2637
+ continue;
2638
+ }
2639
+ const result = analyzeFile({
2640
+ graph,
2641
+ context,
2642
+ entryPoints,
2643
+ targetKey: key,
2644
+ targetPath: absPath,
2645
+ durationMs: 0,
2646
+ ...maxDepth !== void 0 ? { maxDepth } : {}
2647
+ });
2648
+ changes.push({
2649
+ file,
2650
+ repoPath,
2651
+ absPath,
2652
+ result,
2653
+ allowed: isAllowedByDiffAllowlist(file, allowPatterns)
2654
+ });
2655
+ }
2656
+ const entries = changes.filter((change) => change.result !== void 0).sort((a, b) => b.result.risk.score - a.result.risk.score || a.file.localeCompare(b.file));
2657
+ const skipped = changes.filter((change) => change.result === void 0);
2658
+ const gateResults = entries.filter((entry) => !entry.allowed).map((entry) => entry.result);
2659
+ const counts = countLevels(gateResults);
2660
+ const allowedCount = entries.filter((entry) => entry.allowed).length;
2661
+ const blocked = gateResults.some((result) => LEVEL_INDEX3[result.risk.level] >= GATE_INDEX3[gate]);
2662
+ return { changes, entries, skipped, counts, allowedCount, blocked, gate };
2663
+ }
2664
+
2665
+ // src/commands/diff.ts
2102
2666
  async function diffCommand(options, ctx2) {
2103
2667
  const format = options.format ?? (options.json ? "json" : "terminal");
2104
2668
  const style = { color: resolveColor(options.color) };
@@ -2111,46 +2675,30 @@ async function diffCommand(options, ctx2) {
2111
2675
  const changed = changedFiles(ctx2.cwd, base);
2112
2676
  tracker.next("Analyzing changed files");
2113
2677
  const { graph, entryPoints } = await runPipeline(context);
2114
- const byRel = /* @__PURE__ */ new Map();
2115
- const skipped = [];
2116
- for (const rel of changed.files) {
2117
- const abs = path8.resolve(ctx2.cwd, rel);
2118
- const key = pathKey(abs, context.rootDir);
2119
- if (!graph.nodes.has(key)) {
2120
- skipped.push(rel);
2121
- continue;
2122
- }
2123
- byRel.set(
2124
- rel,
2125
- analyzeFile({
2126
- graph,
2127
- context,
2128
- entryPoints,
2129
- targetKey: key,
2130
- targetPath: abs,
2131
- durationMs: 0,
2132
- ...options.depth !== void 0 ? { maxDepth: options.depth } : {}
2133
- })
2134
- );
2135
- }
2136
- tracker.done();
2137
2678
  const allowPatterns = [...options.allow ?? [], ...context.config.diff.allow ?? []];
2138
- const allowedByRel = /* @__PURE__ */ new Map();
2139
- for (const rel of byRel.keys()) {
2140
- allowedByRel.set(rel, isAllowedByDiffAllowlist(rel, allowPatterns));
2141
- }
2142
- 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);
2143
- const gateResults = entries.filter((entry) => !entry.allowed).map((entry) => entry.result);
2144
- const counts = countLevels(gateResults);
2145
- const allowedCount = entries.filter((entry) => entry.allowed).length;
2146
- const blocked = gateResults.some((result) => LEVEL_INDEX2[result.risk.level] >= GATE_INDEX2[gate]);
2679
+ const evaluation = evaluateGate({
2680
+ context,
2681
+ graph,
2682
+ entryPoints,
2683
+ changed,
2684
+ gate,
2685
+ allowPatterns,
2686
+ ...options.depth !== void 0 ? { maxDepth: options.depth } : {}
2687
+ });
2688
+ tracker.done();
2689
+ const { changes, entries, skipped, counts, allowedCount, blocked } = evaluation;
2690
+ const byRel = new Map(entries.map((entry) => [entry.file, entry.result]));
2691
+ const allowedByRel = new Map(
2692
+ entries.map((entry) => [entry.file, entry.allowed])
2693
+ );
2694
+ const changedNames = changes.map((change) => change.file);
2147
2695
  const durationMs = Date.now() - started;
2148
2696
  if (format === "json") {
2149
2697
  ctx2.writer.write(
2150
2698
  serializeJson(
2151
2699
  buildDiffJsonReport(
2152
2700
  changed.baseLabel,
2153
- changed.files,
2701
+ changedNames,
2154
2702
  byRel,
2155
2703
  allowedByRel,
2156
2704
  gate,
@@ -2164,10 +2712,31 @@ async function diffCommand(options, ctx2) {
2164
2712
  );
2165
2713
  return blocked ? ExitCode.Failure : ExitCode.Success;
2166
2714
  }
2715
+ if (format === "sarif") {
2716
+ ctx2.writer.write(
2717
+ serializeJson(
2718
+ buildDiffSarif({
2719
+ baseLabel: changed.baseLabel,
2720
+ entries: entries.map((entry) => ({
2721
+ rel: entry.file,
2722
+ repoRel: entry.repoPath,
2723
+ result: entry.result,
2724
+ allowed: entry.allowed
2725
+ })),
2726
+ gate,
2727
+ counts,
2728
+ blocked,
2729
+ durationMs,
2730
+ version: ctx2.version
2731
+ })
2732
+ )
2733
+ );
2734
+ return blocked ? ExitCode.Failure : ExitCode.Success;
2735
+ }
2167
2736
  if (format === "github") {
2168
2737
  const annotations = [
2169
2738
  ...buildFileAnnotations(
2170
- [...byRel.entries()].filter(([rel]) => !(allowedByRel.get(rel) ?? false)).map(([file, result]) => ({ file, result })),
2739
+ entries.filter((entry) => !entry.allowed).map((entry) => ({ file: entry.repoPath, result: entry.result })),
2171
2740
  gate
2172
2741
  ),
2173
2742
  buildGateAnnotation(blocked, gate, counts)
@@ -2180,15 +2749,20 @@ async function diffCommand(options, ctx2) {
2180
2749
  {
2181
2750
  cwd: ctx2.cwd,
2182
2751
  baseLabel: changed.baseLabel,
2183
- files: changed.files,
2184
- entries,
2185
- skipped,
2752
+ files: changedNames,
2753
+ entries: entries.map((entry) => ({
2754
+ rel: entry.file,
2755
+ result: entry.result,
2756
+ allowed: entry.allowed
2757
+ })),
2758
+ skipped: skipped.map((change) => change.file),
2186
2759
  counts,
2187
2760
  allowedCount,
2188
2761
  gate,
2189
2762
  blocked,
2190
2763
  durationMs,
2191
- style
2764
+ style,
2765
+ verbose: options.verbose
2192
2766
  },
2193
2767
  ctx2.writer
2194
2768
  );
@@ -2206,7 +2780,8 @@ function renderDiffReport(input, writer) {
2206
2780
  gate,
2207
2781
  blocked,
2208
2782
  durationMs,
2209
- style
2783
+ style,
2784
+ verbose = false
2210
2785
  } = input;
2211
2786
  writer.writeLine(brandHeader({ label: `diff vs ${baseLabel}`, style }));
2212
2787
  writer.writeLine();
@@ -2232,7 +2807,7 @@ function renderDiffReport(input, writer) {
2232
2807
  writer.writeLine();
2233
2808
  writer.writeLine(sectionHeader(`Risk analysis (${entries.length} files)`, style));
2234
2809
  for (const { result, allowed } of entries) {
2235
- const risky = LEVEL_INDEX2[result.risk.level] >= GATE_INDEX2[gate];
2810
+ const risky = LEVEL_INDEX3[result.risk.level] >= GATE_INDEX3[gate];
2236
2811
  const fileIcon = risky ? icon("cross") : icon("tick");
2237
2812
  const fileIconText = style.color ? risky ? chalk.red(fileIcon) : chalk.green(fileIcon) : fileIcon;
2238
2813
  const score = result.risk.score.toFixed(1);
@@ -2242,6 +2817,14 @@ function renderDiffReport(input, writer) {
2242
2817
  writer.writeLine(
2243
2818
  `${fileIconText} ${file} ${riskBadge(result.risk.level, style)} \xB7 ${score}/100${gauge ? ` ${gauge}` : ""}${allowedTag}`
2244
2819
  );
2820
+ if (verbose) {
2821
+ writer.writeLine(
2822
+ ` ${dim(
2823
+ `${pluralize(result.summary.affectedFiles, "file")} affected \xB7 max depth ${result.summary.maxDepth} \xB7 confidence ${result.summary.confidence}%`,
2824
+ style
2825
+ )}`
2826
+ );
2827
+ }
2245
2828
  }
2246
2829
  for (const rel of skipped) {
2247
2830
  writer.writeLine(
@@ -2280,6 +2863,438 @@ function buildDiffJsonReport(baseLabel, changedFileNames, byRel, allowedByRel, g
2280
2863
  };
2281
2864
  }
2282
2865
 
2866
+ // src/mcp/server.ts
2867
+ var PROTOCOL_VERSION = "2025-06-18";
2868
+ var ERR_PARSE = -32700;
2869
+ var ERR_INVALID_REQUEST = -32600;
2870
+ var ERR_INTERNAL = -32603;
2871
+ function isObject(value) {
2872
+ return typeof value === "object" && value !== null;
2873
+ }
2874
+ var McpServer = class {
2875
+ options;
2876
+ tools = /* @__PURE__ */ new Map();
2877
+ constructor(options) {
2878
+ this.options = options;
2879
+ for (const tool of options.tools) {
2880
+ this.tools.set(tool.name, tool);
2881
+ }
2882
+ }
2883
+ /**
2884
+ * Process one inbound message. Returns the JSON-RPC response line to write
2885
+ * back, or `null` for notifications and invalid requests.
2886
+ */
2887
+ async handleLine(line) {
2888
+ let message;
2889
+ try {
2890
+ message = JSON.parse(line);
2891
+ } catch {
2892
+ return this.response(void 0, {
2893
+ code: ERR_PARSE,
2894
+ message: "Parse error: not valid JSON"
2895
+ });
2896
+ }
2897
+ if (!isObject(message) || message.jsonrpc !== "2.0") {
2898
+ return this.response(void 0, {
2899
+ code: ERR_INVALID_REQUEST,
2900
+ message: "Invalid Request: expected a JSON-RPC 2.0 object"
2901
+ });
2902
+ }
2903
+ const hasId = "id" in message;
2904
+ if (message.method === "notifications/initialized") {
2905
+ return null;
2906
+ }
2907
+ if (!hasId) {
2908
+ return null;
2909
+ }
2910
+ const method = message.method;
2911
+ if (typeof method !== "string") {
2912
+ return this.response(message.id, {
2913
+ code: ERR_INVALID_REQUEST,
2914
+ message: "Invalid Request: method must be a string"
2915
+ });
2916
+ }
2917
+ try {
2918
+ const result = await this.dispatch(method, message.params);
2919
+ return this.response(message.id, result);
2920
+ } catch (error) {
2921
+ return this.response(message.id, {
2922
+ code: ERR_INTERNAL,
2923
+ message: error instanceof Error ? error.message : "Internal error"
2924
+ });
2925
+ }
2926
+ }
2927
+ async dispatch(method, params) {
2928
+ switch (method) {
2929
+ case "initialize":
2930
+ return {
2931
+ protocolVersion: this.protocolVersion(params),
2932
+ capabilities: { tools: { listChanged: false } },
2933
+ serverInfo: { name: this.options.name, version: this.options.version }
2934
+ };
2935
+ case "ping":
2936
+ return {};
2937
+ case "tools/list":
2938
+ return {
2939
+ tools: [...this.tools.values()].map((tool) => ({
2940
+ name: tool.name,
2941
+ description: tool.description,
2942
+ inputSchema: tool.inputSchema
2943
+ }))
2944
+ };
2945
+ case "tools/call":
2946
+ return this.callTool(params);
2947
+ case "tools/list_changed":
2948
+ throw new Error("Tool list changes are not supported by this server");
2949
+ default:
2950
+ throw new Error(`Method not found: ${method}`);
2951
+ }
2952
+ }
2953
+ protocolVersion(params) {
2954
+ if (isObject(params) && typeof params.protocolVersion === "string") {
2955
+ return params.protocolVersion;
2956
+ }
2957
+ return PROTOCOL_VERSION;
2958
+ }
2959
+ async callTool(params) {
2960
+ if (!isObject(params) || typeof params.name !== "string") {
2961
+ throw new Error("Invalid params: expected { name: string, arguments?: object }");
2962
+ }
2963
+ const tool = this.tools.get(params.name);
2964
+ if (!tool) {
2965
+ throw new Error(`Unknown tool: ${params.name}`);
2966
+ }
2967
+ const args = isObject(params.arguments) || params.arguments === void 0 ? params.arguments ?? {} : null;
2968
+ if (args === null) {
2969
+ throw new Error(`Invalid arguments for tool ${params.name}: expected an object`);
2970
+ }
2971
+ try {
2972
+ const result = await tool.handler(args);
2973
+ return {
2974
+ content: [{ type: "text", text: result.text }],
2975
+ ...result.isError ? { isError: true } : {}
2976
+ };
2977
+ } catch (error) {
2978
+ return {
2979
+ content: [
2980
+ {
2981
+ type: "text",
2982
+ text: error instanceof Error ? error.message : "Tool failed"
2983
+ }
2984
+ ],
2985
+ isError: true
2986
+ };
2987
+ }
2988
+ }
2989
+ response(id, payload) {
2990
+ if (typeof payload === "object" && payload !== null && "code" in payload && typeof payload.code === "number") {
2991
+ const errorPayload = payload;
2992
+ const error = { code: errorPayload.code, message: String(errorPayload.message) };
2993
+ return `${JSON.stringify({ jsonrpc: "2.0", id: id ?? null, error })}
2994
+ `;
2995
+ }
2996
+ return `${JSON.stringify({ jsonrpc: "2.0", id: id ?? null, result: payload })}
2997
+ `;
2998
+ }
2999
+ };
3000
+ var MAX_DEPENDENTS = 200;
3001
+ function ok(text) {
3002
+ return { text };
3003
+ }
3004
+ function fail(error) {
3005
+ return { text: error instanceof Error ? error.message : "Tool failed", isError: true };
3006
+ }
3007
+ function requireString(args, key, tool) {
3008
+ const value = args[key];
3009
+ if (typeof value !== "string" || value.trim() === "") {
3010
+ throw new RippleError(`Missing required argument "${key}" for ${tool}`, ExitCode.Failure);
3011
+ }
3012
+ return value;
3013
+ }
3014
+ function optionalPositiveInt(args, key, tool) {
3015
+ const value = args[key];
3016
+ if (value === void 0) return void 0;
3017
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
3018
+ throw new RippleError(
3019
+ `Argument "${key}" for ${tool} must be a positive integer`,
3020
+ ExitCode.Failure
3021
+ );
3022
+ }
3023
+ return value;
3024
+ }
3025
+ function resolveFile(cwd, file) {
3026
+ return path8.isAbsolute(file) ? file : path8.join(cwd, file);
3027
+ }
3028
+ function analyzeIn(project, cwd, file, maxDepth) {
3029
+ const { context, graph, entryPoints } = project;
3030
+ const targetPath = resolveFile(cwd, file);
3031
+ const key = pathKey(targetPath, context.rootDir);
3032
+ const node = graph.nodes.get(key);
3033
+ if (!node) {
3034
+ throw new RippleError(
3035
+ `File not found in the project graph: ${file}. Is it a source file covered by ripple's discovery?`,
3036
+ ExitCode.NotFound
3037
+ );
3038
+ }
3039
+ return analyzeFile({
3040
+ graph,
3041
+ context,
3042
+ entryPoints,
3043
+ targetKey: key,
3044
+ targetPath,
3045
+ durationMs: 0,
3046
+ ...maxDepth !== void 0 ? { maxDepth } : {}
3047
+ });
3048
+ }
3049
+ function relPath(targetPath, cwd) {
3050
+ const rel = path8.relative(cwd, targetPath);
3051
+ return toPosix(rel || path8.basename(targetPath));
3052
+ }
3053
+ var IMPACT_SCHEMA = {
3054
+ type: "object",
3055
+ properties: {
3056
+ file: {
3057
+ type: "string",
3058
+ description: "Project-relative or absolute path of the file to analyze."
3059
+ },
3060
+ maxDepth: {
3061
+ type: "integer",
3062
+ minimum: 1,
3063
+ description: "Cap the reverse traversal depth (default: unlimited)."
3064
+ }
3065
+ },
3066
+ required: ["file"]
3067
+ };
3068
+ var DEPENDENTS_SCHEMA = {
3069
+ type: "object",
3070
+ properties: {
3071
+ file: {
3072
+ type: "string",
3073
+ description: "Project-relative or absolute path of the file."
3074
+ },
3075
+ depth: {
3076
+ type: "integer",
3077
+ minimum: 1,
3078
+ description: "Max dependency depth to report (default: 1, direct dependents only)."
3079
+ }
3080
+ },
3081
+ required: ["file"]
3082
+ };
3083
+ var RISK_SCHEMA = {
3084
+ type: "object",
3085
+ properties: {
3086
+ file: {
3087
+ type: "string",
3088
+ description: "Project-relative or absolute path of the file."
3089
+ }
3090
+ },
3091
+ required: ["file"]
3092
+ };
3093
+ var GATE_SCHEMA = {
3094
+ type: "object",
3095
+ properties: {
3096
+ base: {
3097
+ type: "string",
3098
+ description: "Git ref to diff against (default: GitHub base, origin/main, origin/master, main, master)."
3099
+ },
3100
+ gate: {
3101
+ type: "string",
3102
+ enum: ["medium", "high", "critical"],
3103
+ description: "Risk level that blocks the merge (default: high)."
3104
+ }
3105
+ }
3106
+ };
3107
+ function createMcpTools(deps) {
3108
+ const { cwd, loadProject, getChanged } = deps;
3109
+ return [
3110
+ {
3111
+ name: "impact",
3112
+ 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.",
3113
+ inputSchema: IMPACT_SCHEMA,
3114
+ handler: async (args) => {
3115
+ try {
3116
+ const file = requireString(args, "file", "impact");
3117
+ const maxDepth = optionalPositiveInt(args, "maxDepth", "impact");
3118
+ const project = await loadProject();
3119
+ const result = analyzeIn(project, cwd, file, maxDepth);
3120
+ return ok(
3121
+ JSON.stringify(
3122
+ {
3123
+ file: relPath(result.targetPath, cwd),
3124
+ risk: { score: result.risk.score, level: result.risk.level },
3125
+ summary: result.summary,
3126
+ targetInCycle: result.targetInCycle
3127
+ },
3128
+ null,
3129
+ 2
3130
+ )
3131
+ );
3132
+ } catch (error) {
3133
+ return fail(error);
3134
+ }
3135
+ }
3136
+ },
3137
+ {
3138
+ name: "dependents",
3139
+ 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.",
3140
+ inputSchema: DEPENDENTS_SCHEMA,
3141
+ handler: async (args) => {
3142
+ try {
3143
+ const file = requireString(args, "file", "dependents");
3144
+ const depth = optionalPositiveInt(args, "depth", "dependents") ?? 1;
3145
+ const project = await loadProject();
3146
+ const result = analyzeIn(project, cwd, file, depth);
3147
+ const dependents = [...result.affected.values()].filter((affected) => affected.depth <= depth).sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path)).map((affected) => ({
3148
+ path: relPath(affected.path, cwd),
3149
+ depth: affected.depth,
3150
+ direct: affected.direct,
3151
+ inCycle: affected.inCycle,
3152
+ categories: affected.categories
3153
+ }));
3154
+ return ok(
3155
+ JSON.stringify(
3156
+ {
3157
+ file: relPath(result.targetPath, cwd),
3158
+ depth,
3159
+ count: dependents.length,
3160
+ truncated: dependents.length > MAX_DEPENDENTS,
3161
+ dependents: dependents.slice(0, MAX_DEPENDENTS)
3162
+ },
3163
+ null,
3164
+ 2
3165
+ )
3166
+ );
3167
+ } catch (error) {
3168
+ return fail(error);
3169
+ }
3170
+ }
3171
+ },
3172
+ {
3173
+ name: "risk",
3174
+ 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.",
3175
+ inputSchema: RISK_SCHEMA,
3176
+ handler: async (args) => {
3177
+ try {
3178
+ const file = requireString(args, "file", "risk");
3179
+ const project = await loadProject();
3180
+ const result = analyzeIn(project, cwd, file);
3181
+ return ok(
3182
+ JSON.stringify(
3183
+ {
3184
+ file: relPath(result.targetPath, cwd),
3185
+ score: result.risk.score,
3186
+ level: result.risk.level,
3187
+ targetInCycle: result.targetInCycle,
3188
+ factors: result.risk.factors
3189
+ },
3190
+ null,
3191
+ 2
3192
+ )
3193
+ );
3194
+ } catch (error) {
3195
+ return fail(error);
3196
+ }
3197
+ }
3198
+ },
3199
+ {
3200
+ name: "gate_status",
3201
+ 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.",
3202
+ inputSchema: GATE_SCHEMA,
3203
+ handler: async (args) => {
3204
+ try {
3205
+ const base = args.base;
3206
+ const gate = args.gate;
3207
+ if (base !== void 0 && typeof base !== "string") {
3208
+ return fail(
3209
+ new RippleError('Argument "base" for gate_status must be a string', ExitCode.Failure)
3210
+ );
3211
+ }
3212
+ if (gate !== void 0 && gate !== "medium" && gate !== "high" && gate !== "critical") {
3213
+ return fail(
3214
+ new RippleError(
3215
+ 'Argument "gate" for gate_status must be medium | high | critical',
3216
+ ExitCode.Failure
3217
+ )
3218
+ );
3219
+ }
3220
+ const project = await loadProject();
3221
+ const resolvedGate = gate ?? project.context.config.diff.gate ?? "high";
3222
+ const changed = getChanged(base ?? project.context.config.diff.base);
3223
+ const evaluation = evaluateGate({
3224
+ context: project.context,
3225
+ graph: project.graph,
3226
+ entryPoints: project.entryPoints,
3227
+ changed,
3228
+ gate: resolvedGate,
3229
+ allowPatterns: project.context.config.diff.allow ?? []
3230
+ });
3231
+ const files = evaluation.changes.map((change) => {
3232
+ if (!change.result) return { file: change.file, analyzed: false };
3233
+ return {
3234
+ file: change.file,
3235
+ analyzed: true,
3236
+ level: change.result.risk.level,
3237
+ score: change.result.risk.score,
3238
+ affectedFiles: change.result.summary.affectedFiles,
3239
+ targetInCycle: change.result.targetInCycle,
3240
+ allowed: change.allowed
3241
+ };
3242
+ });
3243
+ return ok(
3244
+ JSON.stringify(
3245
+ {
3246
+ base: changed.baseLabel,
3247
+ changedFiles: evaluation.changes.length,
3248
+ files,
3249
+ counts: evaluation.counts,
3250
+ gate: {
3251
+ level: resolvedGate,
3252
+ blocked: evaluation.blocked,
3253
+ verdict: evaluation.blocked ? "block" : "pass",
3254
+ allowed: evaluation.allowedCount
3255
+ }
3256
+ },
3257
+ null,
3258
+ 2
3259
+ )
3260
+ );
3261
+ } catch (error) {
3262
+ return fail(error);
3263
+ }
3264
+ }
3265
+ }
3266
+ ];
3267
+ }
3268
+
3269
+ // src/commands/mcp.ts
3270
+ async function mcpCommand(options, ctx2) {
3271
+ const loadProject = async () => {
3272
+ const context = await loadProjectContext(ctx2.cwd, options.config);
3273
+ const { graph, entryPoints } = await runPipeline(context);
3274
+ return { context, graph, entryPoints };
3275
+ };
3276
+ const server = new McpServer({
3277
+ name: "ripple",
3278
+ version: ctx2.version,
3279
+ tools: createMcpTools({
3280
+ cwd: ctx2.cwd,
3281
+ loadProject,
3282
+ getChanged: (base) => changedFiles(ctx2.cwd, base)
3283
+ })
3284
+ });
3285
+ const stdin = createInterface({ input: process.stdin, crlfDelay: Infinity });
3286
+ await new Promise((resolve) => {
3287
+ stdin.on("line", (line) => {
3288
+ if (!line.trim()) return;
3289
+ void server.handleLine(line).then((response) => {
3290
+ if (response) process.stdout.write(response);
3291
+ });
3292
+ });
3293
+ stdin.on("close", () => resolve());
3294
+ });
3295
+ return ExitCode.Success;
3296
+ }
3297
+
2283
3298
  // src/cli/program.ts
2284
3299
  var PROGRAM_NAME = "ripple";
2285
3300
  function helpExamples(examples) {
@@ -2297,11 +3312,12 @@ function createProgram(ctx2) {
2297
3312
  "ripple analyze src/authentication/login.ts",
2298
3313
  "ripple analyze src/index.ts --depth 3"
2299
3314
  ])
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) => {
3315
+ ).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
3316
  const exitCode2 = await analyzeCommand(
2302
3317
  file,
2303
3318
  {
2304
3319
  json: Boolean(options.json),
3320
+ sarif: Boolean(options.sarif),
2305
3321
  verbose: Boolean(options.verbose),
2306
3322
  ...options.depth !== void 0 ? { depth: options.depth } : {},
2307
3323
  ...options.config !== void 0 ? { config: options.config } : {},
@@ -2345,7 +3361,14 @@ function createProgram(ctx2) {
2345
3361
  program.command("diff").description("Analyze changed files since a base ref and gate risky changes").addHelpText(
2346
3362
  "after",
2347
3363
  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(
3364
+ ).option("-j, --json", "emit a machine-readable JSON report").option(
3365
+ "-f, --format <format>",
3366
+ "output format: terminal | json | github | sarif",
3367
+ parseDiffFormat
3368
+ ).option("-v, --verbose", "include extra sections").option(
3369
+ "-b, --base <ref>",
3370
+ "git ref to diff against (default: GitHub base, origin/HEAD, origin/main, origin/master, main, master)"
3371
+ ).option(
2349
3372
  "-g, --gate <level>",
2350
3373
  "risk level that blocks the gate (medium | high | critical)",
2351
3374
  parseGateLevel
@@ -2395,6 +3418,20 @@ function createProgram(ctx2) {
2395
3418
  throw new ExitError(exitCode2);
2396
3419
  }
2397
3420
  });
3421
+ program.command("mcp").description("Serve Ripple analysis tools over the Model Context Protocol (stdio)").addHelpText(
3422
+ "after",
3423
+ helpExamples(['ripple mcp # point an MCP client (Claude, Cursor, ...) at "ripple mcp"'])
3424
+ ).option("-c, --config <path>", "path to a ripple config file").action(async (options) => {
3425
+ const exitCode2 = await mcpCommand(
3426
+ {
3427
+ config: options.config
3428
+ },
3429
+ ctx2
3430
+ );
3431
+ if (exitCode2 !== ExitCode.Success) {
3432
+ throw new ExitError(exitCode2);
3433
+ }
3434
+ });
2398
3435
  program.command("version").description("Print the Ripple version").addHelpText("after", helpExamples(["ripple version"])).action(() => {
2399
3436
  ctx2.writer.writeLine(ctx2.version);
2400
3437
  });
@@ -2435,13 +3472,13 @@ function parseGateLevel(value) {
2435
3472
  );
2436
3473
  }
2437
3474
  function parseDiffFormat(value) {
2438
- if (value === "terminal" || value === "json" || value === "github") {
3475
+ if (value === "terminal" || value === "json" || value === "github" || value === "sarif") {
2439
3476
  return value;
2440
3477
  }
2441
3478
  throw new CommanderError(
2442
3479
  1,
2443
3480
  "commander.invalidArgument",
2444
- `expected one of terminal | json | github, got "${value}"`
3481
+ `expected one of terminal | json | github | sarif, got "${value}"`
2445
3482
  );
2446
3483
  }
2447
3484
  function parseGraphFormat(value) {
@@ -2485,10 +3522,6 @@ async function run({ argv, ctx: ctx2 }) {
2485
3522
  }
2486
3523
  }
2487
3524
 
2488
- // package.json
2489
- var package_default = {
2490
- version: "0.6.0"};
2491
-
2492
3525
  // src/cli/bin.ts
2493
3526
  var ctx = {
2494
3527
  writer: new TerminalWriter(),