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