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