@dependency-maritime/cli 0.1.0-beta.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/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/cli/cli/analyze/adapters.d.ts +32 -0
- package/dist/cli/cli/analyze/calculate-metrics.d.ts +6 -0
- package/dist/cli/cli/analyze/environment.d.ts +10 -0
- package/dist/cli/cli/analyze/models.d.ts +47 -0
- package/dist/cli/cli/analyze/parse-eslint.d.ts +2 -0
- package/dist/cli/cli/analyze/render-markdown-report.d.ts +2 -0
- package/dist/cli/cli/commands/analyze.d.ts +1 -0
- package/dist/cli/cli/commands/validate.d.ts +1 -0
- package/dist/cli/cli/index.d.ts +25 -0
- package/dist/cli/cli/main.d.ts +1 -0
- package/dist/cli/cli/validate/validate.d.ts +10 -0
- package/dist/cli/index.d.ts +25 -0
- package/dist/cli/index.js +1197 -0
- package/dist/cli/main.js +1172 -0
- package/dist/cli/schema/complexity-metrics.d.ts +18 -0
- package/dist/cli/schema/dependency-cruiser.d.ts +180 -0
- package/dist/cli/schema/manifest.d.ts +33 -0
- package/package.json +109 -0
|
@@ -0,0 +1,1197 @@
|
|
|
1
|
+
// src/cli/commands/analyze.ts
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import * as fsPromises from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
// src/cli/analyze/adapters.ts
|
|
6
|
+
import * as fs from "fs/promises";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
|
|
10
|
+
// src/schema/dependency-cruiser.ts
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
var DependencySchema = z.object({
|
|
13
|
+
/** 'true' if following this dependency will ultimately return to the source */
|
|
14
|
+
circular: z.boolean(),
|
|
15
|
+
/** Whether or not this is a node.js core module */
|
|
16
|
+
coreModule: z.boolean(),
|
|
17
|
+
/** 'true' if dependency-cruiser could not resolve the module name to a file */
|
|
18
|
+
couldNotResolve: z.boolean(),
|
|
19
|
+
/** The type of inclusion - local, core, npm, etc. */
|
|
20
|
+
dependencyTypes: z.array(z.string()),
|
|
21
|
+
/** true if this dependency is dynamic, false in all other cases */
|
|
22
|
+
dynamic: z.boolean(),
|
|
23
|
+
/** true if the dependency was defined by a require not named 'require' */
|
|
24
|
+
exoticallyRequired: z.boolean(),
|
|
25
|
+
/** Whether or not this is a dependency that can be followed any further */
|
|
26
|
+
followable: z.boolean(),
|
|
27
|
+
/** the instability of the dependency */
|
|
28
|
+
instability: z.number().optional(),
|
|
29
|
+
/** If the module specification is an URI with a protocol, this holds it */
|
|
30
|
+
protocol: z.enum(["data:", "file:", "node:"]).optional(),
|
|
31
|
+
/** If the module specification is an URI and contains a mime type, this holds it */
|
|
32
|
+
mimeType: z.string().optional(),
|
|
33
|
+
/** The module system used (e.g., "es6", "cjs") */
|
|
34
|
+
moduleSystem: z.enum(["amd", "cjs", "es6", "tsd"]),
|
|
35
|
+
/** The import string used in the code (e.g., "./utils") */
|
|
36
|
+
module: z.string(),
|
|
37
|
+
/** The absolute or relative path to the resolved file (e.g., "src/utils.ts") */
|
|
38
|
+
resolved: z.string(),
|
|
39
|
+
/** 'true' if this dependency violated a rule */
|
|
40
|
+
valid: z.boolean(),
|
|
41
|
+
/** Whether the dependency exists only before compilation (e.g. type-only) */
|
|
42
|
+
preCompilationOnly: z.boolean().optional(),
|
|
43
|
+
/** 'true' when the module included the module explicitly as type only */
|
|
44
|
+
typeOnly: z.boolean().optional(),
|
|
45
|
+
/** Cycle path if circular */
|
|
46
|
+
cycle: z.array(z.object({
|
|
47
|
+
name: z.string(),
|
|
48
|
+
dependencyTypes: z.array(z.string())
|
|
49
|
+
})).optional()
|
|
50
|
+
}).passthrough();
|
|
51
|
+
var ModuleSchema = z.object({
|
|
52
|
+
/** The path to the source file (acts as the unique ID) */
|
|
53
|
+
source: z.string(),
|
|
54
|
+
/** 'true' if this module violated a rule */
|
|
55
|
+
valid: z.boolean(),
|
|
56
|
+
/** List of outgoing dependencies */
|
|
57
|
+
dependencies: z.array(DependencySchema),
|
|
58
|
+
/** List of files that depend on this module (incoming edges) */
|
|
59
|
+
dependents: z.array(z.string()),
|
|
60
|
+
/** Whether or not this is a node.js core module */
|
|
61
|
+
coreModule: z.boolean().optional(),
|
|
62
|
+
/** 'true' if dependency-cruiser could not resolve the module name to a file */
|
|
63
|
+
couldNotResolve: z.boolean().optional(),
|
|
64
|
+
/** Whether the module is an orphan */
|
|
65
|
+
orphan: z.boolean().optional()
|
|
66
|
+
}).passthrough();
|
|
67
|
+
var ViolationSchema = z.object({
|
|
68
|
+
type: z.enum(["dependency", "module", "cycle", "reachability", "instability"]).optional(),
|
|
69
|
+
from: z.string(),
|
|
70
|
+
to: z.string(),
|
|
71
|
+
rule: z.object({
|
|
72
|
+
name: z.string(),
|
|
73
|
+
severity: z.enum(["error", "warn", "info", "ignore"])
|
|
74
|
+
}).passthrough()
|
|
75
|
+
}).passthrough();
|
|
76
|
+
var CruiseResultSchema = z.object({
|
|
77
|
+
/** List of all modules scanned */
|
|
78
|
+
modules: z.array(ModuleSchema),
|
|
79
|
+
/** Summary of the scan (violations, errors, etc.) */
|
|
80
|
+
summary: z.object({
|
|
81
|
+
error: z.number(),
|
|
82
|
+
ignore: z.number(),
|
|
83
|
+
info: z.number(),
|
|
84
|
+
totalCruised: z.number(),
|
|
85
|
+
totalDependenciesCruised: z.number().optional(),
|
|
86
|
+
violations: z.array(ViolationSchema),
|
|
87
|
+
warn: z.number(),
|
|
88
|
+
optionsUsed: z.unknown()
|
|
89
|
+
}).passthrough()
|
|
90
|
+
}).passthrough();
|
|
91
|
+
|
|
92
|
+
// src/cli/analyze/models.ts
|
|
93
|
+
var ValidationError = class extends Error {
|
|
94
|
+
constructor(message) {
|
|
95
|
+
super(message);
|
|
96
|
+
this.name = "ValidationError";
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// src/cli/analyze/adapters.ts
|
|
101
|
+
import { readFileSync } from "node:fs";
|
|
102
|
+
import { fileURLToPath } from "node:url";
|
|
103
|
+
function getPortableFallbackConfig(cwd = process.cwd()) {
|
|
104
|
+
const options = {
|
|
105
|
+
doNotFollow: {
|
|
106
|
+
path: "node_modules"
|
|
107
|
+
},
|
|
108
|
+
tsPreCompilationDeps: true,
|
|
109
|
+
enhancedResolveOptions: {
|
|
110
|
+
exportsFields: ["exports"],
|
|
111
|
+
conditionNames: ["import", "require", "node", "default"]
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const tsConfigApp = path.resolve(cwd, "tsconfig.app.json");
|
|
115
|
+
const tsConfigDefault = path.resolve(cwd, "tsconfig.json");
|
|
116
|
+
try {
|
|
117
|
+
if (existsSync(tsConfigApp)) {
|
|
118
|
+
options.tsConfig = { fileName: "./tsconfig.app.json" };
|
|
119
|
+
} else if (existsSync(tsConfigDefault)) {
|
|
120
|
+
options.tsConfig = { fileName: "./tsconfig.json" };
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
}
|
|
124
|
+
return { options };
|
|
125
|
+
}
|
|
126
|
+
async function resolveDepcruiseConfig(configPath, cwd = process.cwd()) {
|
|
127
|
+
if (configPath) {
|
|
128
|
+
const absConfigPath = path.resolve(cwd, configPath);
|
|
129
|
+
if (!existsSync(absConfigPath)) {
|
|
130
|
+
throw new ValidationError(`Specified dependency-cruiser configuration file not found at ${configPath}`);
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const extractDepcruiseConfigModule = await import("dependency-cruiser/config-utl/extract-depcruise-config");
|
|
134
|
+
const extractDepcruiseConfig = extractDepcruiseConfigModule.default || extractDepcruiseConfigModule;
|
|
135
|
+
const config = await extractDepcruiseConfig(absConfigPath);
|
|
136
|
+
return {
|
|
137
|
+
config,
|
|
138
|
+
configPath: absConfigPath,
|
|
139
|
+
source: "explicit"
|
|
140
|
+
};
|
|
141
|
+
} catch (err) {
|
|
142
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
143
|
+
throw new ValidationError(`Failed to load specified dependency-cruiser configuration at ${configPath}: ${message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const conventionalFiles = [
|
|
147
|
+
".dependency-cruiser.cjs",
|
|
148
|
+
".dependency-cruiser.js",
|
|
149
|
+
".dependency-cruiser.mjs",
|
|
150
|
+
".dependency-cruiser.json"
|
|
151
|
+
];
|
|
152
|
+
for (const fileName of conventionalFiles) {
|
|
153
|
+
const candidate = path.resolve(cwd, fileName);
|
|
154
|
+
if (existsSync(candidate)) {
|
|
155
|
+
try {
|
|
156
|
+
const extractDepcruiseConfigModule = await import("dependency-cruiser/config-utl/extract-depcruise-config");
|
|
157
|
+
const extractDepcruiseConfig = extractDepcruiseConfigModule.default || extractDepcruiseConfigModule;
|
|
158
|
+
const config = await extractDepcruiseConfig(candidate);
|
|
159
|
+
return {
|
|
160
|
+
config,
|
|
161
|
+
configPath: candidate,
|
|
162
|
+
source: "discovered"
|
|
163
|
+
};
|
|
164
|
+
} catch (err) {
|
|
165
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
166
|
+
throw new ValidationError(`Failed to load discovered dependency-cruiser configuration at ${fileName}: ${message}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
config: getPortableFallbackConfig(cwd),
|
|
172
|
+
configPath: null,
|
|
173
|
+
source: "fallback"
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
async function generateDependencyGraph(options) {
|
|
177
|
+
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
178
|
+
const resolvedConfig = await resolveDepcruiseConfig(options.configPath, cwd);
|
|
179
|
+
const prevCwd = process.cwd();
|
|
180
|
+
let cruiseResultRaw;
|
|
181
|
+
try {
|
|
182
|
+
process.chdir(cwd);
|
|
183
|
+
const { cruise } = await import("dependency-cruiser");
|
|
184
|
+
const rawOptions = resolvedConfig.config.options || {};
|
|
185
|
+
const cruiseOptions = { ...rawOptions };
|
|
186
|
+
if (resolvedConfig.config.forbidden || resolvedConfig.config.allowed || resolvedConfig.config.required) {
|
|
187
|
+
cruiseOptions.ruleSet = resolvedConfig.config;
|
|
188
|
+
}
|
|
189
|
+
const cruiseOutput = await cruise(options.sourceRoots, cruiseOptions);
|
|
190
|
+
cruiseResultRaw = cruiseOutput.output;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (err instanceof ValidationError) {
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
196
|
+
throw new Error(`Failed to generate dependency graph: ${message}`);
|
|
197
|
+
} finally {
|
|
198
|
+
process.chdir(prevCwd);
|
|
199
|
+
}
|
|
200
|
+
const validationResult = CruiseResultSchema.safeParse(cruiseResultRaw);
|
|
201
|
+
if (!validationResult.success) {
|
|
202
|
+
throw new ValidationError(`Generated dependency graph failed schema validation:
|
|
203
|
+
${validationResult.error.message}`);
|
|
204
|
+
}
|
|
205
|
+
const modules = validationResult.data.modules.map((m) => ({
|
|
206
|
+
source: m.source,
|
|
207
|
+
dependencies: m.dependencies,
|
|
208
|
+
dependents: m.dependents
|
|
209
|
+
}));
|
|
210
|
+
return {
|
|
211
|
+
cruiseResult: validationResult.data,
|
|
212
|
+
modules,
|
|
213
|
+
configSource: resolvedConfig.source
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
async function readDependencyGraph(graphPath, cwd = process.cwd()) {
|
|
217
|
+
const absolutePath = path.resolve(cwd, graphPath);
|
|
218
|
+
let data;
|
|
219
|
+
let parsed;
|
|
220
|
+
try {
|
|
221
|
+
data = await fs.readFile(absolutePath, "utf8");
|
|
222
|
+
} catch (err) {
|
|
223
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
224
|
+
throw new Error(`Failed to read dependency graph at ${graphPath}: ${message}`);
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
parsed = JSON.parse(data);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
230
|
+
throw new ValidationError(`Invalid JSON in dependency graph at ${graphPath}: ${message}`);
|
|
231
|
+
}
|
|
232
|
+
const validationResult = CruiseResultSchema.safeParse(parsed);
|
|
233
|
+
if (!validationResult.success) {
|
|
234
|
+
throw new ValidationError(`Invalid dependency-cruiser output shape in ${graphPath}:
|
|
235
|
+
${validationResult.error.message}`);
|
|
236
|
+
}
|
|
237
|
+
return validationResult.data.modules.map((m) => ({
|
|
238
|
+
source: m.source,
|
|
239
|
+
dependencies: m.dependencies,
|
|
240
|
+
dependents: m.dependents
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
async function runEslintComplexityScan(sourcePath, sourceFiles, cwd = process.cwd()) {
|
|
244
|
+
let eslintModule;
|
|
245
|
+
try {
|
|
246
|
+
eslintModule = await import("eslint");
|
|
247
|
+
} catch (err) {
|
|
248
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
249
|
+
throw new ValidationError(`ESLint is not installed in ${cwd}: ${message}. Maritime requires ESLint 9+ as a peer dependency.`);
|
|
250
|
+
}
|
|
251
|
+
const ESLint = eslintModule.ESLint;
|
|
252
|
+
const rawPaths = Array.isArray(sourcePath) ? sourcePath : [sourcePath];
|
|
253
|
+
const globPatterns = rawPaths.map((p) => `${path.resolve(cwd, p).replace(/\\/g, "/")}/**/*.{ts,tsx}`);
|
|
254
|
+
const hasExplicitFiles = sourceFiles !== void 0;
|
|
255
|
+
let targets;
|
|
256
|
+
if (hasExplicitFiles) {
|
|
257
|
+
targets = sourceFiles.map((f) => path.resolve(cwd, f).replace(/\\/g, "/"));
|
|
258
|
+
} else {
|
|
259
|
+
targets = globPatterns;
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const eslint = new ESLint({
|
|
263
|
+
cwd,
|
|
264
|
+
overrideConfig: [{
|
|
265
|
+
rules: {
|
|
266
|
+
"complexity": ["warn", 0]
|
|
267
|
+
}
|
|
268
|
+
}]
|
|
269
|
+
});
|
|
270
|
+
const unignoredTargets = [];
|
|
271
|
+
const ignoredResults = [];
|
|
272
|
+
for (const t of targets) {
|
|
273
|
+
if (hasExplicitFiles) {
|
|
274
|
+
try {
|
|
275
|
+
await fs.access(t);
|
|
276
|
+
} catch {
|
|
277
|
+
ignoredResults.push({
|
|
278
|
+
filePath: t,
|
|
279
|
+
ignored: true,
|
|
280
|
+
messages: []
|
|
281
|
+
});
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const isIgnored = await eslint.isPathIgnored(t);
|
|
286
|
+
if (isIgnored) {
|
|
287
|
+
ignoredResults.push({
|
|
288
|
+
filePath: t,
|
|
289
|
+
ignored: true,
|
|
290
|
+
messages: []
|
|
291
|
+
});
|
|
292
|
+
} else {
|
|
293
|
+
unignoredTargets.push(t);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const lintResults = [];
|
|
297
|
+
if (unignoredTargets.length > 0) {
|
|
298
|
+
if (hasExplicitFiles) {
|
|
299
|
+
for (const targetFile of unignoredTargets) {
|
|
300
|
+
try {
|
|
301
|
+
const rawResults = await eslint.lintFiles([targetFile]);
|
|
302
|
+
for (const result of rawResults) {
|
|
303
|
+
lintResults.push({
|
|
304
|
+
filePath: result.filePath,
|
|
305
|
+
ignored: Boolean(result.ignored),
|
|
306
|
+
messages: result.messages.map((msg) => ({
|
|
307
|
+
ruleId: typeof msg.ruleId === "string" ? msg.ruleId : "unknown",
|
|
308
|
+
message: msg.message,
|
|
309
|
+
fatal: Boolean(msg.fatal)
|
|
310
|
+
}))
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
} catch (err) {
|
|
314
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
315
|
+
if (message.includes("No files matching") || message.includes("are ignored") || message.includes("All files matched")) {
|
|
316
|
+
ignoredResults.push({
|
|
317
|
+
filePath: targetFile,
|
|
318
|
+
ignored: true,
|
|
319
|
+
messages: []
|
|
320
|
+
});
|
|
321
|
+
} else {
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
const rawResults = await eslint.lintFiles(unignoredTargets);
|
|
328
|
+
for (const result of rawResults) {
|
|
329
|
+
lintResults.push({
|
|
330
|
+
filePath: result.filePath,
|
|
331
|
+
ignored: Boolean(result.ignored),
|
|
332
|
+
messages: result.messages.map((msg) => ({
|
|
333
|
+
ruleId: typeof msg.ruleId === "string" ? msg.ruleId : "unknown",
|
|
334
|
+
message: msg.message,
|
|
335
|
+
fatal: Boolean(msg.fatal)
|
|
336
|
+
}))
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return [...lintResults, ...ignoredResults];
|
|
342
|
+
} catch (err) {
|
|
343
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
344
|
+
if (message.includes("All files matched") && message.includes("are ignored")) {
|
|
345
|
+
return targets.map((t) => ({
|
|
346
|
+
filePath: t,
|
|
347
|
+
ignored: true,
|
|
348
|
+
messages: []
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
throw new Error(`Failed to run ESLint: ${message}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function countLinesOfCode(sourceFiles, cwd = process.cwd()) {
|
|
355
|
+
const locMap = {};
|
|
356
|
+
for (const file of sourceFiles) {
|
|
357
|
+
try {
|
|
358
|
+
const absolutePath = path.resolve(cwd, file);
|
|
359
|
+
const content = await fs.readFile(absolutePath, "utf8");
|
|
360
|
+
locMap[file] = content.split("\n").length;
|
|
361
|
+
} catch {
|
|
362
|
+
locMap[file] = 0;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return locMap;
|
|
366
|
+
}
|
|
367
|
+
function getToolVersion() {
|
|
368
|
+
try {
|
|
369
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
370
|
+
let currDir = path.dirname(currentFile);
|
|
371
|
+
while (currDir) {
|
|
372
|
+
const pkgPath = path.join(currDir, "package.json");
|
|
373
|
+
if (existsSync(pkgPath)) {
|
|
374
|
+
try {
|
|
375
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
376
|
+
if (pkg.name === "@dependency-maritime/cli" && typeof pkg.version === "string") {
|
|
377
|
+
return pkg.version;
|
|
378
|
+
}
|
|
379
|
+
} catch {
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const parent = path.dirname(currDir);
|
|
383
|
+
if (parent === currDir) break;
|
|
384
|
+
currDir = parent;
|
|
385
|
+
}
|
|
386
|
+
} catch {
|
|
387
|
+
}
|
|
388
|
+
return "0.0.0";
|
|
389
|
+
}
|
|
390
|
+
async function writeOutputFiles(metricsPath, metricsData, reportPath, reportData, arg5, arg6, arg7) {
|
|
391
|
+
let manifestPath;
|
|
392
|
+
let manifestData;
|
|
393
|
+
let cwd = process.cwd();
|
|
394
|
+
if (typeof arg5 === "object" && arg5 !== null) {
|
|
395
|
+
manifestPath = arg5.manifestPath;
|
|
396
|
+
manifestData = arg5.manifestData;
|
|
397
|
+
if (arg5.cwd) {
|
|
398
|
+
cwd = arg5.cwd;
|
|
399
|
+
}
|
|
400
|
+
} else if (typeof arg5 === "string") {
|
|
401
|
+
if (arg6 !== void 0) {
|
|
402
|
+
manifestPath = arg5;
|
|
403
|
+
manifestData = arg6;
|
|
404
|
+
if (typeof arg7 === "string") {
|
|
405
|
+
cwd = arg7;
|
|
406
|
+
}
|
|
407
|
+
} else {
|
|
408
|
+
cwd = arg5;
|
|
409
|
+
}
|
|
410
|
+
} else if (typeof arg7 === "string") {
|
|
411
|
+
cwd = arg7;
|
|
412
|
+
}
|
|
413
|
+
const absMetricsPath = path.resolve(cwd, metricsPath);
|
|
414
|
+
const absReportPath = path.resolve(cwd, reportPath);
|
|
415
|
+
await fs.mkdir(path.dirname(absMetricsPath), { recursive: true });
|
|
416
|
+
await fs.mkdir(path.dirname(absReportPath), { recursive: true });
|
|
417
|
+
const promises = [
|
|
418
|
+
fs.writeFile(absMetricsPath, JSON.stringify(metricsData, null, 2)),
|
|
419
|
+
fs.writeFile(absReportPath, reportData)
|
|
420
|
+
];
|
|
421
|
+
if (manifestPath && manifestData !== void 0) {
|
|
422
|
+
const absManifestPath = path.resolve(cwd, manifestPath);
|
|
423
|
+
await fs.mkdir(path.dirname(absManifestPath), { recursive: true });
|
|
424
|
+
promises.push(fs.writeFile(absManifestPath, JSON.stringify(manifestData, null, 2)));
|
|
425
|
+
}
|
|
426
|
+
await Promise.all(promises);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/cli/analyze/calculate-metrics.ts
|
|
430
|
+
function isSupportedTypeScriptFile(filepath) {
|
|
431
|
+
const lower = filepath.toLowerCase();
|
|
432
|
+
if (!lower.endsWith(".ts") && !lower.endsWith(".tsx")) {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
if (lower.endsWith(".d.ts")) {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
if (lower.includes(".test.") || lower.includes(".spec.")) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
function calculateInstability(fanIn, fanOut) {
|
|
444
|
+
if (fanIn + fanOut === 0) {
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
return fanOut / (fanIn + fanOut);
|
|
448
|
+
}
|
|
449
|
+
function calculateScore(loc, complexity, fanOut, instability) {
|
|
450
|
+
return loc / 10 + complexity * 2 + fanOut * 2 + instability * 20;
|
|
451
|
+
}
|
|
452
|
+
function calculateHealthScore(files, thresholds) {
|
|
453
|
+
let score = 100;
|
|
454
|
+
for (const f of files) {
|
|
455
|
+
if (f.loc > thresholds.loc) score -= 1;
|
|
456
|
+
if (f.complexity > thresholds.complexity) score -= 1;
|
|
457
|
+
if (f.fanOut > thresholds.fanOut) score -= 1;
|
|
458
|
+
}
|
|
459
|
+
return Math.max(0, Math.min(100, score));
|
|
460
|
+
}
|
|
461
|
+
function calculateMetrics(modules, locMap, complexityMap, thresholds, sourcePrefix = "src") {
|
|
462
|
+
const rawPrefixes = Array.isArray(sourcePrefix) ? sourcePrefix : [sourcePrefix];
|
|
463
|
+
const normalizedPrefixes = rawPrefixes.map((p) => p === "." ? "" : p.replace(/\/+$/, ""));
|
|
464
|
+
const files = modules.filter((m) => {
|
|
465
|
+
if (m.source.startsWith("node_modules/") || m.source.startsWith("node:")) {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
const isSource = normalizedPrefixes.some((prefix) => {
|
|
469
|
+
if (prefix === "") return true;
|
|
470
|
+
return m.source === prefix || m.source.startsWith(`${prefix}/`);
|
|
471
|
+
});
|
|
472
|
+
return isSource && isSupportedTypeScriptFile(m.source);
|
|
473
|
+
}).map((m) => {
|
|
474
|
+
const loc = locMap[m.source] || 0;
|
|
475
|
+
const fanOut = m.dependencies.length;
|
|
476
|
+
const fanIn = m.dependents.length;
|
|
477
|
+
const instability = calculateInstability(fanIn, fanOut);
|
|
478
|
+
let complexity = 0;
|
|
479
|
+
let scanned = false;
|
|
480
|
+
const eslintData = complexityMap[m.source];
|
|
481
|
+
if (eslintData !== void 0) {
|
|
482
|
+
if (typeof eslintData === "object" && eslintData !== null) {
|
|
483
|
+
complexity = eslintData.complexity;
|
|
484
|
+
scanned = eslintData.scanned;
|
|
485
|
+
} else if (typeof eslintData === "number") {
|
|
486
|
+
complexity = eslintData;
|
|
487
|
+
scanned = true;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const score = calculateScore(loc, complexity, fanOut, instability);
|
|
491
|
+
return {
|
|
492
|
+
file: m.source,
|
|
493
|
+
loc,
|
|
494
|
+
fanOut,
|
|
495
|
+
fanIn,
|
|
496
|
+
instability: parseFloat(instability.toFixed(2)),
|
|
497
|
+
complexity,
|
|
498
|
+
score: parseFloat(score.toFixed(1)),
|
|
499
|
+
scanned
|
|
500
|
+
};
|
|
501
|
+
});
|
|
502
|
+
const healthScore = calculateHealthScore(files, thresholds);
|
|
503
|
+
const unmeasuredFiles = files.filter((f) => !f.scanned).map((f) => f.file);
|
|
504
|
+
const skippedCount = unmeasuredFiles.length;
|
|
505
|
+
const topByScore = [...files].sort((a, b) => {
|
|
506
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
507
|
+
return a.file.localeCompare(b.file);
|
|
508
|
+
}).slice(0, 10);
|
|
509
|
+
const topByComplexity = [...files].sort((a, b) => {
|
|
510
|
+
if (b.complexity !== a.complexity) return b.complexity - a.complexity;
|
|
511
|
+
return a.file.localeCompare(b.file);
|
|
512
|
+
}).slice(0, 10);
|
|
513
|
+
return {
|
|
514
|
+
files,
|
|
515
|
+
healthScore: parseFloat(healthScore.toFixed(1)),
|
|
516
|
+
topByScore,
|
|
517
|
+
topByComplexity,
|
|
518
|
+
skippedCount,
|
|
519
|
+
unmeasuredFiles
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/cli/analyze/parse-eslint.ts
|
|
524
|
+
import * as path2 from "path";
|
|
525
|
+
function parseEslintComplexityReport(eslintResults, cwd) {
|
|
526
|
+
const complexityMap = {};
|
|
527
|
+
for (const file of eslintResults) {
|
|
528
|
+
const relPath = path2.relative(cwd, file.filePath).replace(/\\/g, "/");
|
|
529
|
+
if (file.ignored || file.messages.some((m) => m.fatal === true)) {
|
|
530
|
+
complexityMap[relPath] = { complexity: 0, scanned: false };
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
let maxC = 0;
|
|
534
|
+
for (const msg of file.messages) {
|
|
535
|
+
if (msg.ruleId === "complexity") {
|
|
536
|
+
const match = msg.message.match(/complexity of (\d+)/);
|
|
537
|
+
if (match) {
|
|
538
|
+
const c = parseInt(match[1], 10);
|
|
539
|
+
if (c > maxC) {
|
|
540
|
+
maxC = c;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
complexityMap[relPath] = {
|
|
546
|
+
complexity: maxC > 0 ? maxC : 1,
|
|
547
|
+
scanned: true
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
return complexityMap;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// src/cli/analyze/render-markdown-report.ts
|
|
554
|
+
function escapeMarkdown(text) {
|
|
555
|
+
return text.replace(/_/g, "\\_").replace(/\*/g, "\\*");
|
|
556
|
+
}
|
|
557
|
+
function renderMarkdownReport(result, thresholds, date = /* @__PURE__ */ new Date()) {
|
|
558
|
+
const dateString = date.toISOString().split("T")[0];
|
|
559
|
+
const healthScoreFormatted = result.healthScore.toFixed(1);
|
|
560
|
+
const totalFiles = result.files.length;
|
|
561
|
+
const measuredFiles = result.files.filter((f) => f.scanned).length;
|
|
562
|
+
const unmeasuredFilesCount = result.skippedCount;
|
|
563
|
+
const scoreTableRows = result.topByScore.map(
|
|
564
|
+
(f) => `| \`${escapeMarkdown(f.file)}\` | **${f.score}** | ${f.loc} | ${f.complexity} | ${f.fanOut} | ${f.instability} |`
|
|
565
|
+
).join("\n");
|
|
566
|
+
const complexityTableRows = result.topByComplexity.map(
|
|
567
|
+
(f) => `| \`${escapeMarkdown(f.file)}\` | **${f.complexity}** | ${f.loc} |`
|
|
568
|
+
).join("\n");
|
|
569
|
+
let skippedSection = "";
|
|
570
|
+
if (unmeasuredFilesCount > 0 && result.unmeasuredFiles.length > 0) {
|
|
571
|
+
const fileList = result.unmeasuredFiles.map((f) => `- \`${escapeMarkdown(f)}\``).join("\n");
|
|
572
|
+
skippedSection = `
|
|
573
|
+
|
|
574
|
+
### \u26A0\uFE0F Skipped / Unmeasured Files (${unmeasuredFilesCount})
|
|
575
|
+
${fileList}`;
|
|
576
|
+
}
|
|
577
|
+
return `
|
|
578
|
+
## \u{1F6A8} Automated Complexity Report
|
|
579
|
+
|
|
580
|
+
**Last Updated:** ${dateString}
|
|
581
|
+
|
|
582
|
+
### \u{1F3E5} Repository Health Score: **${healthScoreFormatted} / 100**
|
|
583
|
+
|
|
584
|
+
* **Formula**: 100 - Penalties for Files exceeding thresholds (LOC > ${thresholds.loc}, Complexity > ${thresholds.complexity}, Fan-Out > ${thresholds.fanOut}).
|
|
585
|
+
* **Total Graph Files**: ${totalFiles}
|
|
586
|
+
* **Measured Files**: ${measuredFiles}
|
|
587
|
+
* **Unmeasured Files**: ${unmeasuredFilesCount}
|
|
588
|
+
|
|
589
|
+
### \u{1F525} Top 10 High-Complexity Files (Compound Score)
|
|
590
|
+
_Score = (LOC/10) + (Complexity*2) + (FanOut*2) + (Instability*20)_
|
|
591
|
+
|
|
592
|
+
| File | Score | LOC | Complexity | Fan-Out | Instability |
|
|
593
|
+
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
594
|
+
${scoreTableRows}
|
|
595
|
+
|
|
596
|
+
### \u{1F9E0} Top 10 Logic-Heavy Files (Cyclomatic Complexity)
|
|
597
|
+
| File | Max Complexity | LOC |
|
|
598
|
+
| :--- | :--- | :--- |
|
|
599
|
+
${complexityTableRows}${skippedSection}
|
|
600
|
+
`.trim() + "\n";
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// src/cli/analyze/environment.ts
|
|
604
|
+
import * as fs2 from "fs";
|
|
605
|
+
import * as path3 from "path";
|
|
606
|
+
import { createRequire } from "node:module";
|
|
607
|
+
var LEGACY_CONFIG_FILES = [
|
|
608
|
+
".eslintrc",
|
|
609
|
+
".eslintrc.js",
|
|
610
|
+
".eslintrc.cjs",
|
|
611
|
+
".eslintrc.mjs",
|
|
612
|
+
".eslintrc.json",
|
|
613
|
+
".eslintrc.yaml",
|
|
614
|
+
".eslintrc.yml"
|
|
615
|
+
];
|
|
616
|
+
var FLAT_CONFIG_FILES = [
|
|
617
|
+
"eslint.config.js",
|
|
618
|
+
"eslint.config.mjs",
|
|
619
|
+
"eslint.config.cjs",
|
|
620
|
+
"eslint.config.ts",
|
|
621
|
+
"eslint.config.mts",
|
|
622
|
+
"eslint.config.cts"
|
|
623
|
+
];
|
|
624
|
+
function validateNodeVersion(nodeVersionStr = process.versions.node) {
|
|
625
|
+
const cleanVersion = nodeVersionStr.replace(/^v/, "");
|
|
626
|
+
const parts = cleanVersion.split(".").map((p) => parseInt(p, 10));
|
|
627
|
+
const major = parts[0] || 0;
|
|
628
|
+
const minor = parts[1] || 0;
|
|
629
|
+
if (major < 20 || major === 20 && minor < 19) {
|
|
630
|
+
throw new ValidationError(
|
|
631
|
+
`Maritime requires Node.js >=20.19.0 (current version: v${cleanVersion}).`
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
function detectEslintConfig(cwd = process.cwd()) {
|
|
636
|
+
for (const legacyFile of LEGACY_CONFIG_FILES) {
|
|
637
|
+
const fullPath = path3.join(cwd, legacyFile);
|
|
638
|
+
if (fs2.existsSync(fullPath)) {
|
|
639
|
+
return { mode: "Legacy", isLegacy: true, hasFlatConfig: false, legacyFile };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const pkgPath = path3.join(cwd, "package.json");
|
|
643
|
+
if (fs2.existsSync(pkgPath)) {
|
|
644
|
+
try {
|
|
645
|
+
const content = fs2.readFileSync(pkgPath, "utf8");
|
|
646
|
+
const parsed = JSON.parse(content);
|
|
647
|
+
if (parsed && typeof parsed === "object" && "eslintConfig" in parsed && parsed.eslintConfig !== void 0) {
|
|
648
|
+
return { mode: "Legacy", isLegacy: true, hasFlatConfig: false, legacyFile: "package.json (eslintConfig)" };
|
|
649
|
+
}
|
|
650
|
+
} catch {
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
for (const flatFile of FLAT_CONFIG_FILES) {
|
|
654
|
+
const fullPath = path3.join(cwd, flatFile);
|
|
655
|
+
if (fs2.existsSync(fullPath)) {
|
|
656
|
+
return { mode: `Flat Config (${flatFile})`, isLegacy: false, hasFlatConfig: true };
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return { mode: "None Detected", isLegacy: false, hasFlatConfig: false };
|
|
660
|
+
}
|
|
661
|
+
function validateEslintEnvironment(cwd = process.cwd(), nodeVersionStr) {
|
|
662
|
+
validateNodeVersion(nodeVersionStr);
|
|
663
|
+
const absCwd = path3.resolve(cwd);
|
|
664
|
+
const req = createRequire(path3.join(absCwd, "package.json"));
|
|
665
|
+
let eslintModule;
|
|
666
|
+
try {
|
|
667
|
+
const eslintPath = req.resolve("eslint");
|
|
668
|
+
eslintModule = req(eslintPath);
|
|
669
|
+
} catch {
|
|
670
|
+
throw new ValidationError(
|
|
671
|
+
`ESLint is not installed in ${absCwd}. Maritime requires ESLint 9+ as a peer dependency.`
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
const eslintVersion = eslintModule.ESLint?.version;
|
|
675
|
+
if (eslintVersion) {
|
|
676
|
+
const major = parseInt(eslintVersion.split(".")[0], 10);
|
|
677
|
+
if (isNaN(major) || major < 9) {
|
|
678
|
+
throw new ValidationError(
|
|
679
|
+
`Unsupported ESLint version (v${eslintVersion}). Maritime requires ESLint >=9.0.0.`
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const detected = detectEslintConfig(cwd);
|
|
684
|
+
if (detected.isLegacy) {
|
|
685
|
+
throw new ValidationError(
|
|
686
|
+
`Legacy ESLint configuration detected (${detected.legacyFile}). Maritime requires ESLint 9+ flat configuration (eslint.config.*).`
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
if (!detected.hasFlatConfig) {
|
|
690
|
+
throw new ValidationError(
|
|
691
|
+
`No ESLint flat configuration found in ${cwd}. Maritime requires ESLint 9+ flat configuration (eslint.config.*).`
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
return { mode: detected.mode };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/schema/manifest.ts
|
|
698
|
+
import { z as z2 } from "zod";
|
|
699
|
+
var MANIFEST_SCHEMA_VERSION = "1.0.0";
|
|
700
|
+
var ArtifactManifestArtifactsSchema = z2.object({
|
|
701
|
+
graph: z2.string().min(1),
|
|
702
|
+
metrics: z2.string().min(1),
|
|
703
|
+
report: z2.string().min(1)
|
|
704
|
+
});
|
|
705
|
+
var ArtifactManifestSummarySchema = z2.object({
|
|
706
|
+
totalFiles: z2.number().int().nonnegative(),
|
|
707
|
+
healthScore: z2.number(),
|
|
708
|
+
scannedCount: z2.number().int().nonnegative(),
|
|
709
|
+
skippedCount: z2.number().int().nonnegative()
|
|
710
|
+
});
|
|
711
|
+
var ArtifactManifestSchema = z2.object({
|
|
712
|
+
schemaVersion: z2.literal(MANIFEST_SCHEMA_VERSION),
|
|
713
|
+
toolVersion: z2.string().min(1),
|
|
714
|
+
generatedAt: z2.string(),
|
|
715
|
+
sourceRoots: z2.array(z2.string()).min(1),
|
|
716
|
+
artifacts: ArtifactManifestArtifactsSchema,
|
|
717
|
+
summary: ArtifactManifestSummarySchema
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// src/cli/commands/analyze.ts
|
|
721
|
+
import * as path4 from "path";
|
|
722
|
+
var DEFAULT_THRESHOLDS = {
|
|
723
|
+
loc: 300,
|
|
724
|
+
complexity: 10,
|
|
725
|
+
fanOut: 15
|
|
726
|
+
};
|
|
727
|
+
async function runAnalyzeCommand(args) {
|
|
728
|
+
let values;
|
|
729
|
+
try {
|
|
730
|
+
const parsed = parseArgs({
|
|
731
|
+
args,
|
|
732
|
+
allowPositionals: true,
|
|
733
|
+
options: {
|
|
734
|
+
source: { type: "string", multiple: true, default: ["src"] },
|
|
735
|
+
graph: { type: "string" },
|
|
736
|
+
metrics: { type: "string" },
|
|
737
|
+
report: { type: "string" },
|
|
738
|
+
output: { type: "string" },
|
|
739
|
+
"depcruise-config": { type: "string" },
|
|
740
|
+
cwd: { type: "string" },
|
|
741
|
+
"fail-on-unmeasured": { type: "boolean", default: false },
|
|
742
|
+
help: { type: "boolean", short: "h" }
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
values = parsed.values;
|
|
746
|
+
} catch (e) {
|
|
747
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
748
|
+
console.error(`Error parsing arguments: ${message}`);
|
|
749
|
+
return 2;
|
|
750
|
+
}
|
|
751
|
+
if (values.help) {
|
|
752
|
+
console.log(`
|
|
753
|
+
Usage: maritime analyze [options]
|
|
754
|
+
|
|
755
|
+
Options:
|
|
756
|
+
--output <dir> Output directory for all generated artifacts (e.g. .maritime)
|
|
757
|
+
--source <dir> Source directory/directories to analyze (repeatable or comma-separated, default: "src")
|
|
758
|
+
--graph <file> Dependency graph JSON file path (input if file exists; output if generated)
|
|
759
|
+
--metrics <file> Output JSON file for complexity metrics
|
|
760
|
+
--report <file> Output Markdown file for complexity report
|
|
761
|
+
--depcruise-config <file> Optional path to repository dependency-cruiser configuration
|
|
762
|
+
--cwd <dir> Working directory root for resolution
|
|
763
|
+
--fail-on-unmeasured Fail if any graph source file is skipped/unmeasured by ESLint
|
|
764
|
+
|
|
765
|
+
Examples:
|
|
766
|
+
# Concise generated-graph workflow:
|
|
767
|
+
maritime analyze --source app --output .maritime
|
|
768
|
+
|
|
769
|
+
# Explicit pre-generated graph workflow:
|
|
770
|
+
maritime analyze --source app --graph artifacts/dependency-graph.json --metrics metrics.json --report report.md
|
|
771
|
+
|
|
772
|
+
Exit Codes:
|
|
773
|
+
0 - Successful analysis
|
|
774
|
+
1 - Operational or runtime failure
|
|
775
|
+
2 - Invalid CLI arguments, environment, or invalid input artifact/schema
|
|
776
|
+
`);
|
|
777
|
+
return 0;
|
|
778
|
+
}
|
|
779
|
+
const workingDir = values.cwd ? path4.resolve(values.cwd) : process.cwd();
|
|
780
|
+
let targetGraphPath = values.graph;
|
|
781
|
+
let targetMetricsPath = values.metrics;
|
|
782
|
+
let targetReportPath = values.report;
|
|
783
|
+
if (values.output) {
|
|
784
|
+
targetGraphPath = targetGraphPath ?? path4.join(values.output, "dependency-graph.json");
|
|
785
|
+
targetMetricsPath = targetMetricsPath ?? path4.join(values.output, "complexity-metrics.json");
|
|
786
|
+
targetReportPath = targetReportPath ?? path4.join(values.output, "complexity-report.md");
|
|
787
|
+
}
|
|
788
|
+
if (!targetMetricsPath || !targetReportPath) {
|
|
789
|
+
console.error("Error: Either --output or both --metrics and --report must be specified.");
|
|
790
|
+
return 2;
|
|
791
|
+
}
|
|
792
|
+
if (!targetGraphPath) {
|
|
793
|
+
targetGraphPath = "dependency-graph.json";
|
|
794
|
+
}
|
|
795
|
+
const rawSources = values.source && values.source.length > 0 ? values.source.flatMap((s) => s.split(",").map((item) => item.trim())).filter(Boolean) : ["src"];
|
|
796
|
+
const normalizedSources = rawSources.map((rawSrc) => {
|
|
797
|
+
let norm = path4.relative(workingDir, path4.resolve(workingDir, rawSrc)).replace(/\\/g, "/");
|
|
798
|
+
if (norm === "") norm = ".";
|
|
799
|
+
return norm;
|
|
800
|
+
});
|
|
801
|
+
try {
|
|
802
|
+
console.log("\u{1F4CA} Starting Complexity Analysis...");
|
|
803
|
+
console.log(" - Validating Environment & Configuration...");
|
|
804
|
+
const { mode: eslintConfigMode } = validateEslintEnvironment(workingDir);
|
|
805
|
+
console.log(` - Working Directory: ${workingDir}`);
|
|
806
|
+
console.log(` - Source Root (raw): ${rawSources.join(", ")}`);
|
|
807
|
+
console.log(` - Source Root (normalized): ${normalizedSources.join(", ")}`);
|
|
808
|
+
console.log(` - Graph Path: ${targetGraphPath}`);
|
|
809
|
+
console.log(` - ESLint Config Mode: ${eslintConfigMode}`);
|
|
810
|
+
const manifestDir = values.output ? path4.resolve(workingDir, values.output) : path4.dirname(path4.resolve(workingDir, targetMetricsPath));
|
|
811
|
+
let modules;
|
|
812
|
+
const isGraphSupplied = values.graph !== void 0;
|
|
813
|
+
let effectiveGraphPath;
|
|
814
|
+
if (isGraphSupplied) {
|
|
815
|
+
console.log(" - Reading Supplied Dependency Cruiser JSON...");
|
|
816
|
+
modules = await readDependencyGraph(values.graph, workingDir);
|
|
817
|
+
const absGraphPath = path4.resolve(workingDir, values.graph);
|
|
818
|
+
const relGraphToManifest = path4.relative(manifestDir, absGraphPath);
|
|
819
|
+
const isOutside = relGraphToManifest.startsWith("..") || path4.isAbsolute(relGraphToManifest);
|
|
820
|
+
if (isOutside) {
|
|
821
|
+
console.log(" - Staging supplied graph into artifact directory...");
|
|
822
|
+
effectiveGraphPath = path4.join(manifestDir, path4.basename(absGraphPath));
|
|
823
|
+
try {
|
|
824
|
+
await fsPromises.mkdir(manifestDir, { recursive: true });
|
|
825
|
+
await fsPromises.copyFile(absGraphPath, effectiveGraphPath);
|
|
826
|
+
} catch (err) {
|
|
827
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
828
|
+
throw new Error(`Failed to stage supplied dependency graph into artifact directory: ${message}`);
|
|
829
|
+
}
|
|
830
|
+
} else {
|
|
831
|
+
effectiveGraphPath = absGraphPath;
|
|
832
|
+
}
|
|
833
|
+
} else {
|
|
834
|
+
console.log(" - Generating Dependency Graph with dependency-cruiser...");
|
|
835
|
+
const genResult = await generateDependencyGraph({
|
|
836
|
+
sourceRoots: rawSources,
|
|
837
|
+
configPath: values["depcruise-config"],
|
|
838
|
+
cwd: workingDir
|
|
839
|
+
});
|
|
840
|
+
console.log(` - Dependency-Cruiser Config Source: ${genResult.configSource}`);
|
|
841
|
+
modules = genResult.modules;
|
|
842
|
+
effectiveGraphPath = path4.resolve(workingDir, targetGraphPath);
|
|
843
|
+
await fsPromises.mkdir(path4.dirname(effectiveGraphPath), { recursive: true });
|
|
844
|
+
await fsPromises.writeFile(effectiveGraphPath, JSON.stringify(genResult.cruiseResult, null, 2));
|
|
845
|
+
}
|
|
846
|
+
const sourceFiles = modules.map((m) => m.source).filter((src) => {
|
|
847
|
+
const isSource = normalizedSources.some((norm) => {
|
|
848
|
+
if (norm === ".") return true;
|
|
849
|
+
return src === norm || src.startsWith(`${norm}/`);
|
|
850
|
+
});
|
|
851
|
+
return isSource && isSupportedTypeScriptFile(src);
|
|
852
|
+
});
|
|
853
|
+
console.log(" - Running ESLint for Complexity...");
|
|
854
|
+
const eslintResults = await runEslintComplexityScan(rawSources, sourceFiles, workingDir);
|
|
855
|
+
const complexityMap = parseEslintComplexityReport(eslintResults, workingDir);
|
|
856
|
+
console.log(" - Counting Lines of Code...");
|
|
857
|
+
const locMap = await countLinesOfCode(sourceFiles, workingDir);
|
|
858
|
+
console.log(" - Aggregating Metrics...");
|
|
859
|
+
const analysisResult = calculateMetrics(
|
|
860
|
+
modules,
|
|
861
|
+
locMap,
|
|
862
|
+
complexityMap,
|
|
863
|
+
DEFAULT_THRESHOLDS,
|
|
864
|
+
normalizedSources
|
|
865
|
+
);
|
|
866
|
+
console.log(` - Skipped / Unmeasured Source Files: ${analysisResult.skippedCount}`);
|
|
867
|
+
if (analysisResult.skippedCount > 0) {
|
|
868
|
+
console.warn(`\u26A0\uFE0F Warning: ${analysisResult.skippedCount} graph source file(s) were skipped or ignored by ESLint and could not be measured:`);
|
|
869
|
+
analysisResult.unmeasuredFiles.forEach((f) => console.warn(` - ${f}`));
|
|
870
|
+
if (values["fail-on-unmeasured"]) {
|
|
871
|
+
throw new ValidationError(
|
|
872
|
+
`Analysis failed because ${analysisResult.skippedCount} graph source file(s) were not scanned by ESLint (--fail-on-unmeasured).`
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
console.log(" - Generating Outputs...");
|
|
877
|
+
const metricsMap = analysisResult.files.reduce((acc, f) => {
|
|
878
|
+
acc[f.file] = {
|
|
879
|
+
complexity: f.complexity,
|
|
880
|
+
loc: f.loc,
|
|
881
|
+
instability: f.instability,
|
|
882
|
+
fanIn: f.fanIn,
|
|
883
|
+
fanOut: f.fanOut,
|
|
884
|
+
scanned: f.scanned
|
|
885
|
+
};
|
|
886
|
+
return acc;
|
|
887
|
+
}, {});
|
|
888
|
+
const reportContent = renderMarkdownReport(analysisResult, DEFAULT_THRESHOLDS);
|
|
889
|
+
const targetManifestPath = path4.relative(workingDir, path4.join(manifestDir, "manifest.json")).replace(/\\/g, "/");
|
|
890
|
+
const relGraph = path4.relative(manifestDir, effectiveGraphPath).replace(/\\/g, "/");
|
|
891
|
+
const relMetrics = path4.relative(manifestDir, path4.resolve(workingDir, targetMetricsPath)).replace(/\\/g, "/");
|
|
892
|
+
const relReport = path4.relative(manifestDir, path4.resolve(workingDir, targetReportPath)).replace(/\\/g, "/");
|
|
893
|
+
const artifactRelPaths = { graph: relGraph, metrics: relMetrics, report: relReport };
|
|
894
|
+
for (const [key, relPath] of Object.entries(artifactRelPaths)) {
|
|
895
|
+
if (relPath.startsWith("..") || path4.isAbsolute(relPath)) {
|
|
896
|
+
throw new ValidationError(`Manifest artifact path for "${key}" escapes the artifact directory: "${relPath}"`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
const manifest = {
|
|
900
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
901
|
+
toolVersion: getToolVersion(),
|
|
902
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
903
|
+
sourceRoots: normalizedSources,
|
|
904
|
+
artifacts: {
|
|
905
|
+
graph: relGraph,
|
|
906
|
+
metrics: relMetrics,
|
|
907
|
+
report: relReport
|
|
908
|
+
},
|
|
909
|
+
summary: {
|
|
910
|
+
totalFiles: analysisResult.files.length,
|
|
911
|
+
healthScore: analysisResult.healthScore,
|
|
912
|
+
scannedCount: analysisResult.files.filter((f) => f.scanned).length,
|
|
913
|
+
skippedCount: analysisResult.skippedCount
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
await writeOutputFiles(
|
|
917
|
+
targetMetricsPath,
|
|
918
|
+
metricsMap,
|
|
919
|
+
targetReportPath,
|
|
920
|
+
reportContent,
|
|
921
|
+
targetManifestPath,
|
|
922
|
+
manifest,
|
|
923
|
+
workingDir
|
|
924
|
+
);
|
|
925
|
+
console.log("\u2705 Complexity Report Updated and Metrics Exported!");
|
|
926
|
+
return 0;
|
|
927
|
+
} catch (e) {
|
|
928
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
929
|
+
console.error(`Error analyzing project: ${message}`);
|
|
930
|
+
if (e instanceof ValidationError) {
|
|
931
|
+
return 2;
|
|
932
|
+
}
|
|
933
|
+
return 1;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// src/cli/commands/validate.ts
|
|
938
|
+
import { parseArgs as parseArgs2 } from "node:util";
|
|
939
|
+
import * as path6 from "node:path";
|
|
940
|
+
|
|
941
|
+
// src/cli/validate/validate.ts
|
|
942
|
+
import * as fsPromises2 from "node:fs/promises";
|
|
943
|
+
import * as path5 from "node:path";
|
|
944
|
+
|
|
945
|
+
// src/schema/complexity-metrics.ts
|
|
946
|
+
import { z as z3 } from "zod";
|
|
947
|
+
var ComplexityMetricSchema = z3.object({
|
|
948
|
+
complexity: z3.number(),
|
|
949
|
+
loc: z3.number(),
|
|
950
|
+
instability: z3.number().optional(),
|
|
951
|
+
fanIn: z3.number().optional(),
|
|
952
|
+
fanOut: z3.number().optional(),
|
|
953
|
+
scanned: z3.boolean().optional()
|
|
954
|
+
});
|
|
955
|
+
var ComplexityMetricsMapSchema = z3.record(z3.string(), ComplexityMetricSchema);
|
|
956
|
+
|
|
957
|
+
// src/cli/validate/validate.ts
|
|
958
|
+
async function validateArtifacts(options = {}) {
|
|
959
|
+
const workingDir = options.cwd ? path5.resolve(options.cwd) : process.cwd();
|
|
960
|
+
const artifactDirRelative = options.artifactDir ?? ".maritime";
|
|
961
|
+
const artifactDir = path5.resolve(workingDir, artifactDirRelative);
|
|
962
|
+
try {
|
|
963
|
+
const stat2 = await fsPromises2.stat(artifactDir);
|
|
964
|
+
if (!stat2.isDirectory()) {
|
|
965
|
+
throw new ValidationError(`Artifact path is not a directory: ${artifactDirRelative}`);
|
|
966
|
+
}
|
|
967
|
+
} catch (err) {
|
|
968
|
+
if (err instanceof ValidationError) throw err;
|
|
969
|
+
throw new ValidationError(`Artifact directory not found: ${artifactDirRelative}`);
|
|
970
|
+
}
|
|
971
|
+
const manifestPath = path5.join(artifactDir, "manifest.json");
|
|
972
|
+
let manifestRaw;
|
|
973
|
+
try {
|
|
974
|
+
manifestRaw = await fsPromises2.readFile(manifestPath, "utf8");
|
|
975
|
+
} catch {
|
|
976
|
+
throw new ValidationError(`Missing manifest file: ${path5.relative(workingDir, manifestPath)}`);
|
|
977
|
+
}
|
|
978
|
+
let manifestJson;
|
|
979
|
+
try {
|
|
980
|
+
manifestJson = JSON.parse(manifestRaw);
|
|
981
|
+
} catch (err) {
|
|
982
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
983
|
+
throw new ValidationError(`Malformed JSON in manifest file ${path5.relative(workingDir, manifestPath)}: ${message}`);
|
|
984
|
+
}
|
|
985
|
+
const manifestParse = ArtifactManifestSchema.safeParse(manifestJson);
|
|
986
|
+
if (!manifestParse.success) {
|
|
987
|
+
const rawObj = manifestJson;
|
|
988
|
+
if (rawObj && typeof rawObj === "object" && "schemaVersion" in rawObj && typeof rawObj.schemaVersion === "string" && rawObj.schemaVersion !== MANIFEST_SCHEMA_VERSION) {
|
|
989
|
+
throw new ValidationError(
|
|
990
|
+
`Unsupported manifest schemaVersion "${rawObj.schemaVersion}". Supported schemaVersion is "${MANIFEST_SCHEMA_VERSION}".`
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
throw new ValidationError(`Invalid manifest structure in ${path5.relative(workingDir, manifestPath)}:
|
|
994
|
+
${manifestParse.error.message}`);
|
|
995
|
+
}
|
|
996
|
+
const manifest = manifestParse.data;
|
|
997
|
+
const artifactKeys = ["graph", "metrics", "report"];
|
|
998
|
+
for (const key of artifactKeys) {
|
|
999
|
+
const declaredPath = manifest.artifacts[key];
|
|
1000
|
+
const resolvedPath = path5.resolve(artifactDir, declaredPath);
|
|
1001
|
+
const rel = path5.relative(artifactDir, resolvedPath);
|
|
1002
|
+
if (rel.startsWith("..") || path5.isAbsolute(rel)) {
|
|
1003
|
+
throw new ValidationError(`Manifest artifact path for "${key}" escapes the artifact directory: "${declaredPath}"`);
|
|
1004
|
+
}
|
|
1005
|
+
try {
|
|
1006
|
+
await fsPromises2.access(resolvedPath);
|
|
1007
|
+
} catch {
|
|
1008
|
+
throw new ValidationError(`Declared artifact file for "${key}" not found: ${declaredPath}`);
|
|
1009
|
+
}
|
|
1010
|
+
if (key === "graph") {
|
|
1011
|
+
let graphRaw;
|
|
1012
|
+
try {
|
|
1013
|
+
graphRaw = await fsPromises2.readFile(resolvedPath, "utf8");
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1016
|
+
throw new ValidationError(`Failed to read graph file ${declaredPath}: ${message}`);
|
|
1017
|
+
}
|
|
1018
|
+
let graphJson;
|
|
1019
|
+
try {
|
|
1020
|
+
graphJson = JSON.parse(graphRaw);
|
|
1021
|
+
} catch (err) {
|
|
1022
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1023
|
+
throw new ValidationError(`Invalid JSON in graph file ${declaredPath}: ${message}`);
|
|
1024
|
+
}
|
|
1025
|
+
const graphParse = CruiseResultSchema.safeParse(graphJson);
|
|
1026
|
+
if (!graphParse.success) {
|
|
1027
|
+
throw new ValidationError(`Invalid dependency-cruiser graph schema in ${declaredPath}:
|
|
1028
|
+
${graphParse.error.message}`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (key === "metrics") {
|
|
1032
|
+
let metricsRaw;
|
|
1033
|
+
try {
|
|
1034
|
+
metricsRaw = await fsPromises2.readFile(resolvedPath, "utf8");
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1037
|
+
throw new ValidationError(`Failed to read metrics file ${declaredPath}: ${message}`);
|
|
1038
|
+
}
|
|
1039
|
+
let metricsJson;
|
|
1040
|
+
try {
|
|
1041
|
+
metricsJson = JSON.parse(metricsRaw);
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1044
|
+
throw new ValidationError(`Invalid JSON in metrics file ${declaredPath}: ${message}`);
|
|
1045
|
+
}
|
|
1046
|
+
const metricsParse = ComplexityMetricsMapSchema.safeParse(metricsJson);
|
|
1047
|
+
if (!metricsParse.success) {
|
|
1048
|
+
throw new ValidationError(`Invalid complexity metrics schema in ${declaredPath}:
|
|
1049
|
+
${metricsParse.error.message}`);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return {
|
|
1054
|
+
manifest,
|
|
1055
|
+
artifactDir
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// src/cli/commands/validate.ts
|
|
1060
|
+
async function runValidateCommand(args) {
|
|
1061
|
+
let values;
|
|
1062
|
+
let positionals;
|
|
1063
|
+
try {
|
|
1064
|
+
const parsed = parseArgs2({
|
|
1065
|
+
args,
|
|
1066
|
+
allowPositionals: true,
|
|
1067
|
+
options: {
|
|
1068
|
+
cwd: { type: "string" },
|
|
1069
|
+
help: { type: "boolean", short: "h" }
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
values = parsed.values;
|
|
1073
|
+
positionals = parsed.positionals;
|
|
1074
|
+
} catch (e) {
|
|
1075
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1076
|
+
console.error(`Error parsing arguments: ${message}`);
|
|
1077
|
+
return 2;
|
|
1078
|
+
}
|
|
1079
|
+
if (values.help) {
|
|
1080
|
+
console.log(`
|
|
1081
|
+
Usage: maritime validate [directory] [options]
|
|
1082
|
+
|
|
1083
|
+
Arguments:
|
|
1084
|
+
[directory] Artifact directory containing manifest.json (default: ".maritime")
|
|
1085
|
+
|
|
1086
|
+
Options:
|
|
1087
|
+
--cwd <dir> Working directory root for resolution
|
|
1088
|
+
-h, --help Show help message
|
|
1089
|
+
|
|
1090
|
+
Examples:
|
|
1091
|
+
maritime validate
|
|
1092
|
+
maritime validate .maritime
|
|
1093
|
+
maritime validate artifacts/maritime-output
|
|
1094
|
+
|
|
1095
|
+
Exit Codes:
|
|
1096
|
+
0 - Valid artifact directory contract
|
|
1097
|
+
1 - Operational or runtime failure
|
|
1098
|
+
2 - Invalid CLI arguments, missing/malformed manifest, path escaping, or schema mismatch
|
|
1099
|
+
`);
|
|
1100
|
+
return 0;
|
|
1101
|
+
}
|
|
1102
|
+
const artifactDirArg = positionals[0] || ".maritime";
|
|
1103
|
+
const workingDir = values.cwd ? path6.resolve(values.cwd) : process.cwd();
|
|
1104
|
+
try {
|
|
1105
|
+
console.log(`\u{1F50D} Validating Maritime artifact directory: ${artifactDirArg}...`);
|
|
1106
|
+
const result = await validateArtifacts({
|
|
1107
|
+
artifactDir: artifactDirArg,
|
|
1108
|
+
cwd: workingDir
|
|
1109
|
+
});
|
|
1110
|
+
console.log("\u2705 Artifact Directory Contract Validated!");
|
|
1111
|
+
console.log(` - Schema Version: ${result.manifest.schemaVersion}`);
|
|
1112
|
+
console.log(` - Tool Version: ${result.manifest.toolVersion}`);
|
|
1113
|
+
console.log(` - Generated At: ${result.manifest.generatedAt}`);
|
|
1114
|
+
console.log(` - Source Roots: ${result.manifest.sourceRoots.join(", ")}`);
|
|
1115
|
+
console.log(` - Total Files: ${result.manifest.summary.totalFiles}`);
|
|
1116
|
+
console.log(` - Health Score: ${result.manifest.summary.healthScore}`);
|
|
1117
|
+
return 0;
|
|
1118
|
+
} catch (e) {
|
|
1119
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1120
|
+
console.error(`Error validating artifacts: ${message}`);
|
|
1121
|
+
if (e instanceof ValidationError) {
|
|
1122
|
+
return 2;
|
|
1123
|
+
}
|
|
1124
|
+
return 1;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// src/cli/index.ts
|
|
1129
|
+
async function analyzeProject(options) {
|
|
1130
|
+
const args = [];
|
|
1131
|
+
if (options.graph) {
|
|
1132
|
+
args.push("--graph", options.graph);
|
|
1133
|
+
}
|
|
1134
|
+
if (options.metrics) {
|
|
1135
|
+
args.push("--metrics", options.metrics);
|
|
1136
|
+
}
|
|
1137
|
+
if (options.report) {
|
|
1138
|
+
args.push("--report", options.report);
|
|
1139
|
+
}
|
|
1140
|
+
if (options.output) {
|
|
1141
|
+
args.push("--output", options.output);
|
|
1142
|
+
}
|
|
1143
|
+
if (options.depcruiseConfig) {
|
|
1144
|
+
args.push("--depcruise-config", options.depcruiseConfig);
|
|
1145
|
+
}
|
|
1146
|
+
if (options.cwd) {
|
|
1147
|
+
args.push("--cwd", options.cwd);
|
|
1148
|
+
}
|
|
1149
|
+
if (options.failOnUnmeasured) {
|
|
1150
|
+
args.push("--fail-on-unmeasured");
|
|
1151
|
+
}
|
|
1152
|
+
if (options.source) {
|
|
1153
|
+
const sources = Array.isArray(options.source) ? options.source : [options.source];
|
|
1154
|
+
for (const s of sources) {
|
|
1155
|
+
args.push("--source", s);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
return runAnalyzeCommand(args);
|
|
1159
|
+
}
|
|
1160
|
+
async function validateProject(options = {}) {
|
|
1161
|
+
const args = [];
|
|
1162
|
+
if (options.artifactDir) {
|
|
1163
|
+
args.push(options.artifactDir);
|
|
1164
|
+
}
|
|
1165
|
+
if (options.cwd) {
|
|
1166
|
+
args.push("--cwd", options.cwd);
|
|
1167
|
+
}
|
|
1168
|
+
return runValidateCommand(args);
|
|
1169
|
+
}
|
|
1170
|
+
export {
|
|
1171
|
+
ArtifactManifestArtifactsSchema,
|
|
1172
|
+
ArtifactManifestSchema,
|
|
1173
|
+
ArtifactManifestSummarySchema,
|
|
1174
|
+
MANIFEST_SCHEMA_VERSION,
|
|
1175
|
+
ValidationError,
|
|
1176
|
+
analyzeProject,
|
|
1177
|
+
calculateHealthScore,
|
|
1178
|
+
calculateInstability,
|
|
1179
|
+
calculateMetrics,
|
|
1180
|
+
calculateScore,
|
|
1181
|
+
countLinesOfCode,
|
|
1182
|
+
detectEslintConfig,
|
|
1183
|
+
generateDependencyGraph,
|
|
1184
|
+
isSupportedTypeScriptFile,
|
|
1185
|
+
parseEslintComplexityReport,
|
|
1186
|
+
readDependencyGraph,
|
|
1187
|
+
renderMarkdownReport,
|
|
1188
|
+
resolveDepcruiseConfig,
|
|
1189
|
+
runAnalyzeCommand,
|
|
1190
|
+
runEslintComplexityScan,
|
|
1191
|
+
runValidateCommand,
|
|
1192
|
+
validateArtifacts,
|
|
1193
|
+
validateEslintEnvironment,
|
|
1194
|
+
validateNodeVersion,
|
|
1195
|
+
validateProject,
|
|
1196
|
+
writeOutputFiles
|
|
1197
|
+
};
|