@alimaandev/ripple 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -48
- package/dist/bin.js +1225 -95
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-LDCFNORX.js → chunk-CFH4UW2E.js} +8 -4
- package/dist/chunk-CFH4UW2E.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1 -1
- package/package.json +13 -6
- package/ripple.schema.json +123 -0
- package/dist/chunk-LDCFNORX.js.map +0 -1
package/dist/bin.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { rippleConfigSchema, DEFAULT_CONFIG, formatSchemaIssues } from './chunk-CFH4UW2E.js';
|
|
3
3
|
import { CommanderError, Command } from 'commander';
|
|
4
4
|
import { createJiti } from 'jiti';
|
|
5
5
|
import path8 from 'path';
|
|
6
|
-
import
|
|
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
|
|
12
|
+
import { createHash } from 'crypto';
|
|
13
13
|
import fg from 'fast-glob';
|
|
14
14
|
import ora from 'ora';
|
|
15
|
+
import picomatch from 'picomatch';
|
|
15
16
|
import { execFileSync } from 'child_process';
|
|
17
|
+
import { createInterface } from 'readline';
|
|
16
18
|
|
|
17
19
|
// src/output/writer.ts
|
|
18
20
|
var TerminalWriter = class {
|
|
@@ -38,7 +40,7 @@ var ExitCode = {
|
|
|
38
40
|
};
|
|
39
41
|
async function pathExists(filePath) {
|
|
40
42
|
try {
|
|
41
|
-
await
|
|
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
|
|
|
@@ -507,7 +513,7 @@ function reverseTraverse(graph, targetKey, options) {
|
|
|
507
513
|
const { maxDepth, categorize } = options;
|
|
508
514
|
const affected = /* @__PURE__ */ new Map();
|
|
509
515
|
const visited = /* @__PURE__ */ new Set([targetKey]);
|
|
510
|
-
const
|
|
516
|
+
const cycleMembers2 = cycleMemberPaths(graph);
|
|
511
517
|
if (!graph.reverse.has(targetKey)) {
|
|
512
518
|
return { affected, maxDepth: 0 };
|
|
513
519
|
}
|
|
@@ -529,7 +535,7 @@ function reverseTraverse(graph, targetKey, options) {
|
|
|
529
535
|
depth: current.depth,
|
|
530
536
|
direct: current.depth === 1,
|
|
531
537
|
categories: categorize(filePath),
|
|
532
|
-
inCycle:
|
|
538
|
+
inCycle: cycleMembers2.has(current.key)
|
|
533
539
|
});
|
|
534
540
|
if (maxDepth === void 0 || current.depth < maxDepth) {
|
|
535
541
|
for (const importer of graph.reverse.get(current.key) ?? []) {
|
|
@@ -786,24 +792,17 @@ function renderTree(nodes) {
|
|
|
786
792
|
}
|
|
787
793
|
return treeify.asTree(root, false, false).trimEnd();
|
|
788
794
|
}
|
|
789
|
-
var DIVIDER_WIDTH = 38;
|
|
790
795
|
function brandHeader(options) {
|
|
791
796
|
const { label, version, style } = options;
|
|
792
797
|
if (!style.color) {
|
|
793
798
|
return `ripple \u2014 ${label}${version ? ` \xB7 v${version}` : ""}`;
|
|
794
799
|
}
|
|
795
|
-
const
|
|
796
|
-
return
|
|
797
|
-
padding: { left: 1, right: 1 },
|
|
798
|
-
title: "ripple",
|
|
799
|
-
borderStyle: "round",
|
|
800
|
-
borderColor: "cyan"
|
|
801
|
-
});
|
|
800
|
+
const title = chalk.bold.cyan(`ripple \xB7 ${label}`);
|
|
801
|
+
return `${title}${version ? ` ${chalk.dim(`\xB7 v${version}`)}` : ""}`;
|
|
802
802
|
}
|
|
803
803
|
function sectionHeader(title, style) {
|
|
804
804
|
if (!style.color) return title;
|
|
805
|
-
return `${chalk.dim("\
|
|
806
|
-
${chalk.bold(title)}`;
|
|
805
|
+
return `${chalk.dim("\u203A")} ${chalk.bold(title)}`;
|
|
807
806
|
}
|
|
808
807
|
|
|
809
808
|
// src/ui/gauge.ts
|
|
@@ -981,12 +980,12 @@ function renderGraphReport(input, options, writer) {
|
|
|
981
980
|
writer.writeLine();
|
|
982
981
|
if (input.forward) {
|
|
983
982
|
writer.writeLine(sectionHeader("Dependants (forward)", style));
|
|
984
|
-
writer.writeLine(renderTree(input.forward.map((
|
|
983
|
+
writer.writeLine(renderTree(input.forward.map((path15) => ({ label: path15 }))));
|
|
985
984
|
writer.writeLine();
|
|
986
985
|
}
|
|
987
986
|
if (input.reverse) {
|
|
988
987
|
writer.writeLine(sectionHeader("Dependents (reverse)", style));
|
|
989
|
-
writer.writeLine(renderTree(input.reverse.map((
|
|
988
|
+
writer.writeLine(renderTree(input.reverse.map((path15) => ({ label: path15 }))));
|
|
990
989
|
writer.writeLine();
|
|
991
990
|
}
|
|
992
991
|
writer.writeLine(sectionHeader("Circular dependencies", style));
|
|
@@ -1025,6 +1024,158 @@ function serializeJson(report) {
|
|
|
1025
1024
|
return `${JSON.stringify(report, null, 2)}
|
|
1026
1025
|
`;
|
|
1027
1026
|
}
|
|
1027
|
+
var LEVEL_INDEX = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
|
|
1028
|
+
var GATE_INDEX = { medium: 1, high: 2, critical: 3 };
|
|
1029
|
+
var INFORMATION_URI = "https://github.com/alimaandev/ripple";
|
|
1030
|
+
function riskToSarifLevel(level) {
|
|
1031
|
+
switch (level) {
|
|
1032
|
+
case "CRITICAL":
|
|
1033
|
+
case "HIGH":
|
|
1034
|
+
return "error";
|
|
1035
|
+
case "MEDIUM":
|
|
1036
|
+
return "warning";
|
|
1037
|
+
default:
|
|
1038
|
+
return "note";
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
function primaryLocationHash(ruleId, uri, score) {
|
|
1042
|
+
return createHash("sha256").update(`${ruleId}|${uri}|${score.toFixed(1)}`).digest("hex");
|
|
1043
|
+
}
|
|
1044
|
+
var RISK_RULE = {
|
|
1045
|
+
id: "ripple/risk",
|
|
1046
|
+
name: "riskScore",
|
|
1047
|
+
shortDescription: {
|
|
1048
|
+
text: "Risk of changing a file's blast radius"
|
|
1049
|
+
},
|
|
1050
|
+
fullDescription: {
|
|
1051
|
+
text: "Ripple scores how risky a change to a file is \u2014 its impact area, affected files, and circular-dependency membership \u2014 and gates merges on the resulting level."
|
|
1052
|
+
},
|
|
1053
|
+
defaultConfiguration: { level: "warning" },
|
|
1054
|
+
properties: { tags: ["ripple", "impact-analysis"] }
|
|
1055
|
+
};
|
|
1056
|
+
function fileResult(entry, gate) {
|
|
1057
|
+
const { rel, result, allowed } = entry;
|
|
1058
|
+
const level = allowed ? "note" : riskToSarifLevel(result.risk.level);
|
|
1059
|
+
const blocked = LEVEL_INDEX[result.risk.level] >= GATE_INDEX[gate];
|
|
1060
|
+
const score = result.risk.score.toFixed(1);
|
|
1061
|
+
const affected = result.summary.affectedFiles;
|
|
1062
|
+
const message = allowed ? `Allowlisted: ${result.risk.level} risk (${score}/100) - ${affected} affected file(s)` : `${result.risk.level} risk (${score}/100) - ${affected} affected file(s)`;
|
|
1063
|
+
const entryResult = {
|
|
1064
|
+
ruleId: "ripple/risk",
|
|
1065
|
+
level,
|
|
1066
|
+
message: { text: message },
|
|
1067
|
+
locations: [
|
|
1068
|
+
{
|
|
1069
|
+
physicalLocation: {
|
|
1070
|
+
artifactLocation: { uri: rel },
|
|
1071
|
+
region: { startLine: 1 }
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
],
|
|
1075
|
+
partialFingerprints: {
|
|
1076
|
+
primaryLocationLineHash: primaryLocationHash("ripple/risk", rel, result.risk.score)
|
|
1077
|
+
},
|
|
1078
|
+
properties: {
|
|
1079
|
+
ripple: {
|
|
1080
|
+
score: result.risk.score,
|
|
1081
|
+
level: result.risk.level,
|
|
1082
|
+
affectedFiles: result.summary.affectedFiles,
|
|
1083
|
+
targetInCycle: result.targetInCycle,
|
|
1084
|
+
allowed,
|
|
1085
|
+
blocked,
|
|
1086
|
+
gate
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
if (allowed) {
|
|
1091
|
+
entryResult.suppressions = [
|
|
1092
|
+
{ kind: "inSource", justification: "Allowlisted via diff.allow in ripple config" }
|
|
1093
|
+
];
|
|
1094
|
+
}
|
|
1095
|
+
return entryResult;
|
|
1096
|
+
}
|
|
1097
|
+
function buildDiffSarif(options) {
|
|
1098
|
+
const { baseLabel, entries, gate, counts, blocked, durationMs, version } = options;
|
|
1099
|
+
const results = entries.map((entry) => fileResult(entry, gate));
|
|
1100
|
+
return {
|
|
1101
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
1102
|
+
version: "2.1.0",
|
|
1103
|
+
runs: [
|
|
1104
|
+
{
|
|
1105
|
+
tool: {
|
|
1106
|
+
driver: {
|
|
1107
|
+
name: "ripple",
|
|
1108
|
+
fullName: "Ripple \u2014 dependency impact analysis",
|
|
1109
|
+
informationUri: INFORMATION_URI,
|
|
1110
|
+
version,
|
|
1111
|
+
rules: [RISK_RULE]
|
|
1112
|
+
}
|
|
1113
|
+
},
|
|
1114
|
+
results,
|
|
1115
|
+
properties: {
|
|
1116
|
+
ripple: {
|
|
1117
|
+
command: "diff",
|
|
1118
|
+
base: baseLabel,
|
|
1119
|
+
gate,
|
|
1120
|
+
blocked,
|
|
1121
|
+
counts,
|
|
1122
|
+
durationMs
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
]
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
function buildAnalyzeSarif(options) {
|
|
1130
|
+
const { result, cwd, version } = options;
|
|
1131
|
+
const rel = result.targetPath.slice(cwd.length).replace(/^[/\\]+/, "").replace(/\\/g, "/");
|
|
1132
|
+
const uri = rel || result.targetPath;
|
|
1133
|
+
return {
|
|
1134
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
1135
|
+
version: "2.1.0",
|
|
1136
|
+
runs: [
|
|
1137
|
+
{
|
|
1138
|
+
tool: {
|
|
1139
|
+
driver: {
|
|
1140
|
+
name: "ripple",
|
|
1141
|
+
fullName: "Ripple \u2014 dependency impact analysis",
|
|
1142
|
+
informationUri: INFORMATION_URI,
|
|
1143
|
+
version,
|
|
1144
|
+
rules: [RISK_RULE]
|
|
1145
|
+
}
|
|
1146
|
+
},
|
|
1147
|
+
results: [
|
|
1148
|
+
{
|
|
1149
|
+
ruleId: "ripple/risk",
|
|
1150
|
+
level: riskToSarifLevel(result.risk.level),
|
|
1151
|
+
message: {
|
|
1152
|
+
text: `${result.risk.level} risk (${result.risk.score.toFixed(1)}/100) - ${result.summary.affectedFiles} affected file(s)`
|
|
1153
|
+
},
|
|
1154
|
+
locations: [
|
|
1155
|
+
{
|
|
1156
|
+
physicalLocation: {
|
|
1157
|
+
artifactLocation: { uri },
|
|
1158
|
+
region: { startLine: 1 }
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
],
|
|
1162
|
+
partialFingerprints: {
|
|
1163
|
+
primaryLocationLineHash: primaryLocationHash("ripple/risk", uri, result.risk.score)
|
|
1164
|
+
},
|
|
1165
|
+
properties: {
|
|
1166
|
+
ripple: {
|
|
1167
|
+
score: result.risk.score,
|
|
1168
|
+
level: result.risk.level,
|
|
1169
|
+
affectedFiles: result.summary.affectedFiles,
|
|
1170
|
+
targetInCycle: result.targetInCycle
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
]
|
|
1175
|
+
}
|
|
1176
|
+
]
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1028
1179
|
function createTsProject() {
|
|
1029
1180
|
return new Project({
|
|
1030
1181
|
skipAddingFilesFromTsConfig: true,
|
|
@@ -1064,7 +1215,7 @@ function isSourceFile(filePath) {
|
|
|
1064
1215
|
|
|
1065
1216
|
// src/scanner/discover.ts
|
|
1066
1217
|
async function discoverSourceFiles(options) {
|
|
1067
|
-
const ignoreGlobs = options.ignore.map(toIgnoreGlob).filter((glob) => glob !== "").concat(["**/node_modules/**"]);
|
|
1218
|
+
const ignoreGlobs = options.ignore.map(toIgnoreGlob).filter((glob) => glob !== "").concat(["**/node_modules/**", "**/.ripple/**"]);
|
|
1068
1219
|
const files = await globFiles({
|
|
1069
1220
|
cwd: options.rootDir,
|
|
1070
1221
|
patterns: options.include,
|
|
@@ -1206,13 +1357,24 @@ function parseSourceFile(project, filePath) {
|
|
|
1206
1357
|
parseError: error instanceof Error ? error.message : String(error)
|
|
1207
1358
|
};
|
|
1208
1359
|
}
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1360
|
+
try {
|
|
1361
|
+
return {
|
|
1362
|
+
path: filePath,
|
|
1363
|
+
kind: sourceFileKind(filePath) ?? "ts",
|
|
1364
|
+
imports: extractImports(sourceFile),
|
|
1365
|
+
exports: extractExports(sourceFile),
|
|
1366
|
+
symbols: extractSymbols(sourceFile)
|
|
1367
|
+
};
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
return {
|
|
1370
|
+
path: filePath,
|
|
1371
|
+
kind: sourceFileKind(filePath) ?? "ts",
|
|
1372
|
+
imports: [],
|
|
1373
|
+
exports: { named: [], hasDefault: false, reExportedFrom: [], reExportedAll: [] },
|
|
1374
|
+
symbols: { functions: [], classes: [], interfaces: [], enums: [], typeAliases: [] },
|
|
1375
|
+
parseError: error instanceof Error ? error.message : String(error)
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1216
1378
|
}
|
|
1217
1379
|
function parseMany(project, filePaths) {
|
|
1218
1380
|
return filePaths.map((filePath) => parseSourceFile(project, filePath));
|
|
@@ -1278,8 +1440,8 @@ function findCycles(graph) {
|
|
|
1278
1440
|
}
|
|
1279
1441
|
const members = [...component].sort();
|
|
1280
1442
|
const componentSet = new Set(component);
|
|
1281
|
-
const
|
|
1282
|
-
cycles.push({ members, path:
|
|
1443
|
+
const path15 = representativeCyclePath(graph, component, componentSet);
|
|
1444
|
+
cycles.push({ members, path: path15 ?? [members[0], members[0]] });
|
|
1283
1445
|
}
|
|
1284
1446
|
cycles.sort(
|
|
1285
1447
|
(a, b) => a.members.length - b.members.length || a.members[0].localeCompare(b.members[0])
|
|
@@ -1288,14 +1450,14 @@ function findCycles(graph) {
|
|
|
1288
1450
|
}
|
|
1289
1451
|
function representativeCyclePath(graph, component, componentSet) {
|
|
1290
1452
|
const inPath = /* @__PURE__ */ new Set();
|
|
1291
|
-
const
|
|
1453
|
+
const path15 = [];
|
|
1292
1454
|
let current = component[0];
|
|
1293
1455
|
for (let steps = 0; steps <= component.length * 2 && current !== void 0; steps++) {
|
|
1294
1456
|
const firstOccurrence = inPath.has(current);
|
|
1295
1457
|
if (firstOccurrence) {
|
|
1296
|
-
const start =
|
|
1458
|
+
const start = path15.indexOf(current);
|
|
1297
1459
|
if (start === -1) return void 0;
|
|
1298
|
-
const cycle =
|
|
1460
|
+
const cycle = path15.slice(start);
|
|
1299
1461
|
if (cycle.length > 1 || graph.forward.get(current)?.has(current)) {
|
|
1300
1462
|
cycle.push(current);
|
|
1301
1463
|
return cycle;
|
|
@@ -1303,7 +1465,7 @@ function representativeCyclePath(graph, component, componentSet) {
|
|
|
1303
1465
|
return void 0;
|
|
1304
1466
|
}
|
|
1305
1467
|
inPath.add(current);
|
|
1306
|
-
|
|
1468
|
+
path15.push(current);
|
|
1307
1469
|
const next = [...graph.forward.get(current) ?? []].find(
|
|
1308
1470
|
(candidate) => componentSet.has(candidate)
|
|
1309
1471
|
);
|
|
@@ -1386,13 +1548,20 @@ function resolveImports(fromPath, importSpecifiers, context) {
|
|
|
1386
1548
|
|
|
1387
1549
|
// src/graph/build.ts
|
|
1388
1550
|
function buildGraph(project, filePaths, context) {
|
|
1389
|
-
|
|
1551
|
+
return buildGraphFromParsed(parseMany(project, filePaths), context);
|
|
1552
|
+
}
|
|
1553
|
+
function buildGraphFromParsed(parsedFiles, context) {
|
|
1554
|
+
const started = Date.now();
|
|
1555
|
+
const trace = process.env.RIPPLE_TRACE === "1";
|
|
1556
|
+
const mark = (label) => {
|
|
1557
|
+
if (trace) console.error(`[trace] graph.${label}: ${Date.now() - started}ms`);
|
|
1558
|
+
};
|
|
1390
1559
|
const nodes = /* @__PURE__ */ new Map();
|
|
1391
1560
|
const forward = /* @__PURE__ */ new Map();
|
|
1392
1561
|
const reverse = /* @__PURE__ */ new Map();
|
|
1393
1562
|
const external = /* @__PURE__ */ new Map();
|
|
1394
1563
|
let parsedCount = 0;
|
|
1395
|
-
let
|
|
1564
|
+
let internalEdges2 = 0;
|
|
1396
1565
|
let unresolvedEdges = 0;
|
|
1397
1566
|
let externalEdges = 0;
|
|
1398
1567
|
for (const parsed of parsedFiles) {
|
|
@@ -1400,6 +1569,7 @@ function buildGraph(project, filePaths, context) {
|
|
|
1400
1569
|
nodes.set(key, { path: parsed.path, parsed });
|
|
1401
1570
|
if (!parsed.parseError) parsedCount++;
|
|
1402
1571
|
}
|
|
1572
|
+
mark("nodes");
|
|
1403
1573
|
for (const parsed of parsedFiles) {
|
|
1404
1574
|
const fromKey = pathKey(parsed.path, context.rootDir);
|
|
1405
1575
|
const targets = /* @__PURE__ */ new Set();
|
|
@@ -1412,7 +1582,7 @@ function buildGraph(project, filePaths, context) {
|
|
|
1412
1582
|
parsed.imports.map((decl) => decl.raw),
|
|
1413
1583
|
context
|
|
1414
1584
|
);
|
|
1415
|
-
|
|
1585
|
+
internalEdges2 += internal.length;
|
|
1416
1586
|
unresolvedEdges += unresolved.length;
|
|
1417
1587
|
externalEdges += externalSpecifiers.length;
|
|
1418
1588
|
for (const target of internal) {
|
|
@@ -1437,32 +1607,212 @@ function buildGraph(project, filePaths, context) {
|
|
|
1437
1607
|
stats: {
|
|
1438
1608
|
files: parsedFiles.length,
|
|
1439
1609
|
parsed: parsedCount,
|
|
1440
|
-
internalEdges,
|
|
1610
|
+
internalEdges: internalEdges2,
|
|
1441
1611
|
unresolvedEdges,
|
|
1442
1612
|
externalEdges,
|
|
1443
1613
|
cycles: 0
|
|
1444
1614
|
}
|
|
1445
1615
|
};
|
|
1616
|
+
mark("edges");
|
|
1446
1617
|
graph.cycles = findCycles(graph);
|
|
1447
1618
|
graph.stats.cycles = graph.cycles.length;
|
|
1619
|
+
mark("cycles");
|
|
1448
1620
|
return graph;
|
|
1449
1621
|
}
|
|
1622
|
+
var CACHE_SCHEMA = 2;
|
|
1623
|
+
var CACHE_FILE = path8.join(".ripple", "cache", "parsed-v2.json");
|
|
1624
|
+
var cacheEnabled = () => process.env.RIPPLE_NO_CACHE !== "1";
|
|
1625
|
+
async function contentHash(filePath) {
|
|
1626
|
+
const bytes = await fs3.readFile(filePath);
|
|
1627
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
1628
|
+
}
|
|
1629
|
+
function configCacheHash(config) {
|
|
1630
|
+
const stable = {
|
|
1631
|
+
include: [...config.include].sort(),
|
|
1632
|
+
ignore: [...config.ignore].sort()
|
|
1633
|
+
};
|
|
1634
|
+
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
|
|
1635
|
+
}
|
|
1636
|
+
function relPosix(rootDir, absPath) {
|
|
1637
|
+
return toPosix(path8.relative(rootDir, absPath));
|
|
1638
|
+
}
|
|
1639
|
+
function cachePath(rootDir) {
|
|
1640
|
+
return path8.join(rootDir, CACHE_FILE);
|
|
1641
|
+
}
|
|
1642
|
+
async function loadParsedCache(rootDir, configHash) {
|
|
1643
|
+
let raw;
|
|
1644
|
+
try {
|
|
1645
|
+
raw = await fs3.readFile(cachePath(rootDir), "utf8");
|
|
1646
|
+
} catch {
|
|
1647
|
+
return /* @__PURE__ */ new Map();
|
|
1648
|
+
}
|
|
1649
|
+
try {
|
|
1650
|
+
const manifest = JSON.parse(raw);
|
|
1651
|
+
if (manifest.schema !== CACHE_SCHEMA || manifest.configHash !== configHash) {
|
|
1652
|
+
return /* @__PURE__ */ new Map();
|
|
1653
|
+
}
|
|
1654
|
+
return new Map(Object.entries(manifest.files ?? {}));
|
|
1655
|
+
} catch {
|
|
1656
|
+
return /* @__PURE__ */ new Map();
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
async function saveParsedCache(rootDir, configHash, entries) {
|
|
1660
|
+
const manifest = {
|
|
1661
|
+
schema: CACHE_SCHEMA,
|
|
1662
|
+
configHash,
|
|
1663
|
+
files: Object.fromEntries(entries)
|
|
1664
|
+
};
|
|
1665
|
+
const target = cachePath(rootDir);
|
|
1666
|
+
const tmp = target + ".tmp";
|
|
1667
|
+
try {
|
|
1668
|
+
await fs3.mkdir(path8.dirname(target), { recursive: true });
|
|
1669
|
+
await fs3.writeFile(tmp, JSON.stringify(manifest), "utf8");
|
|
1670
|
+
await fs3.rename(tmp, target);
|
|
1671
|
+
} catch {
|
|
1672
|
+
try {
|
|
1673
|
+
await fs3.rm(tmp, { force: true });
|
|
1674
|
+
} catch {
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
async function loadParsedFiles(options) {
|
|
1679
|
+
const { project, rootDir, filePaths, config } = options;
|
|
1680
|
+
if (!cacheEnabled()) {
|
|
1681
|
+
return {
|
|
1682
|
+
parsedFiles: parseMany(project, filePaths),
|
|
1683
|
+
stats: { hits: 0, misses: filePaths.length }
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
const configHash = configCacheHash(config);
|
|
1687
|
+
const cached = await loadParsedCache(rootDir, configHash);
|
|
1688
|
+
const trace = process.env.RIPPLE_TRACE === "1";
|
|
1689
|
+
const traceAt = (label, from) => {
|
|
1690
|
+
if (trace) console.error(`[trace] cache.${label}: ${Date.now() - from}ms`);
|
|
1691
|
+
return Date.now();
|
|
1692
|
+
};
|
|
1693
|
+
let stage = Date.now();
|
|
1694
|
+
const states = await Promise.all(
|
|
1695
|
+
filePaths.map(async (abs) => {
|
|
1696
|
+
try {
|
|
1697
|
+
const stat = await fs3.stat(abs);
|
|
1698
|
+
return { abs, rel: relPosix(rootDir, abs), size: stat.size, mtimeMs: stat.mtimeMs };
|
|
1699
|
+
} catch {
|
|
1700
|
+
return { abs, rel: relPosix(rootDir, abs), size: -1, mtimeMs: -1 };
|
|
1701
|
+
}
|
|
1702
|
+
})
|
|
1703
|
+
);
|
|
1704
|
+
stage = traceAt("load-cache", stage);
|
|
1705
|
+
const hits = /* @__PURE__ */ new Map();
|
|
1706
|
+
const stale = [];
|
|
1707
|
+
const parsed = /* @__PURE__ */ new Map();
|
|
1708
|
+
let dirty = false;
|
|
1709
|
+
for (const state of states) {
|
|
1710
|
+
const entry = cached.get(state.rel);
|
|
1711
|
+
if (entry !== void 0 && entry.size === state.size && entry.mtimeMs === state.mtimeMs && state.mtimeMs > 0) {
|
|
1712
|
+
hits.set(state.rel, entry);
|
|
1713
|
+
parsed.set(state.rel, { ...entry.parsed, path: state.abs });
|
|
1714
|
+
continue;
|
|
1715
|
+
}
|
|
1716
|
+
let hash = "";
|
|
1717
|
+
try {
|
|
1718
|
+
hash = await contentHash(state.abs);
|
|
1719
|
+
} catch {
|
|
1720
|
+
}
|
|
1721
|
+
if (entry !== void 0 && entry.hash === hash) {
|
|
1722
|
+
if (entry.mtimeMs !== state.mtimeMs || entry.size !== state.size) {
|
|
1723
|
+
hits.set(state.rel, { ...entry, size: state.size, mtimeMs: state.mtimeMs });
|
|
1724
|
+
dirty = true;
|
|
1725
|
+
} else {
|
|
1726
|
+
hits.set(state.rel, entry);
|
|
1727
|
+
}
|
|
1728
|
+
parsed.set(state.rel, { ...entry.parsed, path: state.abs });
|
|
1729
|
+
} else {
|
|
1730
|
+
stale.push(state.abs);
|
|
1731
|
+
dirty = true;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
if (stale.length > 0) {
|
|
1735
|
+
for (const filePath of stale) {
|
|
1736
|
+
project.getSourceFile(filePath)?.forget();
|
|
1737
|
+
}
|
|
1738
|
+
for (const parsedFile of parseMany(project, stale)) {
|
|
1739
|
+
parsed.set(relPosix(rootDir, parsedFile.path), parsedFile);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
traceAt("freshness+parse", stage);
|
|
1743
|
+
if (dirty) {
|
|
1744
|
+
const next = new Map(hits);
|
|
1745
|
+
for (const parsedFile of parsed.values()) {
|
|
1746
|
+
const rel = relPosix(rootDir, parsedFile.path);
|
|
1747
|
+
if (next.has(rel)) continue;
|
|
1748
|
+
const state = states.find((candidate) => candidate.rel === rel);
|
|
1749
|
+
let hash = "";
|
|
1750
|
+
try {
|
|
1751
|
+
hash = await contentHash(parsedFile.path);
|
|
1752
|
+
} catch {
|
|
1753
|
+
}
|
|
1754
|
+
next.set(rel, {
|
|
1755
|
+
hash,
|
|
1756
|
+
size: state?.size ?? -1,
|
|
1757
|
+
mtimeMs: state?.mtimeMs ?? -1,
|
|
1758
|
+
parsed: { ...parsedFile, path: rel }
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
await saveParsedCache(rootDir, configHash, next);
|
|
1762
|
+
traceAt("save", stage);
|
|
1763
|
+
}
|
|
1764
|
+
const parsedFiles = filePaths.map((abs) => {
|
|
1765
|
+
const rel = relPosix(rootDir, abs);
|
|
1766
|
+
const parsedFile = parsed.get(rel);
|
|
1767
|
+
if (parsedFile === void 0) {
|
|
1768
|
+
return {
|
|
1769
|
+
path: abs,
|
|
1770
|
+
kind: "ts",
|
|
1771
|
+
imports: [],
|
|
1772
|
+
exports: { named: [], hasDefault: false, reExportedFrom: [], reExportedAll: [] },
|
|
1773
|
+
symbols: { functions: [], classes: [], interfaces: [], enums: [], typeAliases: [] },
|
|
1774
|
+
parseError: "file disappeared during discovery"
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
return parsedFile;
|
|
1778
|
+
});
|
|
1779
|
+
return {
|
|
1780
|
+
parsedFiles,
|
|
1781
|
+
stats: { hits: hits.size, misses: stale.length }
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1450
1784
|
|
|
1451
1785
|
// src/commands/pipeline.ts
|
|
1452
1786
|
async function runPipeline(context) {
|
|
1453
1787
|
const started = Date.now();
|
|
1788
|
+
const trace = process.env.RIPPLE_TRACE === "1";
|
|
1789
|
+
const mark = (label) => {
|
|
1790
|
+
if (trace) console.error(`[trace] pipeline.${label}: ${Date.now() - started}ms`);
|
|
1791
|
+
};
|
|
1454
1792
|
const project = createTsProject();
|
|
1793
|
+
mark("ts-project");
|
|
1455
1794
|
const filePaths = await discoverSourceFiles({
|
|
1456
1795
|
rootDir: context.rootDir,
|
|
1457
1796
|
include: context.config.include,
|
|
1458
1797
|
ignore: context.config.ignore
|
|
1459
1798
|
});
|
|
1460
|
-
|
|
1799
|
+
mark("discover");
|
|
1800
|
+
const { parsedFiles, stats } = await loadParsedFiles({
|
|
1801
|
+
project,
|
|
1802
|
+
rootDir: context.rootDir,
|
|
1803
|
+
filePaths,
|
|
1804
|
+
config: context.config
|
|
1805
|
+
});
|
|
1806
|
+
if (trace) console.error(`[trace] cache: ${stats.hits} hits, ${stats.misses} misses`);
|
|
1807
|
+
mark("parsed");
|
|
1808
|
+
const graph = buildGraphFromParsed(parsedFiles, {
|
|
1461
1809
|
rootDir: context.rootDir,
|
|
1462
1810
|
aliases: context.aliases,
|
|
1463
1811
|
fileKeys: new Set(filePaths.map((p) => pathKey(p, context.rootDir)))
|
|
1464
1812
|
});
|
|
1813
|
+
mark("graph");
|
|
1465
1814
|
const entryPoints = await detectEntryPoints(context.rootDir);
|
|
1815
|
+
mark("entry-points");
|
|
1466
1816
|
return { graph, filePaths, entryPoints, durationMs: Date.now() - started };
|
|
1467
1817
|
}
|
|
1468
1818
|
function resolveTargetFile(fileArg, cwd) {
|
|
@@ -1505,6 +1855,12 @@ async function analyzeCommand(fileArg, options, ctx2) {
|
|
|
1505
1855
|
ctx2.writer.write(serializeJson(report));
|
|
1506
1856
|
return ExitCode.Success;
|
|
1507
1857
|
}
|
|
1858
|
+
if (options.sarif) {
|
|
1859
|
+
ctx2.writer.write(
|
|
1860
|
+
serializeJson(buildAnalyzeSarif({ result, cwd: ctx2.cwd, version: ctx2.version }))
|
|
1861
|
+
);
|
|
1862
|
+
return ExitCode.Success;
|
|
1863
|
+
}
|
|
1508
1864
|
renderAnalyzeReport(
|
|
1509
1865
|
result,
|
|
1510
1866
|
{
|
|
@@ -1518,6 +1874,150 @@ async function analyzeCommand(fileArg, options, ctx2) {
|
|
|
1518
1874
|
return ExitCode.Success;
|
|
1519
1875
|
}
|
|
1520
1876
|
|
|
1877
|
+
// src/formatter/graph-export.ts
|
|
1878
|
+
var escapeDot = (value) => value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1879
|
+
var escapeMermaid = (value) => value.replace(/#/g, "#35;").replace(/"/g, "#quot;");
|
|
1880
|
+
var escapeHtml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1881
|
+
function sortedKeys(input) {
|
|
1882
|
+
return [...input.nodes.keys()].sort((a, b) => {
|
|
1883
|
+
const left = input.nodes.get(a) ?? a;
|
|
1884
|
+
const right = input.nodes.get(b) ?? b;
|
|
1885
|
+
return left.localeCompare(right);
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
var nodeLabel = (input, key) => displayPath(input.nodes.get(key) ?? key, input.cwd);
|
|
1889
|
+
function cycleMembers(input) {
|
|
1890
|
+
const present = /* @__PURE__ */ new Set();
|
|
1891
|
+
for (const cycle of input.cycles) {
|
|
1892
|
+
for (const member of cycle.members) {
|
|
1893
|
+
if (input.nodes.has(member)) present.add(member);
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
return [...present].sort((a, b) => nodeLabel(input, a).localeCompare(nodeLabel(input, b)));
|
|
1897
|
+
}
|
|
1898
|
+
function internalEdges(input) {
|
|
1899
|
+
const pairs = [];
|
|
1900
|
+
for (const [source, targets] of input.edges) {
|
|
1901
|
+
if (!input.nodes.has(source)) continue;
|
|
1902
|
+
for (const target of targets) {
|
|
1903
|
+
if (!input.nodes.has(target)) continue;
|
|
1904
|
+
pairs.push([source, target]);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
return pairs.sort((a, b) => {
|
|
1908
|
+
const left = nodeLabel(input, a[0]).localeCompare(nodeLabel(input, b[0]));
|
|
1909
|
+
return left !== 0 ? left : nodeLabel(input, a[1]).localeCompare(nodeLabel(input, b[1]));
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
function renderMermaidGraph(input) {
|
|
1913
|
+
const keys = sortedKeys(input);
|
|
1914
|
+
const idByKey = new Map(keys.map((key, index) => [key, `n${index}`]));
|
|
1915
|
+
const lines = ["flowchart LR"];
|
|
1916
|
+
lines.push(" classDef cycle fill:#4a2525,stroke:#f85149,color:#f0b3b3;");
|
|
1917
|
+
for (const key of keys) {
|
|
1918
|
+
lines.push(` ${idByKey.get(key)}["${escapeMermaid(nodeLabel(input, key))}"]`);
|
|
1919
|
+
}
|
|
1920
|
+
for (const [source, target] of internalEdges(input)) {
|
|
1921
|
+
lines.push(` ${idByKey.get(source)} --> ${idByKey.get(target)}`);
|
|
1922
|
+
}
|
|
1923
|
+
const members = cycleMembers(input).map((key) => idByKey.get(key)).filter((id) => id !== void 0);
|
|
1924
|
+
if (members.length > 0) {
|
|
1925
|
+
lines.push(` class ${members.join(",")} cycle;`);
|
|
1926
|
+
}
|
|
1927
|
+
return `${lines.join("\n")}
|
|
1928
|
+
`;
|
|
1929
|
+
}
|
|
1930
|
+
function renderDotGraph(input) {
|
|
1931
|
+
const lines = [
|
|
1932
|
+
"digraph ripple {",
|
|
1933
|
+
" rankdir=LR;",
|
|
1934
|
+
' node [shape=box, style="rounded,filled", fillcolor="#161b22", color="#30363d", fontcolor="#e6edf3"];',
|
|
1935
|
+
' edge [color="#8b949e"];'
|
|
1936
|
+
];
|
|
1937
|
+
for (const key of sortedKeys(input)) {
|
|
1938
|
+
const label = nodeLabel(input, key);
|
|
1939
|
+
lines.push(` "${escapeDot(label)}" [label="${escapeDot(label)}"];`);
|
|
1940
|
+
}
|
|
1941
|
+
for (const [source, target] of internalEdges(input)) {
|
|
1942
|
+
lines.push(
|
|
1943
|
+
` "${escapeDot(nodeLabel(input, source))}" -> "${escapeDot(nodeLabel(input, target))}";`
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
for (const key of cycleMembers(input)) {
|
|
1947
|
+
const label = nodeLabel(input, key);
|
|
1948
|
+
lines.push(` "${escapeDot(label)}" [color="#f85149", penwidth=1.5];`);
|
|
1949
|
+
}
|
|
1950
|
+
lines.push("}");
|
|
1951
|
+
return `${lines.join("\n")}
|
|
1952
|
+
`;
|
|
1953
|
+
}
|
|
1954
|
+
function renderHtmlGraph(input) {
|
|
1955
|
+
const keys = sortedKeys(input);
|
|
1956
|
+
const edges = internalEdges(input);
|
|
1957
|
+
const members = cycleMembers(input);
|
|
1958
|
+
const cycleLines = input.cycles.map(
|
|
1959
|
+
(cycle) => cycle.path.filter((key) => input.nodes.has(key)).map((key) => escapeHtml(nodeLabel(input, key))).join(" → ")
|
|
1960
|
+
).filter((line) => line.length > 0);
|
|
1961
|
+
const edgeRows = edges.map(
|
|
1962
|
+
([source, target]) => ` <tr><td>${escapeHtml(nodeLabel(input, source))}</td><td>→</td><td>${escapeHtml(nodeLabel(input, target))}</td></tr>`
|
|
1963
|
+
).join("\n");
|
|
1964
|
+
const cycleRows = cycleLines.length > 0 ? cycleLines.map((line) => ` <li><code>${line}</code></li>`).join("\n") : " <li><em>none</em></li>";
|
|
1965
|
+
return `<!DOCTYPE html>
|
|
1966
|
+
<html lang="en">
|
|
1967
|
+
<head>
|
|
1968
|
+
<meta charset="utf-8">
|
|
1969
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1970
|
+
<title>Ripple \u2014 dependency graph</title>
|
|
1971
|
+
<style>
|
|
1972
|
+
:root { color-scheme: dark; }
|
|
1973
|
+
body { margin: 0; padding: 2rem; background: #0d1117; color: #e6edf3;
|
|
1974
|
+
font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
1975
|
+
h1 { font-size: 1.4rem; margin: 0 0 .25rem; }
|
|
1976
|
+
h2 { font-size: 1.05rem; margin: 2rem 0 .5rem; color: #79c0ff; }
|
|
1977
|
+
code, pre { font-family: SFMono-Regular, Consolas, monospace; }
|
|
1978
|
+
pre { background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
|
1979
|
+
padding: 1rem; overflow-x: auto; font-size: .85rem; }
|
|
1980
|
+
table { border-collapse: collapse; width: 100%; font-size: .9rem; }
|
|
1981
|
+
th, td { text-align: left; padding: .35rem .6rem; border-bottom: 1px solid #21262d; }
|
|
1982
|
+
th { color: #8b949e; font-weight: 600; }
|
|
1983
|
+
.stats { display: flex; gap: 1.5rem; margin-top: 1rem; }
|
|
1984
|
+
.stat b { display: block; font-size: 1.6rem; color: #79c0ff; }
|
|
1985
|
+
.stat span { color: #8b949e; font-size: .8rem; }
|
|
1986
|
+
.muted { color: #8b949e; }
|
|
1987
|
+
a { color: #79c0ff; }
|
|
1988
|
+
</style>
|
|
1989
|
+
</head>
|
|
1990
|
+
<body>
|
|
1991
|
+
<h1>Ripple dependency graph</h1>
|
|
1992
|
+
<p class="muted">Exported with <code>ripple graph --format html</code> \u2014 deterministic and offline.</p>
|
|
1993
|
+
<div class="stats">
|
|
1994
|
+
<div class="stat"><b>${keys.length}</b><span>files</span></div>
|
|
1995
|
+
<div class="stat"><b>${edges.length}</b><span>edges</span></div>
|
|
1996
|
+
<div class="stat"><b>${input.cycles.length}</b><span>cycle groups</span></div>
|
|
1997
|
+
</div>
|
|
1998
|
+
|
|
1999
|
+
<h2>Circular dependencies</h2>
|
|
2000
|
+
<ul>${cycleRows}</ul>
|
|
2001
|
+
<p class="muted">Cycle members: ${members.map(escapeHtml).join(", ")}</p>
|
|
2002
|
+
|
|
2003
|
+
<h2>Graph</h2>
|
|
2004
|
+
<pre><code class="language-mermaid">${escapeHtml(renderMermaidGraph(input)).trimEnd()}</code></pre>
|
|
2005
|
+
<p class="muted">Render this diagram with the
|
|
2006
|
+
<a href="https://mermaid.live">Mermaid live editor</a> or a tool that
|
|
2007
|
+
understands Mermaid (GitHub markdown, docs sites, IDEs).</p>
|
|
2008
|
+
|
|
2009
|
+
<h2>Edges (${edges.length})</h2>
|
|
2010
|
+
<table>
|
|
2011
|
+
<thead><tr><th>Source</th><th></th><th>Target</th></tr></thead>
|
|
2012
|
+
<tbody>
|
|
2013
|
+
${edgeRows}
|
|
2014
|
+
</tbody>
|
|
2015
|
+
</table>
|
|
2016
|
+
</body>
|
|
2017
|
+
</html>
|
|
2018
|
+
`;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
1521
2021
|
// src/commands/graph.ts
|
|
1522
2022
|
function walk(graph, startKey, edges, maxDepth, cwd) {
|
|
1523
2023
|
const seen = /* @__PURE__ */ new Set([startKey]);
|
|
@@ -1539,8 +2039,36 @@ function walk(graph, startKey, edges, maxDepth, cwd) {
|
|
|
1539
2039
|
}
|
|
1540
2040
|
return entries.sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path)).map((entry) => entry.path);
|
|
1541
2041
|
}
|
|
2042
|
+
function reachableSubgraph(graph, startKey, reverse, maxDepth, cwd) {
|
|
2043
|
+
const edges = reverse ? graph.reverse : graph.forward;
|
|
2044
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
2045
|
+
const visited = /* @__PURE__ */ new Set([startKey]);
|
|
2046
|
+
const queue = [{ key: startKey, depth: 0 }];
|
|
2047
|
+
const collectedEdges = /* @__PURE__ */ new Map();
|
|
2048
|
+
while (queue.length > 0) {
|
|
2049
|
+
const current = queue.shift();
|
|
2050
|
+
if (!current) continue;
|
|
2051
|
+
nodes.set(current.key, graph.nodes.get(current.key)?.path ?? current.key);
|
|
2052
|
+
if (maxDepth !== void 0 && current.depth >= maxDepth) continue;
|
|
2053
|
+
const targets = /* @__PURE__ */ new Set();
|
|
2054
|
+
for (const candidate of edges.get(current.key) ?? []) {
|
|
2055
|
+
targets.add(candidate);
|
|
2056
|
+
if (!visited.has(candidate)) {
|
|
2057
|
+
visited.add(candidate);
|
|
2058
|
+
queue.push({ key: candidate, depth: current.depth + 1 });
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
if (targets.size > 0) collectedEdges.set(current.key, targets);
|
|
2062
|
+
}
|
|
2063
|
+
return { nodes, edges: collectedEdges, cycles: graph.cycles, cwd };
|
|
2064
|
+
}
|
|
2065
|
+
function writeExport(format, input, writer) {
|
|
2066
|
+
const rendered = format === "mermaid" ? renderMermaidGraph(input) : format === "dot" ? renderDotGraph(input) : renderHtmlGraph(input);
|
|
2067
|
+
writer.write(rendered);
|
|
2068
|
+
}
|
|
1542
2069
|
async function graphCommand(fileArg, options, ctx2) {
|
|
1543
|
-
const
|
|
2070
|
+
const format = options.format ?? (options.json ? "json" : "terminal");
|
|
2071
|
+
const tracker = createStageTracker(format === "terminal");
|
|
1544
2072
|
tracker.next("Loading config");
|
|
1545
2073
|
const cwd = ctx2.cwd;
|
|
1546
2074
|
try {
|
|
@@ -1553,9 +2081,24 @@ async function graphCommand(fileArg, options, ctx2) {
|
|
|
1553
2081
|
nodes: graph.nodes,
|
|
1554
2082
|
cwd
|
|
1555
2083
|
};
|
|
2084
|
+
if (format === "mermaid" || format === "dot" || format === "html") {
|
|
2085
|
+
let exportInput;
|
|
2086
|
+
if (fileArg) {
|
|
2087
|
+
const targetPath2 = resolveTargetFile(fileArg, cwd);
|
|
2088
|
+
const { key: key2 } = requireNode(graph, targetPath2, context.rootDir);
|
|
2089
|
+
exportInput = reachableSubgraph(graph, key2, options.reverse, options.depth, cwd);
|
|
2090
|
+
} else {
|
|
2091
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
2092
|
+
for (const [key2, node] of graph.nodes) nodes.set(key2, node.path);
|
|
2093
|
+
exportInput = { nodes, edges: graph.forward, cycles: graph.cycles, cwd };
|
|
2094
|
+
}
|
|
2095
|
+
tracker.done();
|
|
2096
|
+
writeExport(format, exportInput, ctx2.writer);
|
|
2097
|
+
return ExitCode.Success;
|
|
2098
|
+
}
|
|
1556
2099
|
if (!fileArg) {
|
|
1557
2100
|
tracker.done();
|
|
1558
|
-
if (
|
|
2101
|
+
if (format === "json") {
|
|
1559
2102
|
const report = buildGraphJsonReport(input, ctx2.version);
|
|
1560
2103
|
ctx2.writer.write(serializeJson(report));
|
|
1561
2104
|
} else {
|
|
@@ -1577,7 +2120,7 @@ async function graphCommand(fileArg, options, ctx2) {
|
|
|
1577
2120
|
const dependants = walk(graph, key, graph.forward, options.depth, cwd);
|
|
1578
2121
|
const dependents = walk(graph, key, graph.reverse, options.depth, cwd);
|
|
1579
2122
|
tracker.done();
|
|
1580
|
-
if (
|
|
2123
|
+
if (format === "json") {
|
|
1581
2124
|
const report = buildGraphJsonReport(
|
|
1582
2125
|
{
|
|
1583
2126
|
...input,
|
|
@@ -1720,29 +2263,72 @@ function renderResults(results, ctx2, color) {
|
|
|
1720
2263
|
}
|
|
1721
2264
|
ctx2.writer.writeLine();
|
|
1722
2265
|
}
|
|
1723
|
-
var
|
|
2266
|
+
var SCHEMA_REF = "./node_modules/@alimaandev/ripple/ripple.schema.json";
|
|
2267
|
+
function jsonPayload(full) {
|
|
2268
|
+
const payload = full ? { ...DEFAULT_CONFIG } : { include: DEFAULT_CONFIG.include };
|
|
2269
|
+
return { ...payload, $schema: SCHEMA_REF };
|
|
2270
|
+
}
|
|
2271
|
+
function tsValue(value, indent) {
|
|
2272
|
+
if (value === void 0) return "undefined";
|
|
2273
|
+
if (Array.isArray(value)) {
|
|
2274
|
+
if (value.length === 0) return "[]";
|
|
2275
|
+
const items = value.map((item) => tsValue(item, `${indent} `)).join(`,
|
|
2276
|
+
${indent} `);
|
|
2277
|
+
return `[
|
|
2278
|
+
${indent} ${items}
|
|
2279
|
+
${indent}]`;
|
|
2280
|
+
}
|
|
2281
|
+
if (typeof value === "object" && value !== null) {
|
|
2282
|
+
const entries = Object.entries(value).filter(
|
|
2283
|
+
([, entry]) => entry !== void 0
|
|
2284
|
+
);
|
|
2285
|
+
if (entries.length === 0) return "{}";
|
|
2286
|
+
const items = entries.map(([key, entry]) => `${key}: ${tsValue(entry, `${indent} `)}`).join(`,
|
|
2287
|
+
${indent} `);
|
|
2288
|
+
return `{
|
|
2289
|
+
${indent} ${items}
|
|
2290
|
+
${indent}}`;
|
|
2291
|
+
}
|
|
2292
|
+
return JSON.stringify(value);
|
|
2293
|
+
}
|
|
2294
|
+
function tsPayload(full) {
|
|
2295
|
+
if (!full) {
|
|
2296
|
+
return `import type { RippleConfig } from "@alimaandev/ripple";
|
|
2297
|
+
|
|
2298
|
+
const config: RippleConfig = {
|
|
2299
|
+
include: ${JSON.stringify(DEFAULT_CONFIG.include)},
|
|
2300
|
+
};
|
|
2301
|
+
|
|
2302
|
+
export default config;
|
|
2303
|
+
`;
|
|
2304
|
+
}
|
|
2305
|
+
const body = tsValue({ ...DEFAULT_CONFIG }, " ");
|
|
2306
|
+
return `import type { RippleConfig } from "@alimaandev/ripple";
|
|
2307
|
+
|
|
2308
|
+
const config: RippleConfig = ${body};
|
|
2309
|
+
|
|
2310
|
+
export default config;
|
|
2311
|
+
`;
|
|
2312
|
+
}
|
|
1724
2313
|
async function initCommand(options, ctx2) {
|
|
1725
2314
|
const style = { color: resolveColor(options.color) };
|
|
1726
|
-
const
|
|
2315
|
+
const fileName = options.ts ? "ripple.config.ts" : "ripple.config.json";
|
|
2316
|
+
const target = path8.join(ctx2.cwd, fileName);
|
|
1727
2317
|
try {
|
|
1728
|
-
await
|
|
2318
|
+
await fs3.access(target);
|
|
1729
2319
|
if (!options.force) {
|
|
1730
2320
|
const mark = style.color ? chalk.red(icon("cross")) : icon("cross");
|
|
1731
|
-
ctx2.writer.writeLine(
|
|
1732
|
-
`${mark} ${INIT_FILENAME} already exists. Pass --force to overwrite it.`
|
|
1733
|
-
);
|
|
2321
|
+
ctx2.writer.writeLine(`${mark} ${fileName} already exists. Pass --force to overwrite it.`);
|
|
1734
2322
|
return ExitCode.Failure;
|
|
1735
2323
|
}
|
|
1736
2324
|
} catch {
|
|
1737
2325
|
}
|
|
1738
|
-
const
|
|
1739
|
-
|
|
1740
|
-
};
|
|
2326
|
+
const content = options.ts ? tsPayload(Boolean(options.full)) : `${JSON.stringify(jsonPayload(Boolean(options.full)), null, 2)}
|
|
2327
|
+
`;
|
|
1741
2328
|
try {
|
|
1742
|
-
await
|
|
1743
|
-
`, "utf8");
|
|
2329
|
+
await fs3.writeFile(target, content, "utf8");
|
|
1744
2330
|
const mark = style.color ? chalk.green(icon("tick")) : icon("tick");
|
|
1745
|
-
ctx2.writer.writeLine(`${mark} Wrote ${
|
|
2331
|
+
ctx2.writer.writeLine(`${mark} Wrote ${fileName}`);
|
|
1746
2332
|
ctx2.writer.writeLine(
|
|
1747
2333
|
` ${dim("Edit it to tune include globs, aliases and risk weights.", style)}`
|
|
1748
2334
|
);
|
|
@@ -1750,7 +2336,7 @@ async function initCommand(options, ctx2) {
|
|
|
1750
2336
|
} catch (error) {
|
|
1751
2337
|
const mark = style.color ? chalk.red(icon("cross")) : icon("cross");
|
|
1752
2338
|
ctx2.writer.writeLine(
|
|
1753
|
-
`${mark} Could not write ${
|
|
2339
|
+
`${mark} Could not write ${fileName}: ${error instanceof Error ? error.message : String(error)}`
|
|
1754
2340
|
);
|
|
1755
2341
|
return ExitCode.Failure;
|
|
1756
2342
|
}
|
|
@@ -1807,8 +2393,8 @@ function changedFiles(cwd, base) {
|
|
|
1807
2393
|
}
|
|
1808
2394
|
|
|
1809
2395
|
// src/formatter/annotations.ts
|
|
1810
|
-
var
|
|
1811
|
-
var
|
|
2396
|
+
var LEVEL_INDEX2 = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
|
|
2397
|
+
var GATE_INDEX2 = { medium: 1, high: 2, critical: 3 };
|
|
1812
2398
|
function escapeProperty(value) {
|
|
1813
2399
|
return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
1814
2400
|
}
|
|
@@ -1821,7 +2407,7 @@ function renderAnnotation(annotation) {
|
|
|
1821
2407
|
}
|
|
1822
2408
|
function buildFileAnnotations(files, gate) {
|
|
1823
2409
|
return files.map(({ file, result }) => {
|
|
1824
|
-
const blocked =
|
|
2410
|
+
const blocked = LEVEL_INDEX2[result.risk.level] >= GATE_INDEX2[gate];
|
|
1825
2411
|
return {
|
|
1826
2412
|
level: blocked ? "error" : "warning",
|
|
1827
2413
|
file,
|
|
@@ -1831,12 +2417,12 @@ function buildFileAnnotations(files, gate) {
|
|
|
1831
2417
|
});
|
|
1832
2418
|
}
|
|
1833
2419
|
function buildGateAnnotation(blocked, gate, counts) {
|
|
1834
|
-
const gateIndex =
|
|
2420
|
+
const gateIndex = GATE_INDEX2[gate];
|
|
1835
2421
|
const parts = [];
|
|
1836
|
-
if (
|
|
2422
|
+
if (LEVEL_INDEX2.CRITICAL >= gateIndex && counts.critical > 0)
|
|
1837
2423
|
parts.push(`${counts.critical} CRITICAL`);
|
|
1838
|
-
if (
|
|
1839
|
-
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`);
|
|
1840
2426
|
const atGate = parts.length > 0 ? parts.join(" \xB7 ") : "none";
|
|
1841
2427
|
return {
|
|
1842
2428
|
level: "notice",
|
|
@@ -1846,8 +2432,8 @@ function buildGateAnnotation(blocked, gate, counts) {
|
|
|
1846
2432
|
}
|
|
1847
2433
|
|
|
1848
2434
|
// src/commands/diff.ts
|
|
1849
|
-
var
|
|
1850
|
-
var
|
|
2435
|
+
var LEVEL_INDEX3 = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
|
|
2436
|
+
var GATE_INDEX3 = { medium: 1, high: 2, critical: 3 };
|
|
1851
2437
|
var VERDICT_NOTE = {
|
|
1852
2438
|
medium: "no MEDIUM or worse",
|
|
1853
2439
|
high: "no HIGH or CRITICAL",
|
|
@@ -1860,14 +2446,20 @@ function countLevels(results) {
|
|
|
1860
2446
|
}
|
|
1861
2447
|
return counts;
|
|
1862
2448
|
}
|
|
2449
|
+
function isAllowedByDiffAllowlist(relPath2, patterns) {
|
|
2450
|
+
if (patterns.length === 0) return false;
|
|
2451
|
+
const rel = toPosix(relPath2);
|
|
2452
|
+
const matcher = picomatch(patterns.map(toPosix), { dot: true });
|
|
2453
|
+
return matcher(rel);
|
|
2454
|
+
}
|
|
1863
2455
|
function riskySummary(counts, gate) {
|
|
1864
|
-
const gateIndex =
|
|
2456
|
+
const gateIndex = GATE_INDEX3[gate];
|
|
1865
2457
|
const parts = [];
|
|
1866
|
-
if (
|
|
2458
|
+
if (LEVEL_INDEX3.CRITICAL >= gateIndex && counts.critical > 0)
|
|
1867
2459
|
parts.push(`${counts.critical} CRITICAL`);
|
|
1868
|
-
if (
|
|
1869
|
-
if (
|
|
1870
|
-
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`);
|
|
1871
2463
|
return parts.length > 0 ? parts.join(" \xB7 ") : "none";
|
|
1872
2464
|
}
|
|
1873
2465
|
async function diffCommand(options, ctx2) {
|
|
@@ -1905,9 +2497,16 @@ async function diffCommand(options, ctx2) {
|
|
|
1905
2497
|
);
|
|
1906
2498
|
}
|
|
1907
2499
|
tracker.done();
|
|
1908
|
-
const
|
|
1909
|
-
const
|
|
1910
|
-
const
|
|
2500
|
+
const allowPatterns = [...options.allow ?? [], ...context.config.diff.allow ?? []];
|
|
2501
|
+
const allowedByRel = /* @__PURE__ */ new Map();
|
|
2502
|
+
for (const rel of byRel.keys()) {
|
|
2503
|
+
allowedByRel.set(rel, isAllowedByDiffAllowlist(rel, allowPatterns));
|
|
2504
|
+
}
|
|
2505
|
+
const entries = [...byRel.entries()].map(([rel, result]) => ({ rel, result, allowed: allowedByRel.get(rel) ?? false })).sort((a, b) => b.result.risk.score - a.result.risk.score);
|
|
2506
|
+
const gateResults = entries.filter((entry) => !entry.allowed).map((entry) => entry.result);
|
|
2507
|
+
const counts = countLevels(gateResults);
|
|
2508
|
+
const allowedCount = entries.filter((entry) => entry.allowed).length;
|
|
2509
|
+
const blocked = gateResults.some((result) => LEVEL_INDEX3[result.risk.level] >= GATE_INDEX3[gate]);
|
|
1911
2510
|
const durationMs = Date.now() - started;
|
|
1912
2511
|
if (format === "json") {
|
|
1913
2512
|
ctx2.writer.write(
|
|
@@ -1916,9 +2515,11 @@ async function diffCommand(options, ctx2) {
|
|
|
1916
2515
|
changed.baseLabel,
|
|
1917
2516
|
changed.files,
|
|
1918
2517
|
byRel,
|
|
2518
|
+
allowedByRel,
|
|
1919
2519
|
gate,
|
|
1920
2520
|
blocked,
|
|
1921
2521
|
counts,
|
|
2522
|
+
allowedCount,
|
|
1922
2523
|
durationMs,
|
|
1923
2524
|
ctx2.version
|
|
1924
2525
|
)
|
|
@@ -1926,10 +2527,26 @@ async function diffCommand(options, ctx2) {
|
|
|
1926
2527
|
);
|
|
1927
2528
|
return blocked ? ExitCode.Failure : ExitCode.Success;
|
|
1928
2529
|
}
|
|
2530
|
+
if (format === "sarif") {
|
|
2531
|
+
ctx2.writer.write(
|
|
2532
|
+
serializeJson(
|
|
2533
|
+
buildDiffSarif({
|
|
2534
|
+
baseLabel: changed.baseLabel,
|
|
2535
|
+
entries,
|
|
2536
|
+
gate,
|
|
2537
|
+
counts,
|
|
2538
|
+
blocked,
|
|
2539
|
+
durationMs,
|
|
2540
|
+
version: ctx2.version
|
|
2541
|
+
})
|
|
2542
|
+
)
|
|
2543
|
+
);
|
|
2544
|
+
return blocked ? ExitCode.Failure : ExitCode.Success;
|
|
2545
|
+
}
|
|
1929
2546
|
if (format === "github") {
|
|
1930
2547
|
const annotations = [
|
|
1931
2548
|
...buildFileAnnotations(
|
|
1932
|
-
[...byRel.entries()].map(([file, result]) => ({ file, result })),
|
|
2549
|
+
[...byRel.entries()].filter(([rel]) => !(allowedByRel.get(rel) ?? false)).map(([file, result]) => ({ file, result })),
|
|
1933
2550
|
gate
|
|
1934
2551
|
),
|
|
1935
2552
|
buildGateAnnotation(blocked, gate, counts)
|
|
@@ -1943,9 +2560,10 @@ async function diffCommand(options, ctx2) {
|
|
|
1943
2560
|
cwd: ctx2.cwd,
|
|
1944
2561
|
baseLabel: changed.baseLabel,
|
|
1945
2562
|
files: changed.files,
|
|
1946
|
-
|
|
2563
|
+
entries,
|
|
1947
2564
|
skipped,
|
|
1948
2565
|
counts,
|
|
2566
|
+
allowedCount,
|
|
1949
2567
|
gate,
|
|
1950
2568
|
blocked,
|
|
1951
2569
|
durationMs,
|
|
@@ -1956,12 +2574,25 @@ async function diffCommand(options, ctx2) {
|
|
|
1956
2574
|
return blocked ? ExitCode.Failure : ExitCode.Success;
|
|
1957
2575
|
}
|
|
1958
2576
|
function renderDiffReport(input, writer) {
|
|
1959
|
-
const {
|
|
2577
|
+
const {
|
|
2578
|
+
cwd,
|
|
2579
|
+
baseLabel,
|
|
2580
|
+
files,
|
|
2581
|
+
entries,
|
|
2582
|
+
skipped,
|
|
2583
|
+
counts,
|
|
2584
|
+
allowedCount,
|
|
2585
|
+
gate,
|
|
2586
|
+
blocked,
|
|
2587
|
+
durationMs,
|
|
2588
|
+
style
|
|
2589
|
+
} = input;
|
|
1960
2590
|
writer.writeLine(brandHeader({ label: `diff vs ${baseLabel}`, style }));
|
|
1961
2591
|
writer.writeLine();
|
|
1962
2592
|
const rows = [
|
|
1963
2593
|
["Changed", pluralize(files.length, "file")],
|
|
1964
|
-
["Source", pluralize(
|
|
2594
|
+
["Source", pluralize(entries.length, "file")],
|
|
2595
|
+
["Allowed", pluralize(allowedCount, "file")],
|
|
1965
2596
|
["Duration", dim(formatDuration(durationMs), style)]
|
|
1966
2597
|
];
|
|
1967
2598
|
const width = Math.max(...rows.map(([key]) => key.length));
|
|
@@ -1978,16 +2609,17 @@ function renderDiffReport(input, writer) {
|
|
|
1978
2609
|
`${markText} ${verdictText} \u2014 ${riskySummary(counts, gate)} ${dim(`(${VERDICT_NOTE[gate]})`, style)}`
|
|
1979
2610
|
);
|
|
1980
2611
|
writer.writeLine();
|
|
1981
|
-
writer.writeLine(sectionHeader(`Risk analysis (${
|
|
1982
|
-
for (const result of
|
|
1983
|
-
const risky =
|
|
2612
|
+
writer.writeLine(sectionHeader(`Risk analysis (${entries.length} files)`, style));
|
|
2613
|
+
for (const { result, allowed } of entries) {
|
|
2614
|
+
const risky = LEVEL_INDEX3[result.risk.level] >= GATE_INDEX3[gate];
|
|
1984
2615
|
const fileIcon = risky ? icon("cross") : icon("tick");
|
|
1985
2616
|
const fileIconText = style.color ? risky ? chalk.red(fileIcon) : chalk.green(fileIcon) : fileIcon;
|
|
1986
2617
|
const score = result.risk.score.toFixed(1);
|
|
1987
2618
|
const gauge = riskGauge(result.risk.score, result.risk.level, style);
|
|
1988
2619
|
const file = displayPath(result.targetPath, cwd);
|
|
2620
|
+
const allowedTag = allowed ? ` ${dim("(allowed)", style)}` : "";
|
|
1989
2621
|
writer.writeLine(
|
|
1990
|
-
`${fileIconText} ${file} ${riskBadge(result.risk.level, style)} \xB7 ${score}/100${gauge ? ` ${gauge}` : ""}`
|
|
2622
|
+
`${fileIconText} ${file} ${riskBadge(result.risk.level, style)} \xB7 ${score}/100${gauge ? ` ${gauge}` : ""}${allowedTag}`
|
|
1991
2623
|
);
|
|
1992
2624
|
}
|
|
1993
2625
|
for (const rel of skipped) {
|
|
@@ -1997,7 +2629,7 @@ function renderDiffReport(input, writer) {
|
|
|
1997
2629
|
}
|
|
1998
2630
|
writer.writeLine();
|
|
1999
2631
|
}
|
|
2000
|
-
function buildDiffJsonReport(baseLabel, changedFileNames, byRel, gate, blocked, counts, durationMs, version) {
|
|
2632
|
+
function buildDiffJsonReport(baseLabel, changedFileNames, byRel, allowedByRel, gate, blocked, counts, allowedCount, durationMs, version) {
|
|
2001
2633
|
const files = [];
|
|
2002
2634
|
for (const rel of changedFileNames) {
|
|
2003
2635
|
const result = byRel.get(rel);
|
|
@@ -2007,7 +2639,8 @@ function buildDiffJsonReport(baseLabel, changedFileNames, byRel, gate, blocked,
|
|
|
2007
2639
|
analyzed: true,
|
|
2008
2640
|
risk: result.risk,
|
|
2009
2641
|
affectedFiles: result.summary.affectedFiles,
|
|
2010
|
-
targetInCycle: result.targetInCycle
|
|
2642
|
+
targetInCycle: result.targetInCycle,
|
|
2643
|
+
allowed: allowedByRel.get(rel) ?? false
|
|
2011
2644
|
});
|
|
2012
2645
|
} else {
|
|
2013
2646
|
files.push({ file: rel, analyzed: false });
|
|
@@ -2021,20 +2654,468 @@ function buildDiffJsonReport(baseLabel, changedFileNames, byRel, gate, blocked,
|
|
|
2021
2654
|
base: baseLabel,
|
|
2022
2655
|
changedFiles: changedFileNames.length,
|
|
2023
2656
|
files,
|
|
2024
|
-
gate: { level: gate, blocked, counts },
|
|
2657
|
+
gate: { level: gate, blocked, counts, allowed: allowedCount },
|
|
2025
2658
|
durationMs
|
|
2026
2659
|
};
|
|
2027
2660
|
}
|
|
2028
2661
|
|
|
2662
|
+
// src/mcp/server.ts
|
|
2663
|
+
var PROTOCOL_VERSION = "2025-06-18";
|
|
2664
|
+
var ERR_PARSE = -32700;
|
|
2665
|
+
var ERR_INVALID_REQUEST = -32600;
|
|
2666
|
+
var ERR_INTERNAL = -32603;
|
|
2667
|
+
function isObject(value) {
|
|
2668
|
+
return typeof value === "object" && value !== null;
|
|
2669
|
+
}
|
|
2670
|
+
var McpServer = class {
|
|
2671
|
+
options;
|
|
2672
|
+
tools = /* @__PURE__ */ new Map();
|
|
2673
|
+
constructor(options) {
|
|
2674
|
+
this.options = options;
|
|
2675
|
+
for (const tool of options.tools) {
|
|
2676
|
+
this.tools.set(tool.name, tool);
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* Process one inbound message. Returns the JSON-RPC response line to write
|
|
2681
|
+
* back, or `null` for notifications and invalid requests.
|
|
2682
|
+
*/
|
|
2683
|
+
async handleLine(line) {
|
|
2684
|
+
let message;
|
|
2685
|
+
try {
|
|
2686
|
+
message = JSON.parse(line);
|
|
2687
|
+
} catch {
|
|
2688
|
+
return this.response(void 0, {
|
|
2689
|
+
code: ERR_PARSE,
|
|
2690
|
+
message: "Parse error: not valid JSON"
|
|
2691
|
+
});
|
|
2692
|
+
}
|
|
2693
|
+
if (!isObject(message) || message.jsonrpc !== "2.0") {
|
|
2694
|
+
return this.response(void 0, {
|
|
2695
|
+
code: ERR_INVALID_REQUEST,
|
|
2696
|
+
message: "Invalid Request: expected a JSON-RPC 2.0 object"
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
const hasId = "id" in message;
|
|
2700
|
+
if (message.method === "notifications/initialized") {
|
|
2701
|
+
return null;
|
|
2702
|
+
}
|
|
2703
|
+
if (!hasId) {
|
|
2704
|
+
return null;
|
|
2705
|
+
}
|
|
2706
|
+
const method = message.method;
|
|
2707
|
+
if (typeof method !== "string") {
|
|
2708
|
+
return this.response(message.id, {
|
|
2709
|
+
code: ERR_INVALID_REQUEST,
|
|
2710
|
+
message: "Invalid Request: method must be a string"
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
try {
|
|
2714
|
+
const result = await this.dispatch(method, message.params);
|
|
2715
|
+
return this.response(message.id, result);
|
|
2716
|
+
} catch (error) {
|
|
2717
|
+
return this.response(message.id, {
|
|
2718
|
+
code: ERR_INTERNAL,
|
|
2719
|
+
message: error instanceof Error ? error.message : "Internal error"
|
|
2720
|
+
});
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
async dispatch(method, params) {
|
|
2724
|
+
switch (method) {
|
|
2725
|
+
case "initialize":
|
|
2726
|
+
return {
|
|
2727
|
+
protocolVersion: this.protocolVersion(params),
|
|
2728
|
+
capabilities: { tools: { listChanged: false } },
|
|
2729
|
+
serverInfo: { name: this.options.name, version: this.options.version }
|
|
2730
|
+
};
|
|
2731
|
+
case "ping":
|
|
2732
|
+
return {};
|
|
2733
|
+
case "tools/list":
|
|
2734
|
+
return {
|
|
2735
|
+
tools: [...this.tools.values()].map((tool) => ({
|
|
2736
|
+
name: tool.name,
|
|
2737
|
+
description: tool.description,
|
|
2738
|
+
inputSchema: tool.inputSchema
|
|
2739
|
+
}))
|
|
2740
|
+
};
|
|
2741
|
+
case "tools/call":
|
|
2742
|
+
return this.callTool(params);
|
|
2743
|
+
case "tools/list_changed":
|
|
2744
|
+
throw new Error("Tool list changes are not supported by this server");
|
|
2745
|
+
default:
|
|
2746
|
+
throw new Error(`Method not found: ${method}`);
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
protocolVersion(params) {
|
|
2750
|
+
if (isObject(params) && typeof params.protocolVersion === "string") {
|
|
2751
|
+
return params.protocolVersion;
|
|
2752
|
+
}
|
|
2753
|
+
return PROTOCOL_VERSION;
|
|
2754
|
+
}
|
|
2755
|
+
async callTool(params) {
|
|
2756
|
+
if (!isObject(params) || typeof params.name !== "string") {
|
|
2757
|
+
throw new Error("Invalid params: expected { name: string, arguments?: object }");
|
|
2758
|
+
}
|
|
2759
|
+
const tool = this.tools.get(params.name);
|
|
2760
|
+
if (!tool) {
|
|
2761
|
+
throw new Error(`Unknown tool: ${params.name}`);
|
|
2762
|
+
}
|
|
2763
|
+
const args = isObject(params.arguments) || params.arguments === void 0 ? params.arguments ?? {} : null;
|
|
2764
|
+
if (args === null) {
|
|
2765
|
+
throw new Error(`Invalid arguments for tool ${params.name}: expected an object`);
|
|
2766
|
+
}
|
|
2767
|
+
try {
|
|
2768
|
+
const result = await tool.handler(args);
|
|
2769
|
+
return {
|
|
2770
|
+
content: [{ type: "text", text: result.text }],
|
|
2771
|
+
...result.isError ? { isError: true } : {}
|
|
2772
|
+
};
|
|
2773
|
+
} catch (error) {
|
|
2774
|
+
return {
|
|
2775
|
+
content: [
|
|
2776
|
+
{
|
|
2777
|
+
type: "text",
|
|
2778
|
+
text: error instanceof Error ? error.message : "Tool failed"
|
|
2779
|
+
}
|
|
2780
|
+
],
|
|
2781
|
+
isError: true
|
|
2782
|
+
};
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
response(id, payload) {
|
|
2786
|
+
if (typeof payload === "object" && payload !== null && "code" in payload && typeof payload.code === "number") {
|
|
2787
|
+
const errorPayload = payload;
|
|
2788
|
+
const error = { code: errorPayload.code, message: String(errorPayload.message) };
|
|
2789
|
+
return `${JSON.stringify({ jsonrpc: "2.0", id: id ?? null, error })}
|
|
2790
|
+
`;
|
|
2791
|
+
}
|
|
2792
|
+
return `${JSON.stringify({ jsonrpc: "2.0", id: id ?? null, result: payload })}
|
|
2793
|
+
`;
|
|
2794
|
+
}
|
|
2795
|
+
};
|
|
2796
|
+
var MAX_DEPENDENTS = 200;
|
|
2797
|
+
function ok(text) {
|
|
2798
|
+
return { text };
|
|
2799
|
+
}
|
|
2800
|
+
function fail(error) {
|
|
2801
|
+
return { text: error instanceof Error ? error.message : "Tool failed", isError: true };
|
|
2802
|
+
}
|
|
2803
|
+
function requireString(args, key, tool) {
|
|
2804
|
+
const value = args[key];
|
|
2805
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
2806
|
+
throw new RippleError(`Missing required argument "${key}" for ${tool}`, ExitCode.Failure);
|
|
2807
|
+
}
|
|
2808
|
+
return value;
|
|
2809
|
+
}
|
|
2810
|
+
function optionalPositiveInt(args, key, tool) {
|
|
2811
|
+
const value = args[key];
|
|
2812
|
+
if (value === void 0) return void 0;
|
|
2813
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
2814
|
+
throw new RippleError(
|
|
2815
|
+
`Argument "${key}" for ${tool} must be a positive integer`,
|
|
2816
|
+
ExitCode.Failure
|
|
2817
|
+
);
|
|
2818
|
+
}
|
|
2819
|
+
return value;
|
|
2820
|
+
}
|
|
2821
|
+
function resolveFile(cwd, file) {
|
|
2822
|
+
return path8.isAbsolute(file) ? file : path8.join(cwd, file);
|
|
2823
|
+
}
|
|
2824
|
+
function analyzeIn(project, cwd, file, maxDepth) {
|
|
2825
|
+
const { context, graph, entryPoints } = project;
|
|
2826
|
+
const targetPath = resolveFile(cwd, file);
|
|
2827
|
+
const key = pathKey(targetPath, context.rootDir);
|
|
2828
|
+
const node = graph.nodes.get(key);
|
|
2829
|
+
if (!node) {
|
|
2830
|
+
throw new RippleError(
|
|
2831
|
+
`File not found in the project graph: ${file}. Is it a source file covered by ripple's discovery?`,
|
|
2832
|
+
ExitCode.NotFound
|
|
2833
|
+
);
|
|
2834
|
+
}
|
|
2835
|
+
return analyzeFile({
|
|
2836
|
+
graph,
|
|
2837
|
+
context,
|
|
2838
|
+
entryPoints,
|
|
2839
|
+
targetKey: key,
|
|
2840
|
+
targetPath,
|
|
2841
|
+
durationMs: 0,
|
|
2842
|
+
...maxDepth !== void 0 ? { maxDepth } : {}
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
function relPath(targetPath, cwd) {
|
|
2846
|
+
const rel = targetPath.slice(cwd.length).replace(/^[/\\]+/, "");
|
|
2847
|
+
return (rel || targetPath).replace(/\\/g, "/");
|
|
2848
|
+
}
|
|
2849
|
+
var IMPACT_SCHEMA = {
|
|
2850
|
+
type: "object",
|
|
2851
|
+
properties: {
|
|
2852
|
+
file: {
|
|
2853
|
+
type: "string",
|
|
2854
|
+
description: "Project-relative or absolute path of the file to analyze."
|
|
2855
|
+
},
|
|
2856
|
+
maxDepth: {
|
|
2857
|
+
type: "integer",
|
|
2858
|
+
minimum: 1,
|
|
2859
|
+
description: "Cap the reverse traversal depth (default: unlimited)."
|
|
2860
|
+
}
|
|
2861
|
+
},
|
|
2862
|
+
required: ["file"]
|
|
2863
|
+
};
|
|
2864
|
+
var DEPENDENTS_SCHEMA = {
|
|
2865
|
+
type: "object",
|
|
2866
|
+
properties: {
|
|
2867
|
+
file: {
|
|
2868
|
+
type: "string",
|
|
2869
|
+
description: "Project-relative or absolute path of the file."
|
|
2870
|
+
},
|
|
2871
|
+
depth: {
|
|
2872
|
+
type: "integer",
|
|
2873
|
+
minimum: 1,
|
|
2874
|
+
description: "Max dependency depth to report (default: 1, direct dependents only)."
|
|
2875
|
+
}
|
|
2876
|
+
},
|
|
2877
|
+
required: ["file"]
|
|
2878
|
+
};
|
|
2879
|
+
var RISK_SCHEMA = {
|
|
2880
|
+
type: "object",
|
|
2881
|
+
properties: {
|
|
2882
|
+
file: {
|
|
2883
|
+
type: "string",
|
|
2884
|
+
description: "Project-relative or absolute path of the file."
|
|
2885
|
+
}
|
|
2886
|
+
},
|
|
2887
|
+
required: ["file"]
|
|
2888
|
+
};
|
|
2889
|
+
var GATE_SCHEMA = {
|
|
2890
|
+
type: "object",
|
|
2891
|
+
properties: {
|
|
2892
|
+
base: {
|
|
2893
|
+
type: "string",
|
|
2894
|
+
description: "Git ref to diff against (default: origin/main, main, or HEAD~1)."
|
|
2895
|
+
},
|
|
2896
|
+
gate: {
|
|
2897
|
+
type: "string",
|
|
2898
|
+
enum: ["medium", "high", "critical"],
|
|
2899
|
+
description: "Risk level that blocks the merge (default: high)."
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
};
|
|
2903
|
+
var LEVEL_INDEX4 = { LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 };
|
|
2904
|
+
function createMcpTools(deps) {
|
|
2905
|
+
const { cwd, loadProject, getChanged } = deps;
|
|
2906
|
+
return [
|
|
2907
|
+
{
|
|
2908
|
+
name: "impact",
|
|
2909
|
+
description: "Blast-radius analysis of a file: how many files, routes, tests and components would be affected by changing it, plus its risk score. Call before editing or refactoring a file so the change set stays low-risk.",
|
|
2910
|
+
inputSchema: IMPACT_SCHEMA,
|
|
2911
|
+
handler: async (args) => {
|
|
2912
|
+
try {
|
|
2913
|
+
const file = requireString(args, "file", "impact");
|
|
2914
|
+
const maxDepth = optionalPositiveInt(args, "maxDepth", "impact");
|
|
2915
|
+
const project = await loadProject();
|
|
2916
|
+
const result = analyzeIn(project, cwd, file, maxDepth);
|
|
2917
|
+
return ok(
|
|
2918
|
+
JSON.stringify(
|
|
2919
|
+
{
|
|
2920
|
+
file: relPath(result.targetPath, cwd),
|
|
2921
|
+
risk: { score: result.risk.score, level: result.risk.level },
|
|
2922
|
+
summary: result.summary,
|
|
2923
|
+
targetInCycle: result.targetInCycle
|
|
2924
|
+
},
|
|
2925
|
+
null,
|
|
2926
|
+
2
|
|
2927
|
+
)
|
|
2928
|
+
);
|
|
2929
|
+
} catch (error) {
|
|
2930
|
+
return fail(error);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
},
|
|
2934
|
+
{
|
|
2935
|
+
name: "dependents",
|
|
2936
|
+
description: "List the files that depend on a file (its importers), up to a depth. Depth 1 is direct dependents; deeper levels are transitive. Useful to know who breaks when the file changes.",
|
|
2937
|
+
inputSchema: DEPENDENTS_SCHEMA,
|
|
2938
|
+
handler: async (args) => {
|
|
2939
|
+
try {
|
|
2940
|
+
const file = requireString(args, "file", "dependents");
|
|
2941
|
+
const depth = optionalPositiveInt(args, "depth", "dependents") ?? 1;
|
|
2942
|
+
const project = await loadProject();
|
|
2943
|
+
const result = analyzeIn(project, cwd, file);
|
|
2944
|
+
const dependents = [...result.affected.values()].filter((affected) => affected.depth <= depth).sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path)).map((affected) => ({
|
|
2945
|
+
path: relPath(affected.path, cwd),
|
|
2946
|
+
depth: affected.depth,
|
|
2947
|
+
direct: affected.direct,
|
|
2948
|
+
inCycle: affected.inCycle,
|
|
2949
|
+
categories: affected.categories
|
|
2950
|
+
}));
|
|
2951
|
+
return ok(
|
|
2952
|
+
JSON.stringify(
|
|
2953
|
+
{
|
|
2954
|
+
file: relPath(result.targetPath, cwd),
|
|
2955
|
+
depth,
|
|
2956
|
+
count: dependents.length,
|
|
2957
|
+
truncated: dependents.length > MAX_DEPENDENTS,
|
|
2958
|
+
dependents: dependents.slice(0, MAX_DEPENDENTS)
|
|
2959
|
+
},
|
|
2960
|
+
null,
|
|
2961
|
+
2
|
|
2962
|
+
)
|
|
2963
|
+
);
|
|
2964
|
+
} catch (error) {
|
|
2965
|
+
return fail(error);
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
},
|
|
2969
|
+
{
|
|
2970
|
+
name: "risk",
|
|
2971
|
+
description: "The risk score (0-100) of changing a file and the factor breakdown behind it. Cheap way to compare the danger of different refactor targets.",
|
|
2972
|
+
inputSchema: RISK_SCHEMA,
|
|
2973
|
+
handler: async (args) => {
|
|
2974
|
+
try {
|
|
2975
|
+
const file = requireString(args, "file", "risk");
|
|
2976
|
+
const project = await loadProject();
|
|
2977
|
+
const result = analyzeIn(project, cwd, file);
|
|
2978
|
+
return ok(
|
|
2979
|
+
JSON.stringify(
|
|
2980
|
+
{
|
|
2981
|
+
file: relPath(result.targetPath, cwd),
|
|
2982
|
+
score: result.risk.score,
|
|
2983
|
+
level: result.risk.level,
|
|
2984
|
+
targetInCycle: result.targetInCycle,
|
|
2985
|
+
factors: result.risk.factors
|
|
2986
|
+
},
|
|
2987
|
+
null,
|
|
2988
|
+
2
|
|
2989
|
+
)
|
|
2990
|
+
);
|
|
2991
|
+
} catch (error) {
|
|
2992
|
+
return fail(error);
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
},
|
|
2996
|
+
{
|
|
2997
|
+
name: "gate_status",
|
|
2998
|
+
description: "Check the current change set against the ripple merge gate: which files changed since the base ref, their risk levels, and whether the gate passes or blocks. Call after editing files to verify the changes are safe to merge.",
|
|
2999
|
+
inputSchema: GATE_SCHEMA,
|
|
3000
|
+
handler: async (args) => {
|
|
3001
|
+
try {
|
|
3002
|
+
const base = args.base;
|
|
3003
|
+
const gate = args.gate;
|
|
3004
|
+
if (base !== void 0 && typeof base !== "string") {
|
|
3005
|
+
return fail(
|
|
3006
|
+
new RippleError('Argument "base" for gate_status must be a string', ExitCode.Failure)
|
|
3007
|
+
);
|
|
3008
|
+
}
|
|
3009
|
+
if (gate !== void 0 && gate !== "medium" && gate !== "high" && gate !== "critical") {
|
|
3010
|
+
return fail(
|
|
3011
|
+
new RippleError(
|
|
3012
|
+
'Argument "gate" for gate_status must be medium | high | critical',
|
|
3013
|
+
ExitCode.Failure
|
|
3014
|
+
)
|
|
3015
|
+
);
|
|
3016
|
+
}
|
|
3017
|
+
const changed = getChanged(base);
|
|
3018
|
+
const project = await loadProject();
|
|
3019
|
+
const resolvedGate = gate ?? project.context.config.diff.gate ?? "high";
|
|
3020
|
+
const gateIndex = LEVEL_INDEX4[resolvedGate.toUpperCase()] ?? 3;
|
|
3021
|
+
const files = [];
|
|
3022
|
+
const counts = { low: 0, medium: 0, high: 0, critical: 0 };
|
|
3023
|
+
let blocked = false;
|
|
3024
|
+
for (const rel of changed.files) {
|
|
3025
|
+
try {
|
|
3026
|
+
const result = analyzeIn(project, cwd, rel);
|
|
3027
|
+
const level = result.risk.level;
|
|
3028
|
+
counts[level.toLowerCase()] += 1;
|
|
3029
|
+
if ((LEVEL_INDEX4[level] ?? 1) >= gateIndex) blocked = true;
|
|
3030
|
+
files.push({
|
|
3031
|
+
file: rel,
|
|
3032
|
+
analyzed: true,
|
|
3033
|
+
level,
|
|
3034
|
+
score: result.risk.score,
|
|
3035
|
+
affectedFiles: result.summary.affectedFiles,
|
|
3036
|
+
targetInCycle: result.targetInCycle
|
|
3037
|
+
});
|
|
3038
|
+
} catch {
|
|
3039
|
+
files.push({ file: rel, analyzed: false });
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
return ok(
|
|
3043
|
+
JSON.stringify(
|
|
3044
|
+
{
|
|
3045
|
+
base: changed.baseLabel,
|
|
3046
|
+
changedFiles: changed.files.length,
|
|
3047
|
+
files,
|
|
3048
|
+
counts,
|
|
3049
|
+
gate: { level: resolvedGate, blocked, verdict: blocked ? "block" : "pass" }
|
|
3050
|
+
},
|
|
3051
|
+
null,
|
|
3052
|
+
2
|
|
3053
|
+
)
|
|
3054
|
+
);
|
|
3055
|
+
} catch (error) {
|
|
3056
|
+
return fail(error);
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
];
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
// src/commands/mcp.ts
|
|
3064
|
+
async function mcpCommand(options, ctx2) {
|
|
3065
|
+
let snapshot = null;
|
|
3066
|
+
const loadProject = async () => {
|
|
3067
|
+
if (!snapshot) {
|
|
3068
|
+
const context = await loadProjectContext(ctx2.cwd, options.config);
|
|
3069
|
+
const { graph, entryPoints } = await runPipeline(context);
|
|
3070
|
+
snapshot = { context, graph, entryPoints };
|
|
3071
|
+
}
|
|
3072
|
+
return snapshot;
|
|
3073
|
+
};
|
|
3074
|
+
const server = new McpServer({
|
|
3075
|
+
name: "ripple",
|
|
3076
|
+
version: ctx2.version,
|
|
3077
|
+
tools: createMcpTools({
|
|
3078
|
+
cwd: ctx2.cwd,
|
|
3079
|
+
loadProject,
|
|
3080
|
+
getChanged: (base) => changedFiles(ctx2.cwd, base)
|
|
3081
|
+
})
|
|
3082
|
+
});
|
|
3083
|
+
const stdin = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
3084
|
+
await new Promise((resolve) => {
|
|
3085
|
+
stdin.on("line", (line) => {
|
|
3086
|
+
if (!line.trim()) return;
|
|
3087
|
+
void server.handleLine(line).then((response) => {
|
|
3088
|
+
if (response) process.stdout.write(response);
|
|
3089
|
+
});
|
|
3090
|
+
});
|
|
3091
|
+
stdin.on("close", () => resolve());
|
|
3092
|
+
});
|
|
3093
|
+
return ExitCode.Success;
|
|
3094
|
+
}
|
|
3095
|
+
|
|
2029
3096
|
// src/cli/program.ts
|
|
2030
3097
|
var PROGRAM_NAME = "ripple";
|
|
3098
|
+
function helpExamples(examples) {
|
|
3099
|
+
const lines = examples.map((example) => ` ${example}`);
|
|
3100
|
+
return `
|
|
3101
|
+
Examples:
|
|
3102
|
+
${lines.join("\n")}
|
|
3103
|
+
`;
|
|
3104
|
+
}
|
|
2031
3105
|
function createProgram(ctx2) {
|
|
2032
3106
|
const program = new Command().name(PROGRAM_NAME).description("Dependency impact analysis for TypeScript and JavaScript projects.").version(ctx2.version, "-V, --version", "print the Ripple version").showHelpAfterError();
|
|
2033
|
-
program.command("analyze <file>").description("Analyze the impact of changing a file").
|
|
3107
|
+
program.command("analyze <file>").description("Analyze the impact of changing a file").addHelpText(
|
|
3108
|
+
"after",
|
|
3109
|
+
helpExamples([
|
|
3110
|
+
"ripple analyze src/authentication/login.ts",
|
|
3111
|
+
"ripple analyze src/index.ts --depth 3"
|
|
3112
|
+
])
|
|
3113
|
+
).option("-j, --json", "emit a machine-readable JSON report").option("--sarif", "emit a SARIF 2.1.0 report for code scanning").option("-v, --verbose", "include the risk factor breakdown").option("-d, --depth <number>", "cap the reverse traversal depth", parsePositiveInt).option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (file, options) => {
|
|
2034
3114
|
const exitCode2 = await analyzeCommand(
|
|
2035
3115
|
file,
|
|
2036
3116
|
{
|
|
2037
3117
|
json: Boolean(options.json),
|
|
3118
|
+
sarif: Boolean(options.sarif),
|
|
2038
3119
|
verbose: Boolean(options.verbose),
|
|
2039
3120
|
...options.depth !== void 0 ? { depth: options.depth } : {},
|
|
2040
3121
|
...options.config !== void 0 ? { config: options.config } : {},
|
|
@@ -2046,11 +3127,23 @@ function createProgram(ctx2) {
|
|
|
2046
3127
|
throw new ExitError(exitCode2);
|
|
2047
3128
|
}
|
|
2048
3129
|
});
|
|
2049
|
-
program.command("graph [file]").description("Show project graph stats, or the dependency tree of one file").
|
|
3130
|
+
program.command("graph [file]").description("Show project graph stats, or the dependency tree of one file").addHelpText(
|
|
3131
|
+
"after",
|
|
3132
|
+
helpExamples([
|
|
3133
|
+
"ripple graph",
|
|
3134
|
+
"ripple graph src/index.ts --reverse --depth 3",
|
|
3135
|
+
"ripple graph --format mermaid"
|
|
3136
|
+
])
|
|
3137
|
+
).option("-j, --json", "emit a machine-readable JSON report").option(
|
|
3138
|
+
"-f, --format <format>",
|
|
3139
|
+
"output format: terminal | json | mermaid | dot | html",
|
|
3140
|
+
parseGraphFormat
|
|
3141
|
+
).option("-v, --verbose", "include extra sections").option("-r, --reverse", "show dependents (what imports the file) instead of dependants").option("-d, --depth <number>", "cap the tree depth", parsePositiveInt).option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (file, options) => {
|
|
2050
3142
|
const exitCode2 = await graphCommand(
|
|
2051
3143
|
file,
|
|
2052
3144
|
{
|
|
2053
3145
|
json: Boolean(options.json),
|
|
3146
|
+
format: options.format ?? (options.json ? "json" : "terminal"),
|
|
2054
3147
|
verbose: Boolean(options.verbose),
|
|
2055
3148
|
reverse: Boolean(options.reverse),
|
|
2056
3149
|
...options.depth !== void 0 ? { depth: options.depth } : {},
|
|
@@ -2063,11 +3156,18 @@ function createProgram(ctx2) {
|
|
|
2063
3156
|
throw new ExitError(exitCode2);
|
|
2064
3157
|
}
|
|
2065
3158
|
});
|
|
2066
|
-
program.command("diff").description("Analyze changed files since a base ref and gate risky changes").
|
|
3159
|
+
program.command("diff").description("Analyze changed files since a base ref and gate risky changes").addHelpText(
|
|
3160
|
+
"after",
|
|
3161
|
+
helpExamples(["ripple diff --base main --gate critical", "ripple diff --format github"])
|
|
3162
|
+
).option("-j, --json", "emit a machine-readable JSON report").option(
|
|
3163
|
+
"-f, --format <format>",
|
|
3164
|
+
"output format: terminal | json | github | sarif",
|
|
3165
|
+
parseDiffFormat
|
|
3166
|
+
).option("-v, --verbose", "include extra sections").option("-b, --base <ref>", "git ref to diff against (default: origin/main, main, HEAD~1)").option(
|
|
2067
3167
|
"-g, --gate <level>",
|
|
2068
3168
|
"risk level that blocks the gate (medium | high | critical)",
|
|
2069
3169
|
parseGateLevel
|
|
2070
|
-
).option("-d, --depth <number>", "cap the reverse traversal depth", parsePositiveInt).option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (options) => {
|
|
3170
|
+
).option("-d, --depth <number>", "cap the reverse traversal depth", parsePositiveInt).option("-a, --allow <glob...>", "changed files matching these globs never block the gate").option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (options) => {
|
|
2071
3171
|
const exitCode2 = await diffCommand(
|
|
2072
3172
|
{
|
|
2073
3173
|
json: Boolean(options.json),
|
|
@@ -2076,6 +3176,7 @@ function createProgram(ctx2) {
|
|
|
2076
3176
|
...options.base !== void 0 ? { base: options.base } : {},
|
|
2077
3177
|
...options.gate !== void 0 ? { gate: options.gate } : {},
|
|
2078
3178
|
...options.depth !== void 0 ? { depth: options.depth } : {},
|
|
3179
|
+
...options.allow !== void 0 ? { allow: options.allow } : {},
|
|
2079
3180
|
...options.config !== void 0 ? { config: options.config } : {},
|
|
2080
3181
|
color: options.color
|
|
2081
3182
|
},
|
|
@@ -2085,7 +3186,7 @@ function createProgram(ctx2) {
|
|
|
2085
3186
|
throw new ExitError(exitCode2);
|
|
2086
3187
|
}
|
|
2087
3188
|
});
|
|
2088
|
-
program.command("doctor").description("Check the project and environment for Ripple readiness").option("-v, --verbose", "print detailed diagnostics").option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (options) => {
|
|
3189
|
+
program.command("doctor").description("Check the project and environment for Ripple readiness").addHelpText("after", helpExamples(["ripple doctor", "ripple doctor -v"])).option("-v, --verbose", "print detailed diagnostics").option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (options) => {
|
|
2089
3190
|
const exitCode2 = await doctorCommand(
|
|
2090
3191
|
{
|
|
2091
3192
|
verbose: Boolean(options.verbose),
|
|
@@ -2098,16 +3199,35 @@ function createProgram(ctx2) {
|
|
|
2098
3199
|
throw new ExitError(exitCode2);
|
|
2099
3200
|
}
|
|
2100
3201
|
});
|
|
2101
|
-
program.command("init").description("Create a ripple.config
|
|
3202
|
+
program.command("init").description("Create a ripple.config with the default settings").addHelpText("after", helpExamples(["ripple init --force", "ripple init --ts --full"])).option("-f, --force", "overwrite an existing config file").option("--full", "write every built-in default instead of a minimal config").option("--ts", "write a typed ripple.config.ts instead of JSON").option("--no-color", "disable ANSI colors").action(async (options) => {
|
|
2102
3203
|
const exitCode2 = await initCommand(
|
|
2103
|
-
{
|
|
3204
|
+
{
|
|
3205
|
+
force: Boolean(options.force),
|
|
3206
|
+
full: Boolean(options.full),
|
|
3207
|
+
ts: Boolean(options.ts),
|
|
3208
|
+
color: options.color
|
|
3209
|
+
},
|
|
3210
|
+
ctx2
|
|
3211
|
+
);
|
|
3212
|
+
if (exitCode2 !== ExitCode.Success) {
|
|
3213
|
+
throw new ExitError(exitCode2);
|
|
3214
|
+
}
|
|
3215
|
+
});
|
|
3216
|
+
program.command("mcp").description("Serve Ripple analysis tools over the Model Context Protocol (stdio)").addHelpText(
|
|
3217
|
+
"after",
|
|
3218
|
+
helpExamples(['ripple mcp # point an MCP client (Claude, Cursor, ...) at "ripple mcp"'])
|
|
3219
|
+
).option("-c, --config <path>", "path to a ripple config file").action(async (options) => {
|
|
3220
|
+
const exitCode2 = await mcpCommand(
|
|
3221
|
+
{
|
|
3222
|
+
config: options.config
|
|
3223
|
+
},
|
|
2104
3224
|
ctx2
|
|
2105
3225
|
);
|
|
2106
3226
|
if (exitCode2 !== ExitCode.Success) {
|
|
2107
3227
|
throw new ExitError(exitCode2);
|
|
2108
3228
|
}
|
|
2109
3229
|
});
|
|
2110
|
-
program.command("version").description("Print the Ripple version").action(() => {
|
|
3230
|
+
program.command("version").description("Print the Ripple version").addHelpText("after", helpExamples(["ripple version"])).action(() => {
|
|
2111
3231
|
ctx2.writer.writeLine(ctx2.version);
|
|
2112
3232
|
});
|
|
2113
3233
|
program.addHelpText(
|
|
@@ -2147,13 +3267,23 @@ function parseGateLevel(value) {
|
|
|
2147
3267
|
);
|
|
2148
3268
|
}
|
|
2149
3269
|
function parseDiffFormat(value) {
|
|
2150
|
-
if (value === "terminal" || value === "json" || value === "github") {
|
|
3270
|
+
if (value === "terminal" || value === "json" || value === "github" || value === "sarif") {
|
|
3271
|
+
return value;
|
|
3272
|
+
}
|
|
3273
|
+
throw new CommanderError(
|
|
3274
|
+
1,
|
|
3275
|
+
"commander.invalidArgument",
|
|
3276
|
+
`expected one of terminal | json | github | sarif, got "${value}"`
|
|
3277
|
+
);
|
|
3278
|
+
}
|
|
3279
|
+
function parseGraphFormat(value) {
|
|
3280
|
+
if (value === "terminal" || value === "json" || value === "mermaid" || value === "dot" || value === "html") {
|
|
2151
3281
|
return value;
|
|
2152
3282
|
}
|
|
2153
3283
|
throw new CommanderError(
|
|
2154
3284
|
1,
|
|
2155
3285
|
"commander.invalidArgument",
|
|
2156
|
-
`expected one of terminal | json |
|
|
3286
|
+
`expected one of terminal | json | mermaid | dot | html, got "${value}"`
|
|
2157
3287
|
);
|
|
2158
3288
|
}
|
|
2159
3289
|
function printError(error, ctx2) {
|
|
@@ -2189,7 +3319,7 @@ async function run({ argv, ctx: ctx2 }) {
|
|
|
2189
3319
|
|
|
2190
3320
|
// package.json
|
|
2191
3321
|
var package_default = {
|
|
2192
|
-
version: "0.
|
|
3322
|
+
version: "0.7.0"};
|
|
2193
3323
|
|
|
2194
3324
|
// src/cli/bin.ts
|
|
2195
3325
|
var ctx = {
|