@alimaandev/ripple 0.1.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/LICENSE +21 -0
- package/README.md +453 -0
- package/dist/bin.js +1829 -0
- package/dist/bin.js.map +1 -0
- package/package.json +70 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,1829 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { CommanderError, Command } from 'commander';
|
|
3
|
+
import { createJiti } from 'jiti';
|
|
4
|
+
import path8 from 'path';
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import fs2 from 'fs';
|
|
7
|
+
import { Project, ts, SyntaxKind } from 'ts-morph';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import ora from 'ora';
|
|
10
|
+
import treeify from 'treeify';
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
import fg from 'fast-glob';
|
|
13
|
+
|
|
14
|
+
// src/output/writer.ts
|
|
15
|
+
var TerminalWriter = class {
|
|
16
|
+
constructor(stream = process.stdout) {
|
|
17
|
+
this.stream = stream;
|
|
18
|
+
}
|
|
19
|
+
stream;
|
|
20
|
+
write(text) {
|
|
21
|
+
this.stream.write(text);
|
|
22
|
+
}
|
|
23
|
+
writeLine(text = "") {
|
|
24
|
+
this.stream.write(`${text}
|
|
25
|
+
`);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// src/types/cli.ts
|
|
30
|
+
var ExitCode = {
|
|
31
|
+
Success: 0,
|
|
32
|
+
Failure: 1,
|
|
33
|
+
NotFound: 2,
|
|
34
|
+
InvalidConfig: 3
|
|
35
|
+
};
|
|
36
|
+
async function pathExists(filePath) {
|
|
37
|
+
try {
|
|
38
|
+
await fs.access(filePath);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function pathExistsSync(filePath) {
|
|
45
|
+
try {
|
|
46
|
+
fs2.accessSync(filePath);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function readFileSafe(filePath) {
|
|
53
|
+
try {
|
|
54
|
+
return await fs.readFile(filePath, "utf8");
|
|
55
|
+
} catch {
|
|
56
|
+
return void 0;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function findPackageJson(startDir) {
|
|
60
|
+
let current = path8.resolve(startDir);
|
|
61
|
+
for (; ; ) {
|
|
62
|
+
const candidate = path8.join(current, "package.json");
|
|
63
|
+
if (await pathExists(candidate)) return candidate;
|
|
64
|
+
const parent = path8.dirname(current);
|
|
65
|
+
if (parent === current) return void 0;
|
|
66
|
+
current = parent;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function readPackageEntryPoints(packageJsonPath) {
|
|
70
|
+
const raw = await readFileSafe(packageJsonPath);
|
|
71
|
+
if (!raw) return [];
|
|
72
|
+
try {
|
|
73
|
+
const manifest = JSON.parse(raw);
|
|
74
|
+
const entries = [];
|
|
75
|
+
if (typeof manifest.main === "string") entries.push(manifest.main);
|
|
76
|
+
if (typeof manifest.bin === "string") entries.push(manifest.bin);
|
|
77
|
+
if (manifest.bin && typeof manifest.bin === "object")
|
|
78
|
+
entries.push(...Object.values(manifest.bin));
|
|
79
|
+
return entries;
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/utils/errors.ts
|
|
86
|
+
var RippleError = class extends Error {
|
|
87
|
+
exitCode;
|
|
88
|
+
hint;
|
|
89
|
+
constructor(message, exitCode2 = ExitCode.Failure, hint) {
|
|
90
|
+
super(message);
|
|
91
|
+
this.name = "RippleError";
|
|
92
|
+
this.exitCode = exitCode2;
|
|
93
|
+
this.hint = hint;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
function fileNotFound(filePath) {
|
|
97
|
+
return new RippleError(
|
|
98
|
+
`File not found: ${filePath}`,
|
|
99
|
+
ExitCode.NotFound,
|
|
100
|
+
"Run `ripple graph` to list the files Ripple can analyze."
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
function asStringArray(value) {
|
|
104
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
105
|
+
}
|
|
106
|
+
function readTsconfig(tsconfigPath) {
|
|
107
|
+
if (!pathExistsSync(tsconfigPath)) return void 0;
|
|
108
|
+
const readResult = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
|
109
|
+
if (readResult.error) return void 0;
|
|
110
|
+
const dir = path8.dirname(tsconfigPath);
|
|
111
|
+
const parsed = ts.parseJsonConfigFileContent(
|
|
112
|
+
readResult.config,
|
|
113
|
+
ts.sys,
|
|
114
|
+
dir,
|
|
115
|
+
void 0,
|
|
116
|
+
tsconfigPath
|
|
117
|
+
);
|
|
118
|
+
if (parsed.errors.length > 0) return void 0;
|
|
119
|
+
const options = parsed.options;
|
|
120
|
+
const paths = {};
|
|
121
|
+
if (options.paths) {
|
|
122
|
+
for (const [pattern, targets] of Object.entries(options.paths)) {
|
|
123
|
+
paths[pattern] = [...targets];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
path: tsconfigPath,
|
|
128
|
+
dir,
|
|
129
|
+
baseUrl: options.baseUrl ? path8.resolve(dir, options.baseUrl) : void 0,
|
|
130
|
+
paths,
|
|
131
|
+
include: asStringArray(readResult.config.include ?? []),
|
|
132
|
+
exclude: asStringArray(readResult.config.exclude ?? [])
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
var WILDCARD = "*";
|
|
136
|
+
function hasWildcard(value) {
|
|
137
|
+
return value.includes(WILDCARD);
|
|
138
|
+
}
|
|
139
|
+
function normalizeEntry(key, target, baseDir) {
|
|
140
|
+
const trimmedKey = key.replace(/\/+$/, "");
|
|
141
|
+
const trimmedTarget = target.replace(/\/+$/, "");
|
|
142
|
+
if (hasWildcard(trimmedKey)) {
|
|
143
|
+
return { pattern: trimmedKey, target: path8.resolve(baseDir, trimmedTarget) };
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
pattern: `${trimmedKey}/${WILDCARD}`,
|
|
147
|
+
target: path8.join(path8.resolve(baseDir, trimmedTarget), WILDCARD)
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function buildAliasMap(configAliases, tsconfig, rootDir) {
|
|
151
|
+
const merged = {};
|
|
152
|
+
if (tsconfig) {
|
|
153
|
+
const base = tsconfig.baseUrl ?? tsconfig.dir;
|
|
154
|
+
for (const [pattern, targets] of Object.entries(tsconfig.paths)) {
|
|
155
|
+
const first = targets[0];
|
|
156
|
+
if (!first) continue;
|
|
157
|
+
const absolute = path8.isAbsolute(first) ? first : path8.resolve(base, first);
|
|
158
|
+
merged[pattern] = absolute;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const [key, target] of Object.entries(configAliases)) {
|
|
162
|
+
if (key === "") continue;
|
|
163
|
+
const entry = normalizeEntry(key, target, rootDir);
|
|
164
|
+
merged[entry.pattern] = entry.target;
|
|
165
|
+
}
|
|
166
|
+
return merged;
|
|
167
|
+
}
|
|
168
|
+
function substituteAlias(specifier, aliases) {
|
|
169
|
+
for (const [pattern, target] of Object.entries(aliases)) {
|
|
170
|
+
const starIndex = pattern.indexOf(WILDCARD);
|
|
171
|
+
if (starIndex === -1) {
|
|
172
|
+
if (specifier === pattern) return target;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const prefix = pattern.slice(0, starIndex);
|
|
176
|
+
const suffix = pattern.slice(starIndex + 1);
|
|
177
|
+
if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue;
|
|
178
|
+
const captured = specifier.slice(prefix.length, specifier.length - suffix.length);
|
|
179
|
+
const targetStar = target.indexOf(WILDCARD);
|
|
180
|
+
if (targetStar === -1) return target;
|
|
181
|
+
return target.slice(0, targetStar) + captured + target.slice(targetStar + 1);
|
|
182
|
+
}
|
|
183
|
+
return void 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// src/config/defaults.ts
|
|
187
|
+
var DEFAULT_CONFIG = {
|
|
188
|
+
include: ["**/*.{ts,tsx,js,jsx}"],
|
|
189
|
+
ignore: ["node_modules", "dist", "build", "coverage", ".next", "out", ".git"],
|
|
190
|
+
aliases: {},
|
|
191
|
+
tsconfigPath: "tsconfig.json",
|
|
192
|
+
risk: {
|
|
193
|
+
weights: {
|
|
194
|
+
affectedFiles: 0.3,
|
|
195
|
+
entryPoint: 0.15,
|
|
196
|
+
sharedUtility: 0.15,
|
|
197
|
+
publicExports: 0.1,
|
|
198
|
+
tests: 0.1,
|
|
199
|
+
routes: 0.1,
|
|
200
|
+
cycleMembership: 0.1
|
|
201
|
+
},
|
|
202
|
+
thresholds: {
|
|
203
|
+
medium: 30,
|
|
204
|
+
high: 55,
|
|
205
|
+
critical: 80
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// src/config/schema.ts
|
|
211
|
+
var fraction = z.number().min(0).max(1);
|
|
212
|
+
var score = z.number().min(0).max(100);
|
|
213
|
+
var riskWeightsSchema = z.object({
|
|
214
|
+
affectedFiles: fraction.default(DEFAULT_CONFIG.risk.weights.affectedFiles),
|
|
215
|
+
entryPoint: fraction.default(DEFAULT_CONFIG.risk.weights.entryPoint),
|
|
216
|
+
sharedUtility: fraction.default(DEFAULT_CONFIG.risk.weights.sharedUtility),
|
|
217
|
+
publicExports: fraction.default(DEFAULT_CONFIG.risk.weights.publicExports),
|
|
218
|
+
tests: fraction.default(DEFAULT_CONFIG.risk.weights.tests),
|
|
219
|
+
routes: fraction.default(DEFAULT_CONFIG.risk.weights.routes),
|
|
220
|
+
cycleMembership: fraction.default(DEFAULT_CONFIG.risk.weights.cycleMembership)
|
|
221
|
+
}).strict();
|
|
222
|
+
var riskThresholdsSchema = z.object({
|
|
223
|
+
medium: score.default(DEFAULT_CONFIG.risk.thresholds.medium),
|
|
224
|
+
high: score.default(DEFAULT_CONFIG.risk.thresholds.high),
|
|
225
|
+
critical: score.default(DEFAULT_CONFIG.risk.thresholds.critical)
|
|
226
|
+
}).strict();
|
|
227
|
+
var riskConfigSchema = z.object({
|
|
228
|
+
weights: riskWeightsSchema.default(DEFAULT_CONFIG.risk.weights),
|
|
229
|
+
thresholds: riskThresholdsSchema.default(DEFAULT_CONFIG.risk.thresholds)
|
|
230
|
+
}).strict();
|
|
231
|
+
var rippleConfigSchema = z.object({
|
|
232
|
+
include: z.array(z.string().min(1)).min(1).default(DEFAULT_CONFIG.include),
|
|
233
|
+
ignore: z.array(z.string()).default(DEFAULT_CONFIG.ignore),
|
|
234
|
+
aliases: z.record(z.string(), z.string()).default(DEFAULT_CONFIG.aliases),
|
|
235
|
+
tsconfigPath: z.string().min(1).default(DEFAULT_CONFIG.tsconfigPath),
|
|
236
|
+
risk: riskConfigSchema.default(DEFAULT_CONFIG.risk)
|
|
237
|
+
}).strict();
|
|
238
|
+
function formatSchemaIssues(error) {
|
|
239
|
+
return error.issues.map((issue) => {
|
|
240
|
+
const path12 = issue.path.join(".");
|
|
241
|
+
const location = path12 === "" ? "config" : `config.${path12}`;
|
|
242
|
+
return `${location}: ${issue.message}`;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/config/loader.ts
|
|
247
|
+
var CONFIG_CANDIDATES = [
|
|
248
|
+
"ripple.config.ts",
|
|
249
|
+
"ripple.config.mjs",
|
|
250
|
+
"ripple.config.js",
|
|
251
|
+
"ripple.config.cjs",
|
|
252
|
+
"ripple.config.json"
|
|
253
|
+
];
|
|
254
|
+
var jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
255
|
+
function findConfigFile(cwd, explicitPath) {
|
|
256
|
+
if (explicitPath) return path8.resolve(cwd, explicitPath);
|
|
257
|
+
for (const candidate of CONFIG_CANDIDATES) {
|
|
258
|
+
const absolute = path8.join(cwd, candidate);
|
|
259
|
+
if (pathExistsSync(absolute)) return absolute;
|
|
260
|
+
}
|
|
261
|
+
return void 0;
|
|
262
|
+
}
|
|
263
|
+
async function importConfig(configPath) {
|
|
264
|
+
let loaded;
|
|
265
|
+
try {
|
|
266
|
+
loaded = await jiti.import(configPath);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
269
|
+
throw new RippleError(
|
|
270
|
+
`Could not load ${path8.basename(configPath)}: ${detail}`,
|
|
271
|
+
ExitCode.InvalidConfig
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const value = loaded?.default ?? loaded;
|
|
275
|
+
if (typeof value === "function") {
|
|
276
|
+
throw new RippleError(
|
|
277
|
+
`${path8.basename(configPath)} must export a plain object, not a function.`,
|
|
278
|
+
ExitCode.InvalidConfig
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return value;
|
|
282
|
+
}
|
|
283
|
+
function validateConfig(value) {
|
|
284
|
+
const result = rippleConfigSchema.safeParse(value);
|
|
285
|
+
if (!result.success) {
|
|
286
|
+
const issues = formatSchemaIssues(result.error);
|
|
287
|
+
throw new RippleError(
|
|
288
|
+
`Invalid config:
|
|
289
|
+
${issues.map((issue) => ` ${issue}`).join("\n")}`,
|
|
290
|
+
ExitCode.InvalidConfig
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return result.data;
|
|
294
|
+
}
|
|
295
|
+
async function loadConfig(cwd, explicitPath) {
|
|
296
|
+
const configPath = findConfigFile(cwd, explicitPath);
|
|
297
|
+
if (explicitPath && !configPath) {
|
|
298
|
+
throw new RippleError(
|
|
299
|
+
`Config file not found: ${explicitPath}`,
|
|
300
|
+
ExitCode.InvalidConfig,
|
|
301
|
+
"Pass a path that exists, or remove --config to use defaults."
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
if (!configPath) {
|
|
305
|
+
return { config: rippleConfigSchema.parse({}), rootDir: path8.resolve(cwd) };
|
|
306
|
+
}
|
|
307
|
+
const raw = await importConfig(configPath);
|
|
308
|
+
const config = validateConfig(raw);
|
|
309
|
+
return { config, configPath, rootDir: path8.dirname(configPath) };
|
|
310
|
+
}
|
|
311
|
+
async function loadProjectContext(cwd, explicitConfigPath) {
|
|
312
|
+
const loaded = await loadConfig(cwd, explicitConfigPath);
|
|
313
|
+
const { config, rootDir } = loaded;
|
|
314
|
+
const tsconfigPath = path8.resolve(rootDir, config.tsconfigPath);
|
|
315
|
+
const tsconfig = readTsconfig(tsconfigPath);
|
|
316
|
+
const aliases = buildAliasMap(config.aliases, tsconfig, rootDir);
|
|
317
|
+
return {
|
|
318
|
+
rootDir,
|
|
319
|
+
config,
|
|
320
|
+
...tsconfig ? { tsconfig } : {},
|
|
321
|
+
aliases
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/types/graph.ts
|
|
326
|
+
function cycleMemberPaths(graph) {
|
|
327
|
+
const members = /* @__PURE__ */ new Set();
|
|
328
|
+
for (const cycle of graph.cycles) {
|
|
329
|
+
for (const path12 of cycle.members) {
|
|
330
|
+
members.add(path12);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return members;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// src/analyzer/confidence.ts
|
|
337
|
+
function computeConfidence(stats, targetInCycle) {
|
|
338
|
+
const parseRate = stats.files === 0 ? 1 : stats.parsed / stats.files;
|
|
339
|
+
const totalEdges = stats.internalEdges + stats.unresolvedEdges;
|
|
340
|
+
const resolutionRate = totalEdges === 0 ? 1 : stats.internalEdges / totalEdges;
|
|
341
|
+
let confidence = parseRate * (0.4 + 0.6 * resolutionRate);
|
|
342
|
+
if (targetInCycle) confidence *= 0.95;
|
|
343
|
+
return Math.round(Math.min(1, Math.max(0, confidence)) * 100);
|
|
344
|
+
}
|
|
345
|
+
var SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
|
346
|
+
var RESOLVABLE_EXTENSIONS = [...SOURCE_EXTENSIONS, ".mjs", ".cjs", ".d.ts"];
|
|
347
|
+
function toPosix(filePath) {
|
|
348
|
+
return filePath.replace(/\\/g, "/");
|
|
349
|
+
}
|
|
350
|
+
function normalizeAbsolute(filePath, cwd) {
|
|
351
|
+
return path8.resolve(cwd, filePath);
|
|
352
|
+
}
|
|
353
|
+
function pathKey(filePath, cwd) {
|
|
354
|
+
const absolute = normalizeAbsolute(filePath, cwd);
|
|
355
|
+
const posix = toPosix(absolute);
|
|
356
|
+
return process.platform === "win32" ? posix.toLowerCase() : posix;
|
|
357
|
+
}
|
|
358
|
+
function sourceFileKind(filePath) {
|
|
359
|
+
if (/\.d\.[cm]?ts$/i.test(filePath)) return void 0;
|
|
360
|
+
const ext = path8.extname(filePath).toLowerCase();
|
|
361
|
+
return SOURCE_EXTENSIONS.includes(ext) ? ext.slice(1) : void 0;
|
|
362
|
+
}
|
|
363
|
+
function extensionCandidates(basePath) {
|
|
364
|
+
return RESOLVABLE_EXTENSIONS.map((ext) => basePath + ext);
|
|
365
|
+
}
|
|
366
|
+
function jsToTsCandidate(candidate) {
|
|
367
|
+
const ext = path8.extname(candidate);
|
|
368
|
+
if (ext === ".js") return candidate.slice(0, -3) + ".ts";
|
|
369
|
+
if (ext === ".jsx") return candidate.slice(0, -4) + ".tsx";
|
|
370
|
+
if (ext === ".mjs") return candidate.slice(0, -4) + ".mts";
|
|
371
|
+
if (ext === ".cjs") return candidate.slice(0, -4) + ".cts";
|
|
372
|
+
return candidate;
|
|
373
|
+
}
|
|
374
|
+
function displayPath(filePath, cwd) {
|
|
375
|
+
const relative = path8.relative(cwd, filePath);
|
|
376
|
+
return toPosix(relative === "" ? path8.basename(filePath) : relative);
|
|
377
|
+
}
|
|
378
|
+
function firstSegment(filePath, root) {
|
|
379
|
+
const relative = toPosix(path8.relative(root, filePath));
|
|
380
|
+
if (relative.startsWith("..") || path8.isAbsolute(relative)) return void 0;
|
|
381
|
+
const segment = relative.split("/")[0];
|
|
382
|
+
return segment && segment !== "" ? segment : void 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// src/analyzer/categorize.ts
|
|
386
|
+
var ROUTE_FILE_NAMES = /* @__PURE__ */ new Set([
|
|
387
|
+
"route",
|
|
388
|
+
"page",
|
|
389
|
+
"layout",
|
|
390
|
+
"middleware",
|
|
391
|
+
"loading",
|
|
392
|
+
"error",
|
|
393
|
+
"not-found"
|
|
394
|
+
]);
|
|
395
|
+
var ROUTE_DIR_NAMES = /* @__PURE__ */ new Set(["routes", "api"]);
|
|
396
|
+
var UTILITY_DIR_NAMES = /* @__PURE__ */ new Set(["lib", "utils", "helpers", "shared", "common", "core"]);
|
|
397
|
+
var COMPONENT_DIR_NAMES = /* @__PURE__ */ new Set(["components", "ui"]);
|
|
398
|
+
var ENTRY_FILE_NAMES = /* @__PURE__ */ new Set(["index", "main", "app", "server", "cli", "bin"]);
|
|
399
|
+
var TEST_SEGMENT = "__tests__";
|
|
400
|
+
function relativeSegments(filePath, rootDir) {
|
|
401
|
+
const relative = toPosix(path8.relative(rootDir, filePath));
|
|
402
|
+
if (relative.startsWith("..")) return [];
|
|
403
|
+
return relative.split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
404
|
+
}
|
|
405
|
+
function isRouteFile(basename) {
|
|
406
|
+
return ROUTE_FILE_NAMES.has(basename);
|
|
407
|
+
}
|
|
408
|
+
function isTestFile(name, segments) {
|
|
409
|
+
if (segments.includes(TEST_SEGMENT)) return true;
|
|
410
|
+
if (/(^|[._-])(test|spec)([._-]|$)/i.test(name)) return true;
|
|
411
|
+
return segments.some((segment) => segment === "tests");
|
|
412
|
+
}
|
|
413
|
+
function isComponentFile(basename, extension, segments) {
|
|
414
|
+
if (extension === "tsx" || extension === "jsx") return true;
|
|
415
|
+
if (!COMPONENT_DIR_NAMES.has(segments.at(-2) ?? "")) return false;
|
|
416
|
+
return /^[A-Z]/.test(basename);
|
|
417
|
+
}
|
|
418
|
+
function isUtilityFile(segments) {
|
|
419
|
+
return segments.some((segment) => UTILITY_DIR_NAMES.has(segment));
|
|
420
|
+
}
|
|
421
|
+
function isEntryFile(filePath, rootDir, entryPoints) {
|
|
422
|
+
if (entryPoints.has(path8.normalize(filePath))) return true;
|
|
423
|
+
const relative = toPosix(path8.relative(rootDir, filePath));
|
|
424
|
+
if (relative.startsWith("..")) return false;
|
|
425
|
+
const segments = relative.split("/");
|
|
426
|
+
if (segments.length !== 2) return false;
|
|
427
|
+
if (segments[0] !== "src" && segments[0] !== "") return false;
|
|
428
|
+
const basename = segments[1] ?? "";
|
|
429
|
+
const dot = basename.lastIndexOf(".");
|
|
430
|
+
const name = dot === -1 ? basename : basename.slice(0, dot);
|
|
431
|
+
return ENTRY_FILE_NAMES.has(name);
|
|
432
|
+
}
|
|
433
|
+
function createCategorizer(options) {
|
|
434
|
+
const { rootDir, entryPoints } = options;
|
|
435
|
+
return (filePath) => {
|
|
436
|
+
const segments = relativeSegments(filePath, rootDir);
|
|
437
|
+
const basename = segments.at(-1) ?? "";
|
|
438
|
+
const dot = basename.lastIndexOf(".");
|
|
439
|
+
const name = dot === -1 ? basename : basename.slice(0, dot);
|
|
440
|
+
const extension = dot === -1 ? "" : basename.slice(dot + 1).toLowerCase();
|
|
441
|
+
const categories = [];
|
|
442
|
+
if (isRouteFile(name)) categories.push("route");
|
|
443
|
+
else if (segments.some((segment) => ROUTE_DIR_NAMES.has(segment))) categories.push("route");
|
|
444
|
+
if (isTestFile(name, segments)) categories.push("test");
|
|
445
|
+
if (isComponentFile(name, extension, segments)) categories.push("component");
|
|
446
|
+
if (isUtilityFile(segments)) categories.push("utility");
|
|
447
|
+
if (isEntryFile(filePath, rootDir, entryPoints)) categories.push("entry");
|
|
448
|
+
if (categories.length === 0) categories.push("other");
|
|
449
|
+
return categories;
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
async function detectEntryPoints(rootDir) {
|
|
453
|
+
const entryPoints = /* @__PURE__ */ new Set();
|
|
454
|
+
const packageJsonPath = await findPackageJson(rootDir);
|
|
455
|
+
if (packageJsonPath) {
|
|
456
|
+
const packageDir = path8.dirname(packageJsonPath);
|
|
457
|
+
for (const entry of await readPackageEntryPoints(packageJsonPath)) {
|
|
458
|
+
const absolute = path8.resolve(packageDir, entry);
|
|
459
|
+
if (pathExistsSync(absolute)) entryPoints.add(path8.normalize(absolute));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const baseDirs = [rootDir, path8.join(rootDir, "src")];
|
|
463
|
+
for (const baseDir of baseDirs) {
|
|
464
|
+
for (const name of ENTRY_FILE_NAMES) {
|
|
465
|
+
for (const extension of ["ts", "tsx", "js", "jsx"]) {
|
|
466
|
+
const candidate = path8.join(baseDir, `${name}.${extension}`);
|
|
467
|
+
if (pathExistsSync(candidate)) entryPoints.add(path8.normalize(candidate));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return entryPoints;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// src/analyzer/humanize.ts
|
|
475
|
+
var ACRONYM_CANONICAL = {
|
|
476
|
+
api: "API",
|
|
477
|
+
ui: "UI",
|
|
478
|
+
ux: "UX",
|
|
479
|
+
oauth: "OAuth",
|
|
480
|
+
jwt: "JWT",
|
|
481
|
+
http: "HTTP",
|
|
482
|
+
https: "HTTPS",
|
|
483
|
+
ws: "WS",
|
|
484
|
+
sse: "SSE",
|
|
485
|
+
db: "DB",
|
|
486
|
+
sql: "SQL",
|
|
487
|
+
id: "ID",
|
|
488
|
+
cli: "CLI",
|
|
489
|
+
ci: "CI",
|
|
490
|
+
cd: "CD",
|
|
491
|
+
csv: "CSV",
|
|
492
|
+
json: "JSON",
|
|
493
|
+
pdf: "PDF",
|
|
494
|
+
cors: "CORS",
|
|
495
|
+
url: "URL",
|
|
496
|
+
uri: "URI",
|
|
497
|
+
uuid: "UUID",
|
|
498
|
+
html: "HTML",
|
|
499
|
+
css: "CSS",
|
|
500
|
+
js: "JS",
|
|
501
|
+
ts: "TS",
|
|
502
|
+
tsx: "TSX",
|
|
503
|
+
jsx: "JSX",
|
|
504
|
+
aws: "AWS",
|
|
505
|
+
s3: "S3",
|
|
506
|
+
sms: "SMS",
|
|
507
|
+
mfa: "MFA",
|
|
508
|
+
otp: "OTP",
|
|
509
|
+
smtp: "SMTP",
|
|
510
|
+
rss: "RSS"
|
|
511
|
+
};
|
|
512
|
+
function splitWords(segment) {
|
|
513
|
+
const normalized = segment.replace(/[^a-zA-Z0-9]+/g, " ");
|
|
514
|
+
const matches = normalized.match(/[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+/g);
|
|
515
|
+
return (matches ?? [segment]).map((word) => word.toLowerCase());
|
|
516
|
+
}
|
|
517
|
+
function capitalize(word) {
|
|
518
|
+
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
519
|
+
}
|
|
520
|
+
function humanizeArea(segment) {
|
|
521
|
+
const words = splitWords(segment);
|
|
522
|
+
if (words.length === 0) return capitalize(segment);
|
|
523
|
+
return words.map((word) => ACRONYM_CANONICAL[word] ?? capitalize(word)).join(" ");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/analyzer/impact.ts
|
|
527
|
+
var MAX_IMPACT_AREAS = 5;
|
|
528
|
+
function buildSummary(affected, srcRoot, confidence) {
|
|
529
|
+
let routes = 0;
|
|
530
|
+
let tests = 0;
|
|
531
|
+
let components = 0;
|
|
532
|
+
let utilities = 0;
|
|
533
|
+
let entries = 0;
|
|
534
|
+
let maxDepth = 0;
|
|
535
|
+
const areaCounts = /* @__PURE__ */ new Map();
|
|
536
|
+
for (const file of affected.values()) {
|
|
537
|
+
if (file.depth > maxDepth) maxDepth = file.depth;
|
|
538
|
+
if (file.categories.includes("route")) routes++;
|
|
539
|
+
if (file.categories.includes("test")) tests++;
|
|
540
|
+
if (file.categories.includes("component")) components++;
|
|
541
|
+
if (file.categories.includes("utility")) utilities++;
|
|
542
|
+
if (file.categories.includes("entry")) entries++;
|
|
543
|
+
const area = firstSegment(file.path, srcRoot);
|
|
544
|
+
if (area) {
|
|
545
|
+
areaCounts.set(area, (areaCounts.get(area) ?? 0) + 1);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const topImpact = [...areaCounts.entries()].map(([label, count]) => ({ label: humanizeArea(label), count })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)).slice(0, MAX_IMPACT_AREAS);
|
|
549
|
+
return {
|
|
550
|
+
affectedFiles: affected.size,
|
|
551
|
+
routes,
|
|
552
|
+
tests,
|
|
553
|
+
components,
|
|
554
|
+
utilities,
|
|
555
|
+
entries,
|
|
556
|
+
maxDepth,
|
|
557
|
+
topImpact,
|
|
558
|
+
confidence
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// src/analyzer/reverse-traverse.ts
|
|
563
|
+
function reverseTraverse(graph, targetKey, options) {
|
|
564
|
+
const { maxDepth, categorize } = options;
|
|
565
|
+
const affected = /* @__PURE__ */ new Map();
|
|
566
|
+
const visited = /* @__PURE__ */ new Set([targetKey]);
|
|
567
|
+
const cycleMembers = cycleMemberPaths(graph);
|
|
568
|
+
if (!graph.reverse.has(targetKey)) {
|
|
569
|
+
return { affected, maxDepth: 0 };
|
|
570
|
+
}
|
|
571
|
+
const queue = [];
|
|
572
|
+
for (const importer of graph.reverse.get(targetKey) ?? []) {
|
|
573
|
+
queue.push({ key: importer, depth: 1 });
|
|
574
|
+
}
|
|
575
|
+
let deepest = 0;
|
|
576
|
+
while (queue.length > 0) {
|
|
577
|
+
const current = queue.shift();
|
|
578
|
+
if (current === void 0) break;
|
|
579
|
+
if (visited.has(current.key)) continue;
|
|
580
|
+
visited.add(current.key);
|
|
581
|
+
deepest = Math.max(deepest, current.depth);
|
|
582
|
+
const node = graph.nodes.get(current.key);
|
|
583
|
+
const filePath = node?.path ?? current.key;
|
|
584
|
+
affected.set(current.key, {
|
|
585
|
+
path: filePath,
|
|
586
|
+
depth: current.depth,
|
|
587
|
+
direct: current.depth === 1,
|
|
588
|
+
categories: categorize(filePath),
|
|
589
|
+
inCycle: cycleMembers.has(current.key)
|
|
590
|
+
});
|
|
591
|
+
if (maxDepth === void 0 || current.depth < maxDepth) {
|
|
592
|
+
for (const importer of graph.reverse.get(current.key) ?? []) {
|
|
593
|
+
if (!visited.has(importer)) {
|
|
594
|
+
queue.push({ key: importer, depth: current.depth + 1 });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return { affected, maxDepth: deepest };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/risk/levels.ts
|
|
603
|
+
function scoreToLevel(score2, thresholds) {
|
|
604
|
+
if (score2 >= thresholds.critical) return "CRITICAL";
|
|
605
|
+
if (score2 >= thresholds.high) return "HIGH";
|
|
606
|
+
if (score2 >= thresholds.medium) return "MEDIUM";
|
|
607
|
+
return "LOW";
|
|
608
|
+
}
|
|
609
|
+
function clamp(value, min, max) {
|
|
610
|
+
return Math.min(max, Math.max(min, value));
|
|
611
|
+
}
|
|
612
|
+
function logScale(count, cap) {
|
|
613
|
+
if (count <= 0) return 0;
|
|
614
|
+
return clamp(Math.log10(count + 1) / Math.log10(cap + 1), 0, 1);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// src/risk/score.ts
|
|
618
|
+
var CAP_AFFECTED_FILES = 100;
|
|
619
|
+
var CAP_UTILITIES = 10;
|
|
620
|
+
var CAP_TESTS = 15;
|
|
621
|
+
var CAP_ROUTES = 10;
|
|
622
|
+
var CAP_EXPORTS = 20;
|
|
623
|
+
var FACTORS = [
|
|
624
|
+
{
|
|
625
|
+
name: "affectedFiles",
|
|
626
|
+
label: "Affected files",
|
|
627
|
+
defaultWeight: 0.3,
|
|
628
|
+
compute: ({ summary }) => logScale(summary.affectedFiles, CAP_AFFECTED_FILES)
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
name: "entryPoint",
|
|
632
|
+
label: "Entry point impacted",
|
|
633
|
+
defaultWeight: 0.15,
|
|
634
|
+
compute: ({ summary }) => summary.entries > 0 ? 1 : 0
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: "sharedUtility",
|
|
638
|
+
label: "Shared utilities impacted",
|
|
639
|
+
defaultWeight: 0.15,
|
|
640
|
+
compute: ({ summary }) => clamp(summary.utilities / CAP_UTILITIES, 0, 1)
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
name: "publicExports",
|
|
644
|
+
label: "Public export surface",
|
|
645
|
+
defaultWeight: 0.1,
|
|
646
|
+
compute: ({ targetParsed }) => {
|
|
647
|
+
const { exports } = targetParsed;
|
|
648
|
+
const count = exports.named.length + (exports.hasDefault ? 1 : 0) + exports.reExportedFrom.length;
|
|
649
|
+
return clamp(count / CAP_EXPORTS, 0, 1);
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
name: "tests",
|
|
654
|
+
label: "Tests affected",
|
|
655
|
+
defaultWeight: 0.1,
|
|
656
|
+
compute: ({ summary }) => clamp(summary.tests / CAP_TESTS, 0, 1)
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
name: "routes",
|
|
660
|
+
label: "Routes affected",
|
|
661
|
+
defaultWeight: 0.1,
|
|
662
|
+
compute: ({ summary }) => clamp(summary.routes / CAP_ROUTES, 0, 1)
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
name: "cycleMembership",
|
|
666
|
+
label: "Circular dependency",
|
|
667
|
+
defaultWeight: 0.1,
|
|
668
|
+
compute: ({ targetInCycle }) => targetInCycle ? 1 : 0
|
|
669
|
+
}
|
|
670
|
+
];
|
|
671
|
+
function weightFor(definition, input) {
|
|
672
|
+
const configured = input.riskConfig.weights[definition.name];
|
|
673
|
+
return configured ?? definition.defaultWeight;
|
|
674
|
+
}
|
|
675
|
+
function scoreRisk(input) {
|
|
676
|
+
const factors = FACTORS.map((definition) => {
|
|
677
|
+
const weight = weightFor(definition, input);
|
|
678
|
+
const value = definition.compute(input);
|
|
679
|
+
return {
|
|
680
|
+
name: definition.name,
|
|
681
|
+
label: definition.label,
|
|
682
|
+
weight,
|
|
683
|
+
value,
|
|
684
|
+
contribution: Math.round(weight * value * 100 * 100) / 100
|
|
685
|
+
};
|
|
686
|
+
});
|
|
687
|
+
const score2 = clamp(
|
|
688
|
+
Math.round(factors.reduce((sum, factor) => sum + factor.contribution, 0) * 100) / 100,
|
|
689
|
+
0,
|
|
690
|
+
100
|
|
691
|
+
);
|
|
692
|
+
return {
|
|
693
|
+
score: score2,
|
|
694
|
+
level: scoreToLevel(score2, input.riskConfig.thresholds),
|
|
695
|
+
factors
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// src/analyzer/analyze.ts
|
|
700
|
+
function sourceRoot(rootDir) {
|
|
701
|
+
const candidate = path8.join(rootDir, "src");
|
|
702
|
+
return pathExistsSync(candidate) ? candidate : rootDir;
|
|
703
|
+
}
|
|
704
|
+
function analyzeFile(input) {
|
|
705
|
+
const { graph, context, entryPoints, targetKey, targetPath, durationMs, maxDepth } = input;
|
|
706
|
+
const node = graph.nodes.get(targetKey);
|
|
707
|
+
if (!node) {
|
|
708
|
+
throw new Error(`Target file is not part of the graph: ${targetPath}`);
|
|
709
|
+
}
|
|
710
|
+
const categorize = createCategorizer({ rootDir: context.rootDir, entryPoints });
|
|
711
|
+
const { affected, maxDepth: reachedDepth } = reverseTraverse(graph, targetKey, {
|
|
712
|
+
maxDepth,
|
|
713
|
+
categorize
|
|
714
|
+
});
|
|
715
|
+
const targetInCycle = cycleMemberPaths(graph).has(targetKey);
|
|
716
|
+
const confidence = computeConfidence(graph.stats, targetInCycle);
|
|
717
|
+
const summary = buildSummary(affected, sourceRoot(context.rootDir), confidence);
|
|
718
|
+
const risk = scoreRisk({
|
|
719
|
+
summary,
|
|
720
|
+
targetParsed: node.parsed,
|
|
721
|
+
targetInCycle,
|
|
722
|
+
riskConfig: context.config.risk
|
|
723
|
+
});
|
|
724
|
+
return {
|
|
725
|
+
targetPath,
|
|
726
|
+
rootDir: context.rootDir,
|
|
727
|
+
graph,
|
|
728
|
+
affected,
|
|
729
|
+
summary,
|
|
730
|
+
risk,
|
|
731
|
+
targetInCycle,
|
|
732
|
+
durationMs,
|
|
733
|
+
maxDepth: reachedDepth
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
function createSpinner(enabled) {
|
|
737
|
+
if (!enabled || !process.stdout.isTTY) {
|
|
738
|
+
return { start: () => {
|
|
739
|
+
}, stop: () => {
|
|
740
|
+
} };
|
|
741
|
+
}
|
|
742
|
+
let instance;
|
|
743
|
+
return {
|
|
744
|
+
start(text) {
|
|
745
|
+
instance = ora(text).start();
|
|
746
|
+
},
|
|
747
|
+
stop() {
|
|
748
|
+
instance?.stop();
|
|
749
|
+
instance = void 0;
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/formatter/table.ts
|
|
755
|
+
function renderTable(columns, rows) {
|
|
756
|
+
const widths = columns.map((column, index) => {
|
|
757
|
+
const cells = [column.header, ...rows.map((row) => row[index] ?? "")];
|
|
758
|
+
return Math.max(...cells.map((cell) => cell.length));
|
|
759
|
+
});
|
|
760
|
+
const pad = (value, index) => {
|
|
761
|
+
const width = widths[index] ?? value.length;
|
|
762
|
+
return columns[index]?.align === "right" ? value.padStart(width) : value.padEnd(width);
|
|
763
|
+
};
|
|
764
|
+
const lines = [];
|
|
765
|
+
lines.push(columns.map((column, index) => pad(column.header, index)).join(" "));
|
|
766
|
+
for (const row of rows) {
|
|
767
|
+
lines.push(row.map((cell, index) => pad(cell, index)).join(" "));
|
|
768
|
+
}
|
|
769
|
+
return lines.join("\n");
|
|
770
|
+
}
|
|
771
|
+
function renderKeyValue(entries) {
|
|
772
|
+
const width = Math.max(...entries.map(([key]) => key.length));
|
|
773
|
+
return entries.map(([key, value]) => `${key.padEnd(width)} ${value}`).join("\n");
|
|
774
|
+
}
|
|
775
|
+
function toTreeify(node) {
|
|
776
|
+
if (!node.children || node.children.length === 0) return "";
|
|
777
|
+
const branch = {};
|
|
778
|
+
for (const child of node.children) {
|
|
779
|
+
branch[child.label] = toTreeify(child);
|
|
780
|
+
}
|
|
781
|
+
return branch;
|
|
782
|
+
}
|
|
783
|
+
function renderTree(nodes) {
|
|
784
|
+
if (nodes.length === 0) return "";
|
|
785
|
+
const root = {};
|
|
786
|
+
for (const node of nodes) {
|
|
787
|
+
root[node.label] = toTreeify(node);
|
|
788
|
+
}
|
|
789
|
+
return treeify.asTree(root, false, false).trimEnd();
|
|
790
|
+
}
|
|
791
|
+
function banner(text, style) {
|
|
792
|
+
return style.color ? chalk.cyanBright.bold(text) : text;
|
|
793
|
+
}
|
|
794
|
+
function sectionTitle(text, style) {
|
|
795
|
+
return style.color ? chalk.bold.underline(text) : text;
|
|
796
|
+
}
|
|
797
|
+
function dim(text, style) {
|
|
798
|
+
return style.color ? chalk.dim(text) : text;
|
|
799
|
+
}
|
|
800
|
+
function strong(text, style) {
|
|
801
|
+
return style.color ? chalk.bold(text) : text;
|
|
802
|
+
}
|
|
803
|
+
var RISK_COLORS = {
|
|
804
|
+
LOW: (text) => chalk.green(text),
|
|
805
|
+
MEDIUM: (text) => chalk.yellow(text),
|
|
806
|
+
HIGH: (text) => chalk.magenta(text),
|
|
807
|
+
CRITICAL: (text) => chalk.red.bold(text)
|
|
808
|
+
};
|
|
809
|
+
function riskBadge(level, style) {
|
|
810
|
+
return style.color ? RISK_COLORS[level](level) : level;
|
|
811
|
+
}
|
|
812
|
+
function confidenceText(percent, style) {
|
|
813
|
+
const text = `${percent}%`;
|
|
814
|
+
if (!style.color) return text;
|
|
815
|
+
if (percent >= 90) return chalk.green(text);
|
|
816
|
+
if (percent >= 70) return chalk.yellow(text);
|
|
817
|
+
return chalk.red(text);
|
|
818
|
+
}
|
|
819
|
+
function formatDuration(ms) {
|
|
820
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
821
|
+
return `${(ms / 1e3).toFixed(2)}s`;
|
|
822
|
+
}
|
|
823
|
+
function pluralize(count, noun, plural = `${noun}s`) {
|
|
824
|
+
return `${count} ${count === 1 ? noun : plural}`;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// src/output/report.ts
|
|
828
|
+
var DEFAULT_LIST_LIMIT = 20;
|
|
829
|
+
var textStyle = (options) => ({ color: options.color });
|
|
830
|
+
function fileLabel(filePath, depth, inCycle, style) {
|
|
831
|
+
const relative = dim(filePath, style);
|
|
832
|
+
const depthSuffix = dim(`(depth ${depth})`, style);
|
|
833
|
+
const cycleSuffix = dim("(cycle)", style);
|
|
834
|
+
return `${relative} ${depthSuffix}${inCycle ? ` ${cycleSuffix}` : ""}`;
|
|
835
|
+
}
|
|
836
|
+
function buildAffectedTree(result, cwd, style) {
|
|
837
|
+
const { affected, graph } = result;
|
|
838
|
+
const sorted = [...affected.entries()].sort(
|
|
839
|
+
([, a], [, b]) => a.depth - b.depth || a.path.localeCompare(b.path)
|
|
840
|
+
);
|
|
841
|
+
const nodesByKey = /* @__PURE__ */ new Map();
|
|
842
|
+
const depthByKey = /* @__PURE__ */ new Map();
|
|
843
|
+
for (const [key, file] of sorted) {
|
|
844
|
+
const node = { label: "", children: [] };
|
|
845
|
+
node.label = fileLabel(displayPath(file.path, cwd), file.depth, file.inCycle, style);
|
|
846
|
+
nodesByKey.set(key, node);
|
|
847
|
+
depthByKey.set(key, file.depth);
|
|
848
|
+
}
|
|
849
|
+
const roots = [];
|
|
850
|
+
for (const [key, file] of sorted) {
|
|
851
|
+
const node = nodesByKey.get(key);
|
|
852
|
+
if (!node) continue;
|
|
853
|
+
if (file.depth === 1) {
|
|
854
|
+
roots.push(node);
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
const importers = graph.reverse.get(key) ?? [];
|
|
858
|
+
const parents = [...importers].filter(
|
|
859
|
+
(candidate) => depthByKey.get(candidate) === file.depth - 1 && nodesByKey.has(candidate)
|
|
860
|
+
).sort((a, b) => (depthByKey.get(a) ?? 0) - (depthByKey.get(b) ?? 0) || a.localeCompare(b));
|
|
861
|
+
const parent = parents[0] ? nodesByKey.get(parents[0]) : void 0;
|
|
862
|
+
if (parent) {
|
|
863
|
+
parent.children.push(node);
|
|
864
|
+
} else {
|
|
865
|
+
roots.push(node);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return roots;
|
|
869
|
+
}
|
|
870
|
+
function renderAnalyzeReport(result, options, writer) {
|
|
871
|
+
const style = textStyle(options);
|
|
872
|
+
const { summary, risk } = result;
|
|
873
|
+
const file = displayPath(result.targetPath, options.cwd);
|
|
874
|
+
writer.writeLine(banner("\u{1F30A} Ripple Analysis", style));
|
|
875
|
+
writer.writeLine();
|
|
876
|
+
writer.writeLine(
|
|
877
|
+
renderKeyValue([
|
|
878
|
+
["File", file],
|
|
879
|
+
["Risk", riskBadge(risk.level, style)],
|
|
880
|
+
["Affected Files", pluralize(summary.affectedFiles, "file")],
|
|
881
|
+
["Components", String(summary.components)],
|
|
882
|
+
["API Routes", String(summary.routes)],
|
|
883
|
+
["Tests", String(summary.tests)],
|
|
884
|
+
["Utilities", String(summary.utilities)],
|
|
885
|
+
["Max Depth", String(summary.maxDepth)],
|
|
886
|
+
["Confidence", confidenceText(summary.confidence, style)],
|
|
887
|
+
["Duration", dim(formatDuration(result.durationMs), style)]
|
|
888
|
+
])
|
|
889
|
+
);
|
|
890
|
+
writer.writeLine();
|
|
891
|
+
writer.writeLine(sectionTitle("Top Impact", style));
|
|
892
|
+
for (const area of summary.topImpact) {
|
|
893
|
+
writer.writeLine(`\u2022 ${strong(area.label, style)} ${dim(`(${area.count})`, style)}`);
|
|
894
|
+
}
|
|
895
|
+
writer.writeLine();
|
|
896
|
+
writer.writeLine(sectionTitle("Circular Dependencies", style));
|
|
897
|
+
if (result.graph.cycles.length === 0) {
|
|
898
|
+
writer.writeLine(dim("none", style));
|
|
899
|
+
} else {
|
|
900
|
+
for (const cycle of result.graph.cycles) {
|
|
901
|
+
const chain = cycle.path.map((key) => {
|
|
902
|
+
const node = result.graph.nodes.get(key);
|
|
903
|
+
return displayPath(node?.path ?? key, options.cwd);
|
|
904
|
+
}).join(" \u2192 ");
|
|
905
|
+
writer.writeLine(`\u2B55 ${chain}`);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
writer.writeLine();
|
|
909
|
+
if (summary.affectedFiles > 0) {
|
|
910
|
+
writer.writeLine(sectionTitle(`Affected Files (${summary.affectedFiles})`, style));
|
|
911
|
+
const tree = buildAffectedTree(result, options.cwd, style);
|
|
912
|
+
writer.writeLine(renderTree(tree));
|
|
913
|
+
const limit = options.listLimit ?? DEFAULT_LIST_LIMIT;
|
|
914
|
+
if (summary.affectedFiles > limit) {
|
|
915
|
+
writer.writeLine(
|
|
916
|
+
dim(`${summary.affectedFiles - limit} more\u2026 use --json for the full list`, style)
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
writer.writeLine();
|
|
920
|
+
}
|
|
921
|
+
if (options.verbose) {
|
|
922
|
+
writer.writeLine(sectionTitle("Risk Factors", style));
|
|
923
|
+
const columns = [
|
|
924
|
+
{ header: "Factor" },
|
|
925
|
+
{ header: "Weight" },
|
|
926
|
+
{ header: "Signal" },
|
|
927
|
+
{ header: "Points" }
|
|
928
|
+
];
|
|
929
|
+
const rows = risk.factors.map((factor) => [
|
|
930
|
+
factor.label,
|
|
931
|
+
factor.weight.toFixed(2),
|
|
932
|
+
factor.value.toFixed(2),
|
|
933
|
+
factor.contribution.toFixed(2)
|
|
934
|
+
]);
|
|
935
|
+
writer.writeLine(renderTable(columns, rows));
|
|
936
|
+
writer.writeLine();
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
function buildAnalyzeJsonReport(result, version, cwd) {
|
|
940
|
+
const realPath = (key) => result.graph.nodes.get(key)?.path ?? key;
|
|
941
|
+
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) }));
|
|
942
|
+
return {
|
|
943
|
+
tool: "ripple",
|
|
944
|
+
version,
|
|
945
|
+
command: "analyze",
|
|
946
|
+
file: displayPath(result.targetPath, cwd),
|
|
947
|
+
risk: result.risk,
|
|
948
|
+
summary: result.summary,
|
|
949
|
+
affected,
|
|
950
|
+
cycles: result.graph.cycles.map((cycle) => ({
|
|
951
|
+
members: cycle.members.map((key) => displayPath(realPath(key), cwd)),
|
|
952
|
+
path: cycle.path.map((key) => displayPath(realPath(key), cwd))
|
|
953
|
+
})),
|
|
954
|
+
targetInCycle: result.targetInCycle,
|
|
955
|
+
durationMs: result.durationMs
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
function renderGraphReport(input, options, writer) {
|
|
959
|
+
const style = textStyle(options);
|
|
960
|
+
const { stats } = input;
|
|
961
|
+
writer.writeLine(banner("\u{1F30A} Dependency Graph", style));
|
|
962
|
+
writer.writeLine();
|
|
963
|
+
writer.writeLine(
|
|
964
|
+
renderKeyValue([
|
|
965
|
+
["Files", String(stats.files)],
|
|
966
|
+
["Edges", String(stats.internalEdges)],
|
|
967
|
+
["External", String(stats.externalEdges)],
|
|
968
|
+
["Unresolved", String(stats.unresolvedEdges)],
|
|
969
|
+
["Circular groups", String(stats.cycles)]
|
|
970
|
+
])
|
|
971
|
+
);
|
|
972
|
+
writer.writeLine();
|
|
973
|
+
if (input.forward) {
|
|
974
|
+
writer.writeLine(sectionTitle("Dependants (forward)", style));
|
|
975
|
+
writer.writeLine(renderTree(input.forward.map((path12) => ({ label: path12 }))));
|
|
976
|
+
writer.writeLine();
|
|
977
|
+
}
|
|
978
|
+
if (input.reverse) {
|
|
979
|
+
writer.writeLine(sectionTitle("Dependents (reverse)", style));
|
|
980
|
+
writer.writeLine(renderTree(input.reverse.map((path12) => ({ label: path12 }))));
|
|
981
|
+
writer.writeLine();
|
|
982
|
+
}
|
|
983
|
+
if (input.cycles.length > 0) {
|
|
984
|
+
writer.writeLine(sectionTitle("Circular Dependencies", style));
|
|
985
|
+
for (const cycle of input.cycles) {
|
|
986
|
+
const chain = cycle.path.map((key) => {
|
|
987
|
+
const node = input.nodes.get(key);
|
|
988
|
+
return displayPath(node?.path ?? key, input.cwd);
|
|
989
|
+
}).join(" \u2192 ");
|
|
990
|
+
writer.writeLine(`\u2B55 ${chain}`);
|
|
991
|
+
}
|
|
992
|
+
} else {
|
|
993
|
+
writer.writeLine(dim("No circular dependencies found.", style));
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
function buildGraphJsonReport(input, version) {
|
|
997
|
+
const realPath = (key) => input.nodes.get(key)?.path ?? key;
|
|
998
|
+
return {
|
|
999
|
+
tool: "ripple",
|
|
1000
|
+
version,
|
|
1001
|
+
command: "graph",
|
|
1002
|
+
fileCount: input.stats.files,
|
|
1003
|
+
edgeCount: input.stats.internalEdges,
|
|
1004
|
+
externalEdgeCount: input.stats.externalEdges,
|
|
1005
|
+
cycles: input.cycles.map((cycle) => ({
|
|
1006
|
+
members: cycle.members.map((key) => displayPath(realPath(key), input.cwd)),
|
|
1007
|
+
path: cycle.path.map((key) => displayPath(realPath(key), input.cwd))
|
|
1008
|
+
})),
|
|
1009
|
+
...input.forward ? { forward: input.forward } : {},
|
|
1010
|
+
...input.reverse ? { reverse: input.reverse } : {}
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// src/formatter/json.ts
|
|
1015
|
+
function serializeJson(report) {
|
|
1016
|
+
return `${JSON.stringify(report, null, 2)}
|
|
1017
|
+
`;
|
|
1018
|
+
}
|
|
1019
|
+
function createTsProject() {
|
|
1020
|
+
return new Project({
|
|
1021
|
+
skipAddingFilesFromTsConfig: true,
|
|
1022
|
+
compilerOptions: {
|
|
1023
|
+
allowJs: true,
|
|
1024
|
+
checkJs: false,
|
|
1025
|
+
jsx: ts.JsxEmit.Preserve,
|
|
1026
|
+
noEmit: true,
|
|
1027
|
+
target: ts.ScriptTarget.ESNext
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
async function globFiles(options) {
|
|
1032
|
+
const matches = await fg(options.patterns, {
|
|
1033
|
+
cwd: options.cwd,
|
|
1034
|
+
ignore: options.ignore,
|
|
1035
|
+
onlyFiles: true,
|
|
1036
|
+
absolute: true,
|
|
1037
|
+
unique: true,
|
|
1038
|
+
followSymbolicLinks: false,
|
|
1039
|
+
suppressErrors: true
|
|
1040
|
+
});
|
|
1041
|
+
return matches;
|
|
1042
|
+
}
|
|
1043
|
+
function toIgnoreGlob(segment) {
|
|
1044
|
+
const trimmed = segment.replace(/^\/+|\/+$/g, "");
|
|
1045
|
+
if (trimmed === "") return "";
|
|
1046
|
+
const glob = trimmed.includes("*") || trimmed.includes("{") ? trimmed : `**/${trimmed}/**`;
|
|
1047
|
+
return glob;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// src/scanner/rules.ts
|
|
1051
|
+
`*.{${SOURCE_EXTENSIONS.map((e) => e.slice(1)).join(",")}}`;
|
|
1052
|
+
function isSourceFile(filePath) {
|
|
1053
|
+
return sourceFileKind(filePath) !== void 0;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// src/scanner/discover.ts
|
|
1057
|
+
async function discoverSourceFiles(options) {
|
|
1058
|
+
const ignoreGlobs = options.ignore.map(toIgnoreGlob).filter((glob) => glob !== "").concat(["**/node_modules/**"]);
|
|
1059
|
+
const files = await globFiles({
|
|
1060
|
+
cwd: options.rootDir,
|
|
1061
|
+
patterns: options.include,
|
|
1062
|
+
ignore: ignoreGlobs
|
|
1063
|
+
});
|
|
1064
|
+
return files.filter(isSourceFile).sort((a, b) => a.localeCompare(b));
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// src/parser/exports.ts
|
|
1068
|
+
function extractExports(sourceFile) {
|
|
1069
|
+
const named = [];
|
|
1070
|
+
const reExportedFrom = [];
|
|
1071
|
+
const reExportedAll = [];
|
|
1072
|
+
for (const decl of sourceFile.getExportDeclarations()) {
|
|
1073
|
+
const specifier = decl.getModuleSpecifierValue();
|
|
1074
|
+
const namedExports = decl.getNamedExports();
|
|
1075
|
+
if (specifier) {
|
|
1076
|
+
if (namedExports.length === 0) {
|
|
1077
|
+
reExportedAll.push(specifier);
|
|
1078
|
+
} else {
|
|
1079
|
+
reExportedFrom.push(specifier);
|
|
1080
|
+
}
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
for (const namedExport of namedExports) {
|
|
1084
|
+
named.push(namedExport.getName());
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
for (const statement of sourceFile.getVariableStatements()) {
|
|
1088
|
+
if (!statement.isExported()) continue;
|
|
1089
|
+
for (const declaration of statement.getDeclarations()) {
|
|
1090
|
+
named.push(declaration.getName());
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
for (const fn of sourceFile.getFunctions()) {
|
|
1094
|
+
if (!fn.isExported()) continue;
|
|
1095
|
+
const name = fn.getName();
|
|
1096
|
+
if (name) named.push(name);
|
|
1097
|
+
}
|
|
1098
|
+
for (const cls of sourceFile.getClasses()) {
|
|
1099
|
+
if (!cls.isExported()) continue;
|
|
1100
|
+
const name = cls.getName();
|
|
1101
|
+
if (name) named.push(name);
|
|
1102
|
+
}
|
|
1103
|
+
for (const en of sourceFile.getEnums()) {
|
|
1104
|
+
if (!en.isExported()) continue;
|
|
1105
|
+
const name = en.getName();
|
|
1106
|
+
if (name) named.push(name);
|
|
1107
|
+
}
|
|
1108
|
+
for (const iface of sourceFile.getInterfaces()) {
|
|
1109
|
+
if (!iface.isExported()) continue;
|
|
1110
|
+
named.push(iface.getName());
|
|
1111
|
+
}
|
|
1112
|
+
for (const alias of sourceFile.getTypeAliases()) {
|
|
1113
|
+
if (!alias.isExported()) continue;
|
|
1114
|
+
named.push(alias.getName());
|
|
1115
|
+
}
|
|
1116
|
+
const hasDefault = sourceFile.getExportAssignments().length > 0 || sourceFile.getFunctions().some((fn) => fn.isDefaultExport()) || sourceFile.getClasses().some((cls) => cls.isDefaultExport());
|
|
1117
|
+
named.sort();
|
|
1118
|
+
return { named, hasDefault, reExportedFrom, reExportedAll };
|
|
1119
|
+
}
|
|
1120
|
+
function specifierFor(imported, alias, isType) {
|
|
1121
|
+
return alias && alias !== imported ? { name: imported, alias, isType } : { name: imported, isType };
|
|
1122
|
+
}
|
|
1123
|
+
function extractImports(sourceFile) {
|
|
1124
|
+
const declarations = [];
|
|
1125
|
+
for (const decl of sourceFile.getImportDeclarations()) {
|
|
1126
|
+
declarations.push({
|
|
1127
|
+
raw: decl.getModuleSpecifierValue(),
|
|
1128
|
+
kind: "import",
|
|
1129
|
+
isTypeOnly: decl.isTypeOnly(),
|
|
1130
|
+
specifiers: decl.getNamedImports().map(
|
|
1131
|
+
(named) => specifierFor(named.getName(), named.getAliasNode()?.getText(), named.isTypeOnly())
|
|
1132
|
+
)
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
for (const decl of sourceFile.getExportDeclarations()) {
|
|
1136
|
+
const specifier = decl.getModuleSpecifierValue();
|
|
1137
|
+
if (!specifier) continue;
|
|
1138
|
+
declarations.push({
|
|
1139
|
+
raw: specifier,
|
|
1140
|
+
kind: "export-from",
|
|
1141
|
+
isTypeOnly: decl.isTypeOnly(),
|
|
1142
|
+
specifiers: decl.getNamedExports().map(
|
|
1143
|
+
(named) => specifierFor(named.getName(), named.getAliasNode()?.getText(), named.isTypeOnly())
|
|
1144
|
+
)
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
const importEqualsNodes = sourceFile.getDescendantsOfKind(SyntaxKind.ImportEqualsDeclaration).map((node) => node);
|
|
1148
|
+
for (const decl of importEqualsNodes) {
|
|
1149
|
+
const literal = decl.getFirstDescendantByKind(SyntaxKind.StringLiteral);
|
|
1150
|
+
if (literal) {
|
|
1151
|
+
declarations.push({
|
|
1152
|
+
raw: literal.getLiteralText(),
|
|
1153
|
+
kind: "require",
|
|
1154
|
+
isTypeOnly: false,
|
|
1155
|
+
specifiers: []
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
1160
|
+
const callee = call.getExpression().getText();
|
|
1161
|
+
if (callee !== "require" && callee !== "import") continue;
|
|
1162
|
+
const argument = call.getArguments()[0];
|
|
1163
|
+
if (!argument?.isKind(SyntaxKind.StringLiteral)) continue;
|
|
1164
|
+
declarations.push({
|
|
1165
|
+
raw: argument.getLiteralText(),
|
|
1166
|
+
kind: callee === "require" ? "require" : "dynamic",
|
|
1167
|
+
isTypeOnly: false,
|
|
1168
|
+
specifiers: []
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
return declarations;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// src/parser/symbols.ts
|
|
1175
|
+
function extractSymbols(sourceFile) {
|
|
1176
|
+
return {
|
|
1177
|
+
functions: sourceFile.getFunctions().map((fn) => fn.getName() ?? "(default)"),
|
|
1178
|
+
classes: sourceFile.getClasses().map((cls) => cls.getName() ?? "(default)"),
|
|
1179
|
+
interfaces: sourceFile.getInterfaces().map((iface) => iface.getName()),
|
|
1180
|
+
enums: sourceFile.getEnums().map((en) => en.getName()),
|
|
1181
|
+
typeAliases: sourceFile.getTypeAliases().map((alias) => alias.getName())
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// src/parser/parse.ts
|
|
1186
|
+
function parseSourceFile(project, filePath) {
|
|
1187
|
+
let sourceFile;
|
|
1188
|
+
try {
|
|
1189
|
+
sourceFile = project.addSourceFileAtPath(filePath);
|
|
1190
|
+
} catch (error) {
|
|
1191
|
+
return {
|
|
1192
|
+
path: filePath,
|
|
1193
|
+
kind: sourceFileKind(filePath) ?? "ts",
|
|
1194
|
+
imports: [],
|
|
1195
|
+
exports: { named: [], hasDefault: false, reExportedFrom: [], reExportedAll: [] },
|
|
1196
|
+
symbols: { functions: [], classes: [], interfaces: [], enums: [], typeAliases: [] },
|
|
1197
|
+
parseError: error instanceof Error ? error.message : String(error)
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
return {
|
|
1201
|
+
path: filePath,
|
|
1202
|
+
kind: sourceFileKind(filePath) ?? "ts",
|
|
1203
|
+
imports: extractImports(sourceFile),
|
|
1204
|
+
exports: extractExports(sourceFile),
|
|
1205
|
+
symbols: extractSymbols(sourceFile)
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
function parseMany(project, filePaths) {
|
|
1209
|
+
return filePaths.map((filePath) => parseSourceFile(project, filePath));
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// src/graph/cycles.ts
|
|
1213
|
+
function findCycles(graph) {
|
|
1214
|
+
const nodeKeys = [...graph.nodes.keys()];
|
|
1215
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1216
|
+
const finishOrder = [];
|
|
1217
|
+
for (const start of nodeKeys) {
|
|
1218
|
+
if (visited.has(start)) continue;
|
|
1219
|
+
const stack = [[start, 0]];
|
|
1220
|
+
visited.add(start);
|
|
1221
|
+
while (stack.length > 0) {
|
|
1222
|
+
const top = stack[stack.length - 1];
|
|
1223
|
+
if (top === void 0) break;
|
|
1224
|
+
const [node, index] = top;
|
|
1225
|
+
const neighbors = [...graph.forward.get(node) ?? []];
|
|
1226
|
+
if (index < neighbors.length) {
|
|
1227
|
+
top[1] = index + 1;
|
|
1228
|
+
const neighbor = neighbors[index];
|
|
1229
|
+
if (neighbor === void 0) continue;
|
|
1230
|
+
if (!visited.has(neighbor)) {
|
|
1231
|
+
visited.add(neighbor);
|
|
1232
|
+
stack.push([neighbor, 0]);
|
|
1233
|
+
}
|
|
1234
|
+
} else {
|
|
1235
|
+
stack.pop();
|
|
1236
|
+
finishOrder.push(node);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
const componentOf = /* @__PURE__ */ new Map();
|
|
1241
|
+
const components = [];
|
|
1242
|
+
for (let i = finishOrder.length - 1; i >= 0; i--) {
|
|
1243
|
+
const start = finishOrder[i];
|
|
1244
|
+
if (start === void 0 || componentOf.has(start)) continue;
|
|
1245
|
+
const component = [];
|
|
1246
|
+
const stack = [start];
|
|
1247
|
+
componentOf.set(start, components.length);
|
|
1248
|
+
while (stack.length > 0) {
|
|
1249
|
+
const node = stack.pop();
|
|
1250
|
+
if (node === void 0) continue;
|
|
1251
|
+
component.push(node);
|
|
1252
|
+
for (const neighbor of graph.reverse.get(node) ?? []) {
|
|
1253
|
+
if (!componentOf.has(neighbor)) {
|
|
1254
|
+
componentOf.set(neighbor, components.length);
|
|
1255
|
+
stack.push(neighbor);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
components.push(component);
|
|
1260
|
+
}
|
|
1261
|
+
const cycles = [];
|
|
1262
|
+
for (const component of components) {
|
|
1263
|
+
if (component.length < 2) {
|
|
1264
|
+
const single = component[0];
|
|
1265
|
+
if (single !== void 0 && graph.forward.get(single)?.has(single)) {
|
|
1266
|
+
cycles.push({ members: [single], path: [single, single] });
|
|
1267
|
+
}
|
|
1268
|
+
continue;
|
|
1269
|
+
}
|
|
1270
|
+
const members = [...component].sort();
|
|
1271
|
+
const componentSet = new Set(component);
|
|
1272
|
+
const path12 = representativeCyclePath(graph, component, componentSet);
|
|
1273
|
+
cycles.push({ members, path: path12 ?? [members[0], members[0]] });
|
|
1274
|
+
}
|
|
1275
|
+
cycles.sort(
|
|
1276
|
+
(a, b) => a.members.length - b.members.length || a.members[0].localeCompare(b.members[0])
|
|
1277
|
+
);
|
|
1278
|
+
return cycles;
|
|
1279
|
+
}
|
|
1280
|
+
function representativeCyclePath(graph, component, componentSet) {
|
|
1281
|
+
const inPath = /* @__PURE__ */ new Set();
|
|
1282
|
+
const path12 = [];
|
|
1283
|
+
let current = component[0];
|
|
1284
|
+
for (let steps = 0; steps <= component.length * 2 && current !== void 0; steps++) {
|
|
1285
|
+
const firstOccurrence = inPath.has(current);
|
|
1286
|
+
if (firstOccurrence) {
|
|
1287
|
+
const start = path12.indexOf(current);
|
|
1288
|
+
if (start === -1) return void 0;
|
|
1289
|
+
const cycle = path12.slice(start);
|
|
1290
|
+
if (cycle.length > 1 || graph.forward.get(current)?.has(current)) {
|
|
1291
|
+
cycle.push(current);
|
|
1292
|
+
return cycle;
|
|
1293
|
+
}
|
|
1294
|
+
return void 0;
|
|
1295
|
+
}
|
|
1296
|
+
inPath.add(current);
|
|
1297
|
+
path12.push(current);
|
|
1298
|
+
const next = [...graph.forward.get(current) ?? []].find(
|
|
1299
|
+
(candidate) => componentSet.has(candidate)
|
|
1300
|
+
);
|
|
1301
|
+
current = next;
|
|
1302
|
+
}
|
|
1303
|
+
return void 0;
|
|
1304
|
+
}
|
|
1305
|
+
function probeCandidates(base, fileKeys, rootDir) {
|
|
1306
|
+
for (const candidate of extensionCandidates(base)) {
|
|
1307
|
+
const key = pathKey(candidate, rootDir);
|
|
1308
|
+
if (fileKeys.has(key)) return path8.normalize(candidate);
|
|
1309
|
+
}
|
|
1310
|
+
const jsSwapped = jsToTsCandidate(base);
|
|
1311
|
+
if (jsSwapped !== base) {
|
|
1312
|
+
const swappedKey = pathKey(jsSwapped, rootDir);
|
|
1313
|
+
if (fileKeys.has(swappedKey)) return path8.normalize(jsSwapped);
|
|
1314
|
+
}
|
|
1315
|
+
return void 0;
|
|
1316
|
+
}
|
|
1317
|
+
function existsOnDisk(filePath) {
|
|
1318
|
+
try {
|
|
1319
|
+
fs2.accessSync(filePath);
|
|
1320
|
+
return true;
|
|
1321
|
+
} catch {
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
function specifierToBase(specifier, fromDir, context) {
|
|
1326
|
+
const { rootDir, aliases } = context;
|
|
1327
|
+
if (specifier.startsWith("/")) {
|
|
1328
|
+
return path8.resolve(rootDir, specifier.slice(1));
|
|
1329
|
+
}
|
|
1330
|
+
if (specifier.startsWith(".")) {
|
|
1331
|
+
return path8.resolve(fromDir, specifier);
|
|
1332
|
+
}
|
|
1333
|
+
const substituted = substituteAlias(specifier, aliases);
|
|
1334
|
+
if (substituted === void 0) return void 0;
|
|
1335
|
+
return path8.isAbsolute(substituted) ? substituted : path8.resolve(rootDir, substituted);
|
|
1336
|
+
}
|
|
1337
|
+
function resolveToFile(base, context) {
|
|
1338
|
+
const { rootDir, fileKeys } = context;
|
|
1339
|
+
const exactKey = pathKey(base, rootDir);
|
|
1340
|
+
if (fileKeys.has(exactKey)) return path8.normalize(base);
|
|
1341
|
+
const probed = probeCandidates(base, fileKeys, rootDir);
|
|
1342
|
+
if (probed) return probed;
|
|
1343
|
+
if (existsOnDisk(base) && fs2.statSync(base).isDirectory()) {
|
|
1344
|
+
const indexProbed = probeCandidates(path8.join(base, "index"), fileKeys, rootDir);
|
|
1345
|
+
if (indexProbed) return indexProbed;
|
|
1346
|
+
}
|
|
1347
|
+
return void 0;
|
|
1348
|
+
}
|
|
1349
|
+
function resolveEdge(specifier, fromPath, context) {
|
|
1350
|
+
const base = specifierToBase(specifier, path8.dirname(fromPath), context);
|
|
1351
|
+
if (base !== void 0) {
|
|
1352
|
+
const resolved = resolveToFile(base, context);
|
|
1353
|
+
if (resolved !== void 0) return { filePath: resolved, kind: "internal" };
|
|
1354
|
+
if (existsOnDisk(base)) return { kind: "external" };
|
|
1355
|
+
}
|
|
1356
|
+
const isRelative = specifier.startsWith(".") || specifier.startsWith("/");
|
|
1357
|
+
const isAlias = specifierToBase(specifier, path8.dirname(fromPath), context) !== void 0;
|
|
1358
|
+
if (isRelative || isAlias) return { kind: "unresolved" };
|
|
1359
|
+
return { kind: "external" };
|
|
1360
|
+
}
|
|
1361
|
+
function resolveImports(fromPath, importSpecifiers, context) {
|
|
1362
|
+
const internal = [];
|
|
1363
|
+
const external = [];
|
|
1364
|
+
const unresolved = [];
|
|
1365
|
+
for (const specifier of importSpecifiers) {
|
|
1366
|
+
const edge = resolveEdge(specifier, fromPath, context);
|
|
1367
|
+
if (edge.kind === "internal" && edge.filePath) {
|
|
1368
|
+
internal.push(edge.filePath);
|
|
1369
|
+
} else if (edge.kind === "external") {
|
|
1370
|
+
external.push(specifier);
|
|
1371
|
+
} else {
|
|
1372
|
+
unresolved.push(specifier);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return { internal, external, unresolved };
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// src/graph/build.ts
|
|
1379
|
+
function buildGraph(project, filePaths, context) {
|
|
1380
|
+
const parsedFiles = parseMany(project, filePaths);
|
|
1381
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
1382
|
+
const forward = /* @__PURE__ */ new Map();
|
|
1383
|
+
const reverse = /* @__PURE__ */ new Map();
|
|
1384
|
+
const external = /* @__PURE__ */ new Map();
|
|
1385
|
+
let parsedCount = 0;
|
|
1386
|
+
let internalEdges = 0;
|
|
1387
|
+
let unresolvedEdges = 0;
|
|
1388
|
+
let externalEdges = 0;
|
|
1389
|
+
for (const parsed of parsedFiles) {
|
|
1390
|
+
const key = pathKey(parsed.path, context.rootDir);
|
|
1391
|
+
nodes.set(key, { path: parsed.path, parsed });
|
|
1392
|
+
if (!parsed.parseError) parsedCount++;
|
|
1393
|
+
}
|
|
1394
|
+
for (const parsed of parsedFiles) {
|
|
1395
|
+
const fromKey = pathKey(parsed.path, context.rootDir);
|
|
1396
|
+
const targets = /* @__PURE__ */ new Set();
|
|
1397
|
+
const {
|
|
1398
|
+
internal,
|
|
1399
|
+
external: externalSpecifiers,
|
|
1400
|
+
unresolved
|
|
1401
|
+
} = resolveImports(
|
|
1402
|
+
parsed.path,
|
|
1403
|
+
parsed.imports.map((decl) => decl.raw),
|
|
1404
|
+
context
|
|
1405
|
+
);
|
|
1406
|
+
internalEdges += internal.length;
|
|
1407
|
+
unresolvedEdges += unresolved.length;
|
|
1408
|
+
externalEdges += externalSpecifiers.length;
|
|
1409
|
+
for (const target of internal) {
|
|
1410
|
+
targets.add(pathKey(target, context.rootDir));
|
|
1411
|
+
}
|
|
1412
|
+
if (externalSpecifiers.length > 0) {
|
|
1413
|
+
external.set(fromKey, new Set(externalSpecifiers));
|
|
1414
|
+
}
|
|
1415
|
+
forward.set(fromKey, targets);
|
|
1416
|
+
for (const target of targets) {
|
|
1417
|
+
const importers = reverse.get(target) ?? /* @__PURE__ */ new Set();
|
|
1418
|
+
importers.add(fromKey);
|
|
1419
|
+
reverse.set(target, importers);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
const graph = {
|
|
1423
|
+
nodes,
|
|
1424
|
+
forward,
|
|
1425
|
+
reverse,
|
|
1426
|
+
external,
|
|
1427
|
+
cycles: [],
|
|
1428
|
+
stats: {
|
|
1429
|
+
files: parsedFiles.length,
|
|
1430
|
+
parsed: parsedCount,
|
|
1431
|
+
internalEdges,
|
|
1432
|
+
unresolvedEdges,
|
|
1433
|
+
externalEdges,
|
|
1434
|
+
cycles: 0
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
graph.cycles = findCycles(graph);
|
|
1438
|
+
graph.stats.cycles = graph.cycles.length;
|
|
1439
|
+
return graph;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// src/commands/pipeline.ts
|
|
1443
|
+
async function runPipeline(context) {
|
|
1444
|
+
const started = Date.now();
|
|
1445
|
+
const project = createTsProject();
|
|
1446
|
+
const filePaths = await discoverSourceFiles({
|
|
1447
|
+
rootDir: context.rootDir,
|
|
1448
|
+
include: context.config.include,
|
|
1449
|
+
ignore: context.config.ignore
|
|
1450
|
+
});
|
|
1451
|
+
const graph = buildGraph(project, filePaths, {
|
|
1452
|
+
rootDir: context.rootDir,
|
|
1453
|
+
aliases: context.aliases,
|
|
1454
|
+
fileKeys: new Set(filePaths.map((p) => pathKey(p, context.rootDir)))
|
|
1455
|
+
});
|
|
1456
|
+
const entryPoints = await detectEntryPoints(context.rootDir);
|
|
1457
|
+
return { graph, filePaths, entryPoints, durationMs: Date.now() - started };
|
|
1458
|
+
}
|
|
1459
|
+
function resolveTargetFile(fileArg, cwd) {
|
|
1460
|
+
return path8.isAbsolute(fileArg) ? fileArg : path8.join(cwd, fileArg);
|
|
1461
|
+
}
|
|
1462
|
+
function requireNode(graph, targetPath, rootDir) {
|
|
1463
|
+
const key = pathKey(targetPath, rootDir);
|
|
1464
|
+
if (!graph.nodes.has(key)) {
|
|
1465
|
+
throw fileNotFound(displayPath(targetPath, rootDir));
|
|
1466
|
+
}
|
|
1467
|
+
return { key };
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// src/commands/analyze.ts
|
|
1471
|
+
async function analyzeCommand(fileArg, options, ctx2) {
|
|
1472
|
+
const targetPath = resolveTargetFile(fileArg, ctx2.cwd);
|
|
1473
|
+
if (!await pathExists(targetPath)) {
|
|
1474
|
+
throw new RippleError(`File not found: ${fileArg}`, ExitCode.NotFound);
|
|
1475
|
+
}
|
|
1476
|
+
const spinner = createSpinner(!options.json);
|
|
1477
|
+
spinner.start("Analyzing project\u2026");
|
|
1478
|
+
const started = Date.now();
|
|
1479
|
+
const context = await loadProjectContext(ctx2.cwd, options.config);
|
|
1480
|
+
const { graph, entryPoints, durationMs: pipelineMs } = await runPipeline(context);
|
|
1481
|
+
const completed = Date.now();
|
|
1482
|
+
const result = analyzeFile({
|
|
1483
|
+
graph,
|
|
1484
|
+
context,
|
|
1485
|
+
entryPoints,
|
|
1486
|
+
targetKey: requireNode(graph, targetPath, context.rootDir).key,
|
|
1487
|
+
targetPath,
|
|
1488
|
+
durationMs: pipelineMs + (completed - started),
|
|
1489
|
+
...options.depth !== void 0 ? { maxDepth: options.depth } : {}
|
|
1490
|
+
});
|
|
1491
|
+
spinner.stop();
|
|
1492
|
+
if (options.json) {
|
|
1493
|
+
const report = buildAnalyzeJsonReport(result, ctx2.version, ctx2.cwd);
|
|
1494
|
+
ctx2.writer.write(serializeJson(report));
|
|
1495
|
+
return ExitCode.Success;
|
|
1496
|
+
}
|
|
1497
|
+
renderAnalyzeReport(
|
|
1498
|
+
result,
|
|
1499
|
+
{
|
|
1500
|
+
cwd: ctx2.cwd,
|
|
1501
|
+
color: options.color !== false,
|
|
1502
|
+
verbose: options.verbose
|
|
1503
|
+
},
|
|
1504
|
+
ctx2.writer
|
|
1505
|
+
);
|
|
1506
|
+
return ExitCode.Success;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// src/commands/graph.ts
|
|
1510
|
+
function walk(graph, startKey, edges, maxDepth, cwd) {
|
|
1511
|
+
const seen = /* @__PURE__ */ new Set([startKey]);
|
|
1512
|
+
const queue = [{ key: startKey, depth: 0 }];
|
|
1513
|
+
const entries = [];
|
|
1514
|
+
while (queue.length > 0) {
|
|
1515
|
+
const current = queue.shift();
|
|
1516
|
+
if (!current) continue;
|
|
1517
|
+
if (current.key !== startKey) {
|
|
1518
|
+
const nodePath = graph.nodes.get(current.key)?.path ?? current.key;
|
|
1519
|
+
entries.push({ path: displayPath(nodePath, cwd), depth: current.depth });
|
|
1520
|
+
}
|
|
1521
|
+
if (maxDepth !== void 0 && current.depth >= maxDepth) continue;
|
|
1522
|
+
for (const candidate of edges.get(current.key) ?? []) {
|
|
1523
|
+
if (seen.has(candidate)) continue;
|
|
1524
|
+
seen.add(candidate);
|
|
1525
|
+
queue.push({ key: candidate, depth: current.depth + 1 });
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
return entries.sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path)).map((entry) => entry.path);
|
|
1529
|
+
}
|
|
1530
|
+
async function graphCommand(fileArg, options, ctx2) {
|
|
1531
|
+
const spinner = createSpinner(!options.json);
|
|
1532
|
+
spinner.start(fileArg ? "Building dependency tree\u2026" : "Building graph\u2026");
|
|
1533
|
+
const cwd = ctx2.cwd;
|
|
1534
|
+
try {
|
|
1535
|
+
const context = await loadProjectContext(cwd, options.config);
|
|
1536
|
+
const { graph } = await runPipeline(context);
|
|
1537
|
+
const input = {
|
|
1538
|
+
stats: graph.stats,
|
|
1539
|
+
cycles: graph.cycles,
|
|
1540
|
+
nodes: graph.nodes,
|
|
1541
|
+
cwd
|
|
1542
|
+
};
|
|
1543
|
+
if (!fileArg) {
|
|
1544
|
+
if (options.json) {
|
|
1545
|
+
const report = buildGraphJsonReport(input, ctx2.version);
|
|
1546
|
+
ctx2.writer.write(serializeJson(report));
|
|
1547
|
+
} else {
|
|
1548
|
+
renderGraphReport(
|
|
1549
|
+
input,
|
|
1550
|
+
{ cwd, color: options.color !== false, verbose: options.verbose },
|
|
1551
|
+
ctx2.writer
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
return ExitCode.Success;
|
|
1555
|
+
}
|
|
1556
|
+
const targetPath = resolveTargetFile(fileArg, cwd);
|
|
1557
|
+
const { key } = requireNode(graph, targetPath, context.rootDir);
|
|
1558
|
+
const dependants = walk(graph, key, graph.forward, options.depth, cwd);
|
|
1559
|
+
const dependents = walk(graph, key, graph.reverse, options.depth, cwd);
|
|
1560
|
+
if (options.json) {
|
|
1561
|
+
const report = buildGraphJsonReport(
|
|
1562
|
+
{
|
|
1563
|
+
...input,
|
|
1564
|
+
...options.reverse ? { reverse: dependents } : { forward: dependants }
|
|
1565
|
+
},
|
|
1566
|
+
ctx2.version
|
|
1567
|
+
);
|
|
1568
|
+
ctx2.writer.write(serializeJson(report));
|
|
1569
|
+
} else {
|
|
1570
|
+
renderGraphReport(
|
|
1571
|
+
{
|
|
1572
|
+
...input,
|
|
1573
|
+
forward: options.reverse ? void 0 : dependants,
|
|
1574
|
+
reverse: options.reverse ? dependents : void 0
|
|
1575
|
+
},
|
|
1576
|
+
{ cwd, color: options.color !== false, verbose: options.verbose },
|
|
1577
|
+
ctx2.writer
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
return ExitCode.Success;
|
|
1581
|
+
} finally {
|
|
1582
|
+
spinner.stop();
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
async function doctorCommand(options, ctx2) {
|
|
1586
|
+
const spinner = createSpinner(options.color !== false);
|
|
1587
|
+
spinner.start("Running diagnostics\u2026");
|
|
1588
|
+
const results = [];
|
|
1589
|
+
const check = (label, state, detail) => {
|
|
1590
|
+
results.push({ label, state, detail });
|
|
1591
|
+
};
|
|
1592
|
+
try {
|
|
1593
|
+
check("Node.js runtime", "ok", process.version);
|
|
1594
|
+
const packageJson = await findPackageJson(ctx2.cwd);
|
|
1595
|
+
if (packageJson) {
|
|
1596
|
+
check("package.json", "ok", path8.relative(ctx2.cwd, packageJson));
|
|
1597
|
+
} else {
|
|
1598
|
+
check("package.json", "fail", "not found in this directory or its parents");
|
|
1599
|
+
}
|
|
1600
|
+
let context;
|
|
1601
|
+
try {
|
|
1602
|
+
const loaded = await loadConfig(ctx2.cwd, options.config);
|
|
1603
|
+
check(
|
|
1604
|
+
"ripple config",
|
|
1605
|
+
"ok",
|
|
1606
|
+
loaded.configPath ? path8.relative(ctx2.cwd, loaded.configPath) : "defaults"
|
|
1607
|
+
);
|
|
1608
|
+
context = await loadProjectContext(ctx2.cwd, options.config);
|
|
1609
|
+
} catch (error) {
|
|
1610
|
+
check("ripple config", "fail", error instanceof Error ? error.message : String(error));
|
|
1611
|
+
renderResults(results, ctx2);
|
|
1612
|
+
return ExitCode.InvalidConfig;
|
|
1613
|
+
}
|
|
1614
|
+
if (context.tsconfig) {
|
|
1615
|
+
check("tsconfig.json", "ok", path8.relative(ctx2.cwd, context.tsconfig.path));
|
|
1616
|
+
} else {
|
|
1617
|
+
check("tsconfig.json", "warn", "not found \u2014 path aliases unavailable");
|
|
1618
|
+
}
|
|
1619
|
+
const project = createTsProject();
|
|
1620
|
+
const filePaths = await discoverSourceFiles({
|
|
1621
|
+
rootDir: context.rootDir,
|
|
1622
|
+
include: context.config.include,
|
|
1623
|
+
ignore: context.config.ignore
|
|
1624
|
+
});
|
|
1625
|
+
if (filePaths.length > 0) {
|
|
1626
|
+
check("source files", "ok", `${filePaths.length} discovered`);
|
|
1627
|
+
} else {
|
|
1628
|
+
check("source files", "fail", "no source files matched the include globs");
|
|
1629
|
+
}
|
|
1630
|
+
if (Object.keys(context.aliases).length > 0) {
|
|
1631
|
+
check("path aliases", "ok", `${Object.keys(context.aliases).length} configured`);
|
|
1632
|
+
}
|
|
1633
|
+
const graph = buildGraph(project, filePaths, {
|
|
1634
|
+
rootDir: context.rootDir,
|
|
1635
|
+
aliases: context.aliases,
|
|
1636
|
+
fileKeys: new Set(filePaths.map((p) => pathKey(p, context.rootDir)))
|
|
1637
|
+
});
|
|
1638
|
+
const { stats } = graph;
|
|
1639
|
+
const parseRate = stats.files === 0 ? 1 : stats.parsed / stats.files;
|
|
1640
|
+
if (parseRate >= 0.99) {
|
|
1641
|
+
check("parse", "ok", "all files parsed cleanly");
|
|
1642
|
+
} else if (parseRate >= 0.9) {
|
|
1643
|
+
check("parse", "warn", `${stats.files - stats.parsed} file(s) failed to parse`);
|
|
1644
|
+
} else {
|
|
1645
|
+
check("parse", "fail", `${stats.files - stats.parsed} file(s) failed to parse`);
|
|
1646
|
+
}
|
|
1647
|
+
if (stats.unresolvedEdges === 0) {
|
|
1648
|
+
check("import resolution", "ok", "all internal imports resolved");
|
|
1649
|
+
} else {
|
|
1650
|
+
check("import resolution", "warn", `${stats.unresolvedEdges} unresolved import(s)`);
|
|
1651
|
+
}
|
|
1652
|
+
if (stats.cycles === 0) {
|
|
1653
|
+
check("circular dependencies", "ok", "none found");
|
|
1654
|
+
} else {
|
|
1655
|
+
check("circular dependencies", "warn", `${stats.cycles} cycle group(s)`);
|
|
1656
|
+
}
|
|
1657
|
+
} finally {
|
|
1658
|
+
spinner.stop();
|
|
1659
|
+
}
|
|
1660
|
+
renderResults(results, ctx2);
|
|
1661
|
+
const failed = results.some((check2) => check2.state === "fail");
|
|
1662
|
+
const warned = results.some((check2) => check2.state === "warn");
|
|
1663
|
+
ctx2.writer.writeLine(
|
|
1664
|
+
failed ? "\u2716 Diagnoses found problems." : warned ? "\u26A0 Diagnoses passed with warnings." : "\u2713 All checks passed."
|
|
1665
|
+
);
|
|
1666
|
+
return failed ? ExitCode.Failure : ExitCode.Success;
|
|
1667
|
+
}
|
|
1668
|
+
function renderResults(results, ctx2) {
|
|
1669
|
+
for (const check of results) {
|
|
1670
|
+
const icon = check.state === "ok" ? "\u2713" : check.state === "warn" ? "\u26A0" : "\u2716";
|
|
1671
|
+
const suffix = check.detail ? ` ${check.detail}` : "";
|
|
1672
|
+
ctx2.writer.writeLine(`${icon} ${check.label}${suffix}`);
|
|
1673
|
+
}
|
|
1674
|
+
ctx2.writer.writeLine();
|
|
1675
|
+
}
|
|
1676
|
+
var INIT_FILENAME = "ripple.config.json";
|
|
1677
|
+
async function initCommand(options, ctx2) {
|
|
1678
|
+
const target = path8.join(ctx2.cwd, INIT_FILENAME);
|
|
1679
|
+
try {
|
|
1680
|
+
await fs.access(target);
|
|
1681
|
+
if (!options.force) {
|
|
1682
|
+
ctx2.writer.writeLine(`\u2716 ${INIT_FILENAME} already exists. Pass --force to overwrite it.`);
|
|
1683
|
+
return ExitCode.Failure;
|
|
1684
|
+
}
|
|
1685
|
+
} catch {
|
|
1686
|
+
}
|
|
1687
|
+
const payload = {
|
|
1688
|
+
...DEFAULT_CONFIG
|
|
1689
|
+
};
|
|
1690
|
+
try {
|
|
1691
|
+
await fs.writeFile(target, `${JSON.stringify(payload, null, 2)}
|
|
1692
|
+
`, "utf8");
|
|
1693
|
+
ctx2.writer.writeLine(`\u2713 Wrote ${INIT_FILENAME}`);
|
|
1694
|
+
ctx2.writer.writeLine(` Edit it to tune include globs, aliases and risk weights.`);
|
|
1695
|
+
return ExitCode.Success;
|
|
1696
|
+
} catch (error) {
|
|
1697
|
+
ctx2.writer.writeLine(
|
|
1698
|
+
`\u2716 Could not write ${INIT_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
|
|
1699
|
+
);
|
|
1700
|
+
return ExitCode.Failure;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// src/cli/program.ts
|
|
1705
|
+
var PROGRAM_NAME = "ripple";
|
|
1706
|
+
function createProgram(ctx2) {
|
|
1707
|
+
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();
|
|
1708
|
+
program.command("analyze <file>").description("Analyze the impact of changing a file").option("-j, --json", "emit a machine-readable JSON report").option("-v, --verbose", "include the risk factor breakdown").option("-d, --depth <number>", "cap the reverse traversal depth", parsePositiveInt).option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (file, options) => {
|
|
1709
|
+
const exitCode2 = await analyzeCommand(
|
|
1710
|
+
file,
|
|
1711
|
+
{
|
|
1712
|
+
json: Boolean(options.json),
|
|
1713
|
+
verbose: Boolean(options.verbose),
|
|
1714
|
+
...options.depth !== void 0 ? { depth: options.depth } : {},
|
|
1715
|
+
...options.config !== void 0 ? { config: options.config } : {},
|
|
1716
|
+
color: options.color
|
|
1717
|
+
},
|
|
1718
|
+
ctx2
|
|
1719
|
+
);
|
|
1720
|
+
if (exitCode2 !== ExitCode.Success) {
|
|
1721
|
+
throw new ExitError(exitCode2);
|
|
1722
|
+
}
|
|
1723
|
+
});
|
|
1724
|
+
program.command("graph [file]").description("Show project graph stats, or the dependency tree of one file").option("-j, --json", "emit a machine-readable JSON report").option("-v, --verbose", "include extra sections").option("-r, --reverse", "show dependents (what imports the file) instead of dependants").option("-d, --depth <number>", "cap the tree depth", parsePositiveInt).option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (file, options) => {
|
|
1725
|
+
const exitCode2 = await graphCommand(
|
|
1726
|
+
file,
|
|
1727
|
+
{
|
|
1728
|
+
json: Boolean(options.json),
|
|
1729
|
+
verbose: Boolean(options.verbose),
|
|
1730
|
+
reverse: Boolean(options.reverse),
|
|
1731
|
+
...options.depth !== void 0 ? { depth: options.depth } : {},
|
|
1732
|
+
...options.config !== void 0 ? { config: options.config } : {},
|
|
1733
|
+
color: options.color
|
|
1734
|
+
},
|
|
1735
|
+
ctx2
|
|
1736
|
+
);
|
|
1737
|
+
if (exitCode2 !== ExitCode.Success) {
|
|
1738
|
+
throw new ExitError(exitCode2);
|
|
1739
|
+
}
|
|
1740
|
+
});
|
|
1741
|
+
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) => {
|
|
1742
|
+
const exitCode2 = await doctorCommand(
|
|
1743
|
+
{
|
|
1744
|
+
verbose: Boolean(options.verbose),
|
|
1745
|
+
config: options.config,
|
|
1746
|
+
color: options.color
|
|
1747
|
+
},
|
|
1748
|
+
ctx2
|
|
1749
|
+
);
|
|
1750
|
+
if (exitCode2 !== ExitCode.Success) {
|
|
1751
|
+
throw new ExitError(exitCode2);
|
|
1752
|
+
}
|
|
1753
|
+
});
|
|
1754
|
+
program.command("init").description("Create a ripple.config.json with the default settings").option("-f, --force", "overwrite an existing config file").action(async (options) => {
|
|
1755
|
+
const exitCode2 = await initCommand({ force: Boolean(options.force) }, ctx2);
|
|
1756
|
+
if (exitCode2 !== ExitCode.Success) {
|
|
1757
|
+
throw new ExitError(exitCode2);
|
|
1758
|
+
}
|
|
1759
|
+
});
|
|
1760
|
+
program.command("version").description("Print the Ripple version").action(() => {
|
|
1761
|
+
ctx2.writer.writeLine(ctx2.version);
|
|
1762
|
+
});
|
|
1763
|
+
return program;
|
|
1764
|
+
}
|
|
1765
|
+
var ExitError = class extends Error {
|
|
1766
|
+
constructor(exitCode2) {
|
|
1767
|
+
super(`exit ${exitCode2}`);
|
|
1768
|
+
this.exitCode = exitCode2;
|
|
1769
|
+
this.name = "ExitError";
|
|
1770
|
+
}
|
|
1771
|
+
exitCode;
|
|
1772
|
+
};
|
|
1773
|
+
function parsePositiveInt(value) {
|
|
1774
|
+
const parsed = Number.parseInt(value, 10);
|
|
1775
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
1776
|
+
throw new CommanderError(
|
|
1777
|
+
1,
|
|
1778
|
+
"commander.invalidArgument",
|
|
1779
|
+
`expected a positive integer, got "${value}"`
|
|
1780
|
+
);
|
|
1781
|
+
}
|
|
1782
|
+
return parsed;
|
|
1783
|
+
}
|
|
1784
|
+
function printError(error, ctx2) {
|
|
1785
|
+
if (error instanceof RippleError) {
|
|
1786
|
+
ctx2.errorWriter.writeLine(`\u2716 ${error.message}`);
|
|
1787
|
+
if (error.hint) ctx2.errorWriter.writeLine(` ${error.hint}`);
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
if (error instanceof Error) {
|
|
1791
|
+
ctx2.errorWriter.writeLine(`\u2716 ${error.message}`);
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
ctx2.errorWriter.writeLine(`\u2716 ${String(error)}`);
|
|
1795
|
+
}
|
|
1796
|
+
async function run({ argv, ctx: ctx2 }) {
|
|
1797
|
+
const program = createProgram(ctx2);
|
|
1798
|
+
program.exitOverride();
|
|
1799
|
+
try {
|
|
1800
|
+
await program.parseAsync(argv);
|
|
1801
|
+
return ExitCode.Success;
|
|
1802
|
+
} catch (error) {
|
|
1803
|
+
if (error instanceof ExitError) {
|
|
1804
|
+
return error.exitCode;
|
|
1805
|
+
}
|
|
1806
|
+
if (error instanceof CommanderError) {
|
|
1807
|
+
if (error.exitCode === 0) return ExitCode.Success;
|
|
1808
|
+
return ExitCode.Failure;
|
|
1809
|
+
}
|
|
1810
|
+
printError(error, ctx2);
|
|
1811
|
+
return error instanceof RippleError ? error.exitCode : ExitCode.Failure;
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// package.json
|
|
1816
|
+
var package_default = {
|
|
1817
|
+
version: "0.1.0"};
|
|
1818
|
+
|
|
1819
|
+
// src/cli/bin.ts
|
|
1820
|
+
var ctx = {
|
|
1821
|
+
writer: new TerminalWriter(),
|
|
1822
|
+
errorWriter: new TerminalWriter(process.stderr),
|
|
1823
|
+
cwd: process.cwd(),
|
|
1824
|
+
version: package_default.version
|
|
1825
|
+
};
|
|
1826
|
+
var exitCode = await run({ argv: process.argv, ctx });
|
|
1827
|
+
process.exitCode = exitCode;
|
|
1828
|
+
//# sourceMappingURL=bin.js.map
|
|
1829
|
+
//# sourceMappingURL=bin.js.map
|