@dependency-maritime/cli 0.1.0-beta.1 → 0.1.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/cli/cli/commands/graph.d.ts +1 -0
- package/dist/cli/cli/graph/render-dot.d.ts +6 -0
- package/dist/cli/cli/graph/render-graphviz.d.ts +4 -0
- package/dist/cli/cli/index.d.ts +4 -1
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +216 -2
- package/dist/cli/main.js +219 -7
- package/dist/cli/schema/dependency-cruiser.d.ts +4 -0
- package/package.json +3 -4
package/README.md
CHANGED
|
@@ -127,3 +127,16 @@ This project uses **npm** for package management. Please ensure you lock files a
|
|
|
127
127
|
## 📝 License
|
|
128
128
|
|
|
129
129
|
Distributed under the MIT License.
|
|
130
|
+
|
|
131
|
+
### Render existing graph evidence
|
|
132
|
+
|
|
133
|
+
With Graphviz `dot` installed, render without performing a second analysis:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
maritime graph --input .maritime --output docs/images/dependency-graph.svg
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`.maritime/dependency-graph.json` is canonical evidence; SVG and DOT outputs are derived
|
|
140
|
+
presentations. The composite Action's reproducible committed-SVG contract is limited to its pinned
|
|
141
|
+
Ubuntu Graphviz path; other runners must provide and pin `dot` themselves because layout can vary
|
|
142
|
+
between Graphviz versions.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runGraphCommand(args: string[]): Promise<number>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { MaritimeCruiseResult } from '../../schema/dependency-cruiser';
|
|
2
|
+
declare function externalPackageName(value: string): string | undefined;
|
|
3
|
+
/** Pure, deterministic conversion of a validated dependency-cruiser result to Graphviz DOT. */
|
|
4
|
+
export declare function renderDependencyGraphToDot(graph: MaritimeCruiseResult): string;
|
|
5
|
+
export declare function inferGraphvizFormat(outputPath: string): 'svg' | 'dot';
|
|
6
|
+
export { externalPackageName };
|
package/dist/cli/cli/index.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { runAnalyzeCommand } from './commands/analyze';
|
|
2
2
|
import { runValidateCommand } from './commands/validate';
|
|
3
|
+
import { runGraphCommand } from './commands/graph';
|
|
3
4
|
import { validateArtifacts } from './validate/validate';
|
|
4
|
-
export { runAnalyzeCommand, runValidateCommand, validateArtifacts };
|
|
5
|
+
export { runAnalyzeCommand, runValidateCommand, runGraphCommand, validateArtifacts };
|
|
6
|
+
export { renderDependencyGraphToDot, inferGraphvizFormat } from './graph/render-dot';
|
|
7
|
+
export { renderDotWithGraphviz, normalizeGraphvizSvg } from './graph/render-graphviz';
|
|
5
8
|
export * from '../schema/manifest';
|
|
6
9
|
export { readDependencyGraph, resolveDepcruiseConfig, generateDependencyGraph, runEslintComplexityScan, countLinesOfCode, writeOutputFiles } from './analyze/adapters';
|
|
7
10
|
export { calculateMetrics, isSupportedTypeScriptFile, calculateInstability, calculateScore, calculateHealthScore } from './analyze/calculate-metrics';
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { runAnalyzeCommand } from './commands/analyze';
|
|
2
2
|
import { runValidateCommand } from './commands/validate';
|
|
3
|
+
import { runGraphCommand } from './commands/graph';
|
|
3
4
|
import { validateArtifacts } from './validate/validate';
|
|
4
|
-
export { runAnalyzeCommand, runValidateCommand, validateArtifacts };
|
|
5
|
+
export { runAnalyzeCommand, runValidateCommand, runGraphCommand, validateArtifacts };
|
|
6
|
+
export { renderDependencyGraphToDot, inferGraphvizFormat } from './graph/render-dot';
|
|
7
|
+
export { renderDotWithGraphviz, normalizeGraphvizSvg } from './graph/render-graphviz';
|
|
5
8
|
export * from '../schema/manifest';
|
|
6
9
|
export { readDependencyGraph, resolveDepcruiseConfig, generateDependencyGraph, runEslintComplexityScan, countLinesOfCode, writeOutputFiles } from './analyze/adapters';
|
|
7
10
|
export { calculateMetrics, isSupportedTypeScriptFile, calculateInstability, calculateScore, calculateHealthScore } from './analyze/calculate-metrics';
|
package/dist/cli/index.js
CHANGED
|
@@ -960,8 +960,8 @@ async function validateArtifacts(options = {}) {
|
|
|
960
960
|
const artifactDirRelative = options.artifactDir ?? ".maritime";
|
|
961
961
|
const artifactDir = path5.resolve(workingDir, artifactDirRelative);
|
|
962
962
|
try {
|
|
963
|
-
const
|
|
964
|
-
if (!
|
|
963
|
+
const stat3 = await fsPromises2.stat(artifactDir);
|
|
964
|
+
if (!stat3.isDirectory()) {
|
|
965
965
|
throw new ValidationError(`Artifact path is not a directory: ${artifactDirRelative}`);
|
|
966
966
|
}
|
|
967
967
|
} catch (err) {
|
|
@@ -1125,6 +1125,215 @@ Exit Codes:
|
|
|
1125
1125
|
}
|
|
1126
1126
|
}
|
|
1127
1127
|
|
|
1128
|
+
// src/cli/commands/graph.ts
|
|
1129
|
+
import { parseArgs as parseArgs3 } from "node:util";
|
|
1130
|
+
import * as fs4 from "node:fs/promises";
|
|
1131
|
+
import * as path9 from "node:path";
|
|
1132
|
+
|
|
1133
|
+
// src/cli/graph/render-dot.ts
|
|
1134
|
+
import * as path7 from "node:path";
|
|
1135
|
+
var dotQuote = (value) => JSON.stringify(value);
|
|
1136
|
+
var normalizedPath = (value) => value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
1137
|
+
function externalPackageName(value) {
|
|
1138
|
+
const normalized = normalizedPath(value);
|
|
1139
|
+
const marker = "node_modules/";
|
|
1140
|
+
const markerIndex = normalized.lastIndexOf(marker);
|
|
1141
|
+
const packagePath = markerIndex >= 0 ? normalized.slice(markerIndex + marker.length) : normalized;
|
|
1142
|
+
const parts = packagePath.split("/").filter(Boolean);
|
|
1143
|
+
if (markerIndex < 0 && (packagePath.startsWith(".") || packagePath.startsWith("/") || parts.length === 0)) {
|
|
1144
|
+
return void 0;
|
|
1145
|
+
}
|
|
1146
|
+
return parts[0]?.startsWith("@") && parts[1] ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
1147
|
+
}
|
|
1148
|
+
function isExternalPath(value) {
|
|
1149
|
+
return normalizedPath(value).split("/").includes("node_modules");
|
|
1150
|
+
}
|
|
1151
|
+
function addLocalModule(root, source) {
|
|
1152
|
+
const parts = normalizedPath(source).split("/").filter((part) => part && part !== ".");
|
|
1153
|
+
const name = parts.pop();
|
|
1154
|
+
if (!name) return;
|
|
1155
|
+
let directory = root;
|
|
1156
|
+
for (const part of parts) {
|
|
1157
|
+
let child = directory.directories.get(part);
|
|
1158
|
+
if (!child) {
|
|
1159
|
+
child = { directories: /* @__PURE__ */ new Map(), files: [] };
|
|
1160
|
+
directory.directories.set(part, child);
|
|
1161
|
+
}
|
|
1162
|
+
directory = child;
|
|
1163
|
+
}
|
|
1164
|
+
directory.files.push({ source: normalizedPath(source), name });
|
|
1165
|
+
}
|
|
1166
|
+
function renderDirectory(directory, segments, indent) {
|
|
1167
|
+
const lines = [];
|
|
1168
|
+
for (const [name, child] of [...directory.directories].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1169
|
+
const childSegments = [...segments, name];
|
|
1170
|
+
lines.push(`${indent}subgraph ${dotQuote(`cluster:${childSegments.join("/")}`)} {`);
|
|
1171
|
+
lines.push(`${indent} label=${dotQuote(name)};`);
|
|
1172
|
+
lines.push(`${indent} color="#94a3b8";`);
|
|
1173
|
+
lines.push(...renderDirectory(child, childSegments, `${indent} `));
|
|
1174
|
+
lines.push(`${indent}}`);
|
|
1175
|
+
}
|
|
1176
|
+
for (const file of [...directory.files].sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source))) {
|
|
1177
|
+
lines.push(`${indent}${dotQuote(`local:${file.source}`)} [label=${dotQuote(file.name)}, shape="box"];`);
|
|
1178
|
+
}
|
|
1179
|
+
return lines;
|
|
1180
|
+
}
|
|
1181
|
+
function edgeAttributes(dependency) {
|
|
1182
|
+
const attributes = [];
|
|
1183
|
+
const dependencyTypes = [...dependency.dependencyTypes].sort();
|
|
1184
|
+
if (dependencyTypes.length > 0) attributes.push(`label=${dotQuote(dependencyTypes.join(", "))}`);
|
|
1185
|
+
if (dependency.typeOnly || dependency.preCompilationOnly) attributes.push('style="dashed"');
|
|
1186
|
+
if (dependency.circular) attributes.push('color="#d97706"', 'penwidth="2"');
|
|
1187
|
+
if (!dependency.valid) {
|
|
1188
|
+
if (dependency.circular) attributes.push('xlabel="invalid"', 'fontcolor="#dc2626"');
|
|
1189
|
+
else attributes.push('color="#dc2626"', 'penwidth="2"');
|
|
1190
|
+
}
|
|
1191
|
+
return attributes.length > 0 ? ` [${attributes.join(", ")}]` : "";
|
|
1192
|
+
}
|
|
1193
|
+
function renderDependencyGraphToDot(graph) {
|
|
1194
|
+
const root = { directories: /* @__PURE__ */ new Map(), files: [] };
|
|
1195
|
+
const localSources = /* @__PURE__ */ new Set();
|
|
1196
|
+
const externalPackages = /* @__PURE__ */ new Set();
|
|
1197
|
+
for (const module of graph.modules) {
|
|
1198
|
+
const source = normalizedPath(module.source);
|
|
1199
|
+
if (isExternalPath(source) || module.coreModule) continue;
|
|
1200
|
+
localSources.add(source);
|
|
1201
|
+
addLocalModule(root, source);
|
|
1202
|
+
}
|
|
1203
|
+
const edges = [];
|
|
1204
|
+
for (const module of [...graph.modules].sort((a, b) => a.source.localeCompare(b.source))) {
|
|
1205
|
+
const source = normalizedPath(module.source);
|
|
1206
|
+
if (!localSources.has(source)) continue;
|
|
1207
|
+
for (const dependency of module.dependencies) {
|
|
1208
|
+
const resolved = normalizedPath(dependency.resolved);
|
|
1209
|
+
let target;
|
|
1210
|
+
if (localSources.has(resolved)) {
|
|
1211
|
+
target = `local:${resolved}`;
|
|
1212
|
+
} else if (!dependency.coreModule) {
|
|
1213
|
+
const looksExternal = isExternalPath(resolved) || dependency.dependencyTypes.includes("npm");
|
|
1214
|
+
const packageName = looksExternal ? externalPackageName(isExternalPath(resolved) ? resolved : dependency.module) : void 0;
|
|
1215
|
+
if (packageName) {
|
|
1216
|
+
externalPackages.add(packageName);
|
|
1217
|
+
target = `external:${packageName}`;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (target) edges.push({ from: `local:${source}`, to: target, dependency });
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
edges.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || JSON.stringify(a.dependency).localeCompare(JSON.stringify(b.dependency)));
|
|
1224
|
+
const lines = [
|
|
1225
|
+
'digraph "dependency-graph" {',
|
|
1226
|
+
' graph [compound="true", rankdir="LR", fontname="Helvetica"];',
|
|
1227
|
+
' node [fontname="Helvetica", fontsize="10"];',
|
|
1228
|
+
' edge [fontname="Helvetica", fontsize="8"];',
|
|
1229
|
+
...renderDirectory(root, [], " ")
|
|
1230
|
+
];
|
|
1231
|
+
if (externalPackages.size > 0) {
|
|
1232
|
+
lines.push(' subgraph "cluster:external-packages" {', ' label="External packages";', ' style="dashed";', ' color="#64748b";');
|
|
1233
|
+
for (const packageName of [...externalPackages].sort()) {
|
|
1234
|
+
lines.push(` ${dotQuote(`external:${packageName}`)} [label=${dotQuote(packageName)}, shape="component", style="filled", fillcolor="#e2e8f0"];`);
|
|
1235
|
+
}
|
|
1236
|
+
lines.push(" }");
|
|
1237
|
+
}
|
|
1238
|
+
for (const edge of edges) {
|
|
1239
|
+
lines.push(` ${dotQuote(edge.from)} -> ${dotQuote(edge.to)}${edgeAttributes(edge.dependency)};`);
|
|
1240
|
+
}
|
|
1241
|
+
lines.push("}", "");
|
|
1242
|
+
return lines.join("\n");
|
|
1243
|
+
}
|
|
1244
|
+
function inferGraphvizFormat(outputPath) {
|
|
1245
|
+
const extension = path7.extname(outputPath).toLowerCase();
|
|
1246
|
+
if (extension === ".svg") return "svg";
|
|
1247
|
+
if (extension === ".dot") return "dot";
|
|
1248
|
+
throw new Error(`Unsupported graph output format "${extension || "(none)"}". Use an .svg or .dot output path.`);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// src/cli/graph/render-graphviz.ts
|
|
1252
|
+
import { spawn } from "node:child_process";
|
|
1253
|
+
import * as fs3 from "node:fs/promises";
|
|
1254
|
+
import * as path8 from "node:path";
|
|
1255
|
+
function normalizeGraphvizSvg(svg) {
|
|
1256
|
+
return svg.replace(/<!-- Generated by graphviz version .*? -->/gi, "<!-- Generated by Graphviz -->");
|
|
1257
|
+
}
|
|
1258
|
+
async function renderDotWithGraphviz(dot, outputPath, options = {}) {
|
|
1259
|
+
const executable = options.executable ?? "dot";
|
|
1260
|
+
const svg = await new Promise((resolve7, reject) => {
|
|
1261
|
+
const child = spawn(executable, ["-Tsvg"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
1262
|
+
const stdout = [];
|
|
1263
|
+
const stderr = [];
|
|
1264
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
1265
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
1266
|
+
child.on("error", (error) => {
|
|
1267
|
+
if (error.code === "ENOENT") {
|
|
1268
|
+
reject(new Error(`Graphviz executable "${executable}" was not found. Install Graphviz and ensure "dot" is available on PATH.`));
|
|
1269
|
+
} else {
|
|
1270
|
+
reject(new Error(`Unable to start Graphviz: ${error.message}`));
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
child.on("close", (code) => {
|
|
1274
|
+
if (code === 0) resolve7(Buffer.concat(stdout).toString("utf8"));
|
|
1275
|
+
else reject(new Error(`Graphviz exited with code ${code}: ${Buffer.concat(stderr).toString("utf8").trim()}`));
|
|
1276
|
+
});
|
|
1277
|
+
child.stdin.end(dot);
|
|
1278
|
+
});
|
|
1279
|
+
await fs3.mkdir(path8.dirname(outputPath), { recursive: true });
|
|
1280
|
+
await fs3.writeFile(outputPath, normalizeGraphvizSvg(svg));
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// src/cli/commands/graph.ts
|
|
1284
|
+
async function runGraphCommand(args) {
|
|
1285
|
+
let values;
|
|
1286
|
+
try {
|
|
1287
|
+
values = parseArgs3({ args, options: {
|
|
1288
|
+
input: { type: "string", short: "i" },
|
|
1289
|
+
output: { type: "string", short: "o" },
|
|
1290
|
+
cwd: { type: "string" },
|
|
1291
|
+
help: { type: "boolean", short: "h" }
|
|
1292
|
+
} }).values;
|
|
1293
|
+
} catch (error) {
|
|
1294
|
+
console.error(`Error parsing arguments: ${error instanceof Error ? error.message : String(error)}`);
|
|
1295
|
+
return 2;
|
|
1296
|
+
}
|
|
1297
|
+
if (values.help) {
|
|
1298
|
+
console.log(`
|
|
1299
|
+
Usage: maritime graph --input <artifact-directory-or-graph.json> --output <graph.svg|graph.dot>
|
|
1300
|
+
|
|
1301
|
+
Renders existing canonical graph evidence without running dependency analysis.
|
|
1302
|
+
|
|
1303
|
+
Options:
|
|
1304
|
+
-i, --input <path> .maritime directory or dependency-graph.json (default: .maritime)
|
|
1305
|
+
-o, --output <path> SVG or DOT output path (required)
|
|
1306
|
+
--cwd <dir> Working directory root for resolution
|
|
1307
|
+
-h, --help Show help message
|
|
1308
|
+
`);
|
|
1309
|
+
return 0;
|
|
1310
|
+
}
|
|
1311
|
+
if (!values.output) {
|
|
1312
|
+
console.error("Error: --output is required.");
|
|
1313
|
+
return 2;
|
|
1314
|
+
}
|
|
1315
|
+
const cwd = path9.resolve(values.cwd ?? process.cwd());
|
|
1316
|
+
const input = path9.resolve(cwd, values.input ?? ".maritime");
|
|
1317
|
+
const output = path9.resolve(cwd, values.output);
|
|
1318
|
+
try {
|
|
1319
|
+
const stat3 = await fs4.stat(input);
|
|
1320
|
+
const graphPath = stat3.isDirectory() ? path9.resolve(input, (await validateArtifacts({ artifactDir: input, cwd })).manifest.artifacts.graph) : input;
|
|
1321
|
+
const parsed = JSON.parse(await fs4.readFile(graphPath, "utf8"));
|
|
1322
|
+
const result = CruiseResultSchema.safeParse(parsed);
|
|
1323
|
+
if (!result.success) throw new Error(`Invalid Maritime dependency graph: ${result.error.message}`);
|
|
1324
|
+
const dot = renderDependencyGraphToDot(result.data);
|
|
1325
|
+
const format = inferGraphvizFormat(output);
|
|
1326
|
+
await fs4.mkdir(path9.dirname(output), { recursive: true });
|
|
1327
|
+
if (format === "dot") await fs4.writeFile(output, dot);
|
|
1328
|
+
else await renderDotWithGraphviz(dot, output);
|
|
1329
|
+
console.log(`\u2705 Dependency graph rendered from ${graphPath} to ${output}`);
|
|
1330
|
+
return 0;
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
console.error(`Error rendering dependency graph: ${error instanceof Error ? error.message : String(error)}`);
|
|
1333
|
+
return 2;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1128
1337
|
// src/cli/index.ts
|
|
1129
1338
|
async function analyzeProject(options) {
|
|
1130
1339
|
const args = [];
|
|
@@ -1181,13 +1390,18 @@ export {
|
|
|
1181
1390
|
countLinesOfCode,
|
|
1182
1391
|
detectEslintConfig,
|
|
1183
1392
|
generateDependencyGraph,
|
|
1393
|
+
inferGraphvizFormat,
|
|
1184
1394
|
isSupportedTypeScriptFile,
|
|
1395
|
+
normalizeGraphvizSvg,
|
|
1185
1396
|
parseEslintComplexityReport,
|
|
1186
1397
|
readDependencyGraph,
|
|
1398
|
+
renderDependencyGraphToDot,
|
|
1399
|
+
renderDotWithGraphviz,
|
|
1187
1400
|
renderMarkdownReport,
|
|
1188
1401
|
resolveDepcruiseConfig,
|
|
1189
1402
|
runAnalyzeCommand,
|
|
1190
1403
|
runEslintComplexityScan,
|
|
1404
|
+
runGraphCommand,
|
|
1191
1405
|
runValidateCommand,
|
|
1192
1406
|
validateArtifacts,
|
|
1193
1407
|
validateEslintEnvironment,
|
package/dist/cli/main.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli/main.ts
|
|
4
|
-
import { parseArgs as
|
|
4
|
+
import { parseArgs as parseArgs4 } from "node:util";
|
|
5
5
|
|
|
6
6
|
// src/cli/commands/analyze.ts
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
@@ -965,8 +965,8 @@ async function validateArtifacts(options = {}) {
|
|
|
965
965
|
const artifactDirRelative = options.artifactDir ?? ".maritime";
|
|
966
966
|
const artifactDir = path5.resolve(workingDir, artifactDirRelative);
|
|
967
967
|
try {
|
|
968
|
-
const
|
|
969
|
-
if (!
|
|
968
|
+
const stat3 = await fsPromises2.stat(artifactDir);
|
|
969
|
+
if (!stat3.isDirectory()) {
|
|
970
970
|
throw new ValidationError(`Artifact path is not a directory: ${artifactDirRelative}`);
|
|
971
971
|
}
|
|
972
972
|
} catch (err) {
|
|
@@ -1130,11 +1130,220 @@ Exit Codes:
|
|
|
1130
1130
|
}
|
|
1131
1131
|
}
|
|
1132
1132
|
|
|
1133
|
+
// src/cli/commands/graph.ts
|
|
1134
|
+
import { parseArgs as parseArgs3 } from "node:util";
|
|
1135
|
+
import * as fs4 from "node:fs/promises";
|
|
1136
|
+
import * as path9 from "node:path";
|
|
1137
|
+
|
|
1138
|
+
// src/cli/graph/render-dot.ts
|
|
1139
|
+
import * as path7 from "node:path";
|
|
1140
|
+
var dotQuote = (value) => JSON.stringify(value);
|
|
1141
|
+
var normalizedPath = (value) => value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
1142
|
+
function externalPackageName(value) {
|
|
1143
|
+
const normalized = normalizedPath(value);
|
|
1144
|
+
const marker = "node_modules/";
|
|
1145
|
+
const markerIndex = normalized.lastIndexOf(marker);
|
|
1146
|
+
const packagePath = markerIndex >= 0 ? normalized.slice(markerIndex + marker.length) : normalized;
|
|
1147
|
+
const parts = packagePath.split("/").filter(Boolean);
|
|
1148
|
+
if (markerIndex < 0 && (packagePath.startsWith(".") || packagePath.startsWith("/") || parts.length === 0)) {
|
|
1149
|
+
return void 0;
|
|
1150
|
+
}
|
|
1151
|
+
return parts[0]?.startsWith("@") && parts[1] ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
1152
|
+
}
|
|
1153
|
+
function isExternalPath(value) {
|
|
1154
|
+
return normalizedPath(value).split("/").includes("node_modules");
|
|
1155
|
+
}
|
|
1156
|
+
function addLocalModule(root, source) {
|
|
1157
|
+
const parts = normalizedPath(source).split("/").filter((part) => part && part !== ".");
|
|
1158
|
+
const name = parts.pop();
|
|
1159
|
+
if (!name) return;
|
|
1160
|
+
let directory = root;
|
|
1161
|
+
for (const part of parts) {
|
|
1162
|
+
let child = directory.directories.get(part);
|
|
1163
|
+
if (!child) {
|
|
1164
|
+
child = { directories: /* @__PURE__ */ new Map(), files: [] };
|
|
1165
|
+
directory.directories.set(part, child);
|
|
1166
|
+
}
|
|
1167
|
+
directory = child;
|
|
1168
|
+
}
|
|
1169
|
+
directory.files.push({ source: normalizedPath(source), name });
|
|
1170
|
+
}
|
|
1171
|
+
function renderDirectory(directory, segments, indent) {
|
|
1172
|
+
const lines = [];
|
|
1173
|
+
for (const [name, child] of [...directory.directories].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1174
|
+
const childSegments = [...segments, name];
|
|
1175
|
+
lines.push(`${indent}subgraph ${dotQuote(`cluster:${childSegments.join("/")}`)} {`);
|
|
1176
|
+
lines.push(`${indent} label=${dotQuote(name)};`);
|
|
1177
|
+
lines.push(`${indent} color="#94a3b8";`);
|
|
1178
|
+
lines.push(...renderDirectory(child, childSegments, `${indent} `));
|
|
1179
|
+
lines.push(`${indent}}`);
|
|
1180
|
+
}
|
|
1181
|
+
for (const file of [...directory.files].sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source))) {
|
|
1182
|
+
lines.push(`${indent}${dotQuote(`local:${file.source}`)} [label=${dotQuote(file.name)}, shape="box"];`);
|
|
1183
|
+
}
|
|
1184
|
+
return lines;
|
|
1185
|
+
}
|
|
1186
|
+
function edgeAttributes(dependency) {
|
|
1187
|
+
const attributes = [];
|
|
1188
|
+
const dependencyTypes = [...dependency.dependencyTypes].sort();
|
|
1189
|
+
if (dependencyTypes.length > 0) attributes.push(`label=${dotQuote(dependencyTypes.join(", "))}`);
|
|
1190
|
+
if (dependency.typeOnly || dependency.preCompilationOnly) attributes.push('style="dashed"');
|
|
1191
|
+
if (dependency.circular) attributes.push('color="#d97706"', 'penwidth="2"');
|
|
1192
|
+
if (!dependency.valid) {
|
|
1193
|
+
if (dependency.circular) attributes.push('xlabel="invalid"', 'fontcolor="#dc2626"');
|
|
1194
|
+
else attributes.push('color="#dc2626"', 'penwidth="2"');
|
|
1195
|
+
}
|
|
1196
|
+
return attributes.length > 0 ? ` [${attributes.join(", ")}]` : "";
|
|
1197
|
+
}
|
|
1198
|
+
function renderDependencyGraphToDot(graph) {
|
|
1199
|
+
const root = { directories: /* @__PURE__ */ new Map(), files: [] };
|
|
1200
|
+
const localSources = /* @__PURE__ */ new Set();
|
|
1201
|
+
const externalPackages = /* @__PURE__ */ new Set();
|
|
1202
|
+
for (const module of graph.modules) {
|
|
1203
|
+
const source = normalizedPath(module.source);
|
|
1204
|
+
if (isExternalPath(source) || module.coreModule) continue;
|
|
1205
|
+
localSources.add(source);
|
|
1206
|
+
addLocalModule(root, source);
|
|
1207
|
+
}
|
|
1208
|
+
const edges = [];
|
|
1209
|
+
for (const module of [...graph.modules].sort((a, b) => a.source.localeCompare(b.source))) {
|
|
1210
|
+
const source = normalizedPath(module.source);
|
|
1211
|
+
if (!localSources.has(source)) continue;
|
|
1212
|
+
for (const dependency of module.dependencies) {
|
|
1213
|
+
const resolved = normalizedPath(dependency.resolved);
|
|
1214
|
+
let target;
|
|
1215
|
+
if (localSources.has(resolved)) {
|
|
1216
|
+
target = `local:${resolved}`;
|
|
1217
|
+
} else if (!dependency.coreModule) {
|
|
1218
|
+
const looksExternal = isExternalPath(resolved) || dependency.dependencyTypes.includes("npm");
|
|
1219
|
+
const packageName = looksExternal ? externalPackageName(isExternalPath(resolved) ? resolved : dependency.module) : void 0;
|
|
1220
|
+
if (packageName) {
|
|
1221
|
+
externalPackages.add(packageName);
|
|
1222
|
+
target = `external:${packageName}`;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
if (target) edges.push({ from: `local:${source}`, to: target, dependency });
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
edges.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || JSON.stringify(a.dependency).localeCompare(JSON.stringify(b.dependency)));
|
|
1229
|
+
const lines = [
|
|
1230
|
+
'digraph "dependency-graph" {',
|
|
1231
|
+
' graph [compound="true", rankdir="LR", fontname="Helvetica"];',
|
|
1232
|
+
' node [fontname="Helvetica", fontsize="10"];',
|
|
1233
|
+
' edge [fontname="Helvetica", fontsize="8"];',
|
|
1234
|
+
...renderDirectory(root, [], " ")
|
|
1235
|
+
];
|
|
1236
|
+
if (externalPackages.size > 0) {
|
|
1237
|
+
lines.push(' subgraph "cluster:external-packages" {', ' label="External packages";', ' style="dashed";', ' color="#64748b";');
|
|
1238
|
+
for (const packageName of [...externalPackages].sort()) {
|
|
1239
|
+
lines.push(` ${dotQuote(`external:${packageName}`)} [label=${dotQuote(packageName)}, shape="component", style="filled", fillcolor="#e2e8f0"];`);
|
|
1240
|
+
}
|
|
1241
|
+
lines.push(" }");
|
|
1242
|
+
}
|
|
1243
|
+
for (const edge of edges) {
|
|
1244
|
+
lines.push(` ${dotQuote(edge.from)} -> ${dotQuote(edge.to)}${edgeAttributes(edge.dependency)};`);
|
|
1245
|
+
}
|
|
1246
|
+
lines.push("}", "");
|
|
1247
|
+
return lines.join("\n");
|
|
1248
|
+
}
|
|
1249
|
+
function inferGraphvizFormat(outputPath) {
|
|
1250
|
+
const extension = path7.extname(outputPath).toLowerCase();
|
|
1251
|
+
if (extension === ".svg") return "svg";
|
|
1252
|
+
if (extension === ".dot") return "dot";
|
|
1253
|
+
throw new Error(`Unsupported graph output format "${extension || "(none)"}". Use an .svg or .dot output path.`);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// src/cli/graph/render-graphviz.ts
|
|
1257
|
+
import { spawn } from "node:child_process";
|
|
1258
|
+
import * as fs3 from "node:fs/promises";
|
|
1259
|
+
import * as path8 from "node:path";
|
|
1260
|
+
function normalizeGraphvizSvg(svg) {
|
|
1261
|
+
return svg.replace(/<!-- Generated by graphviz version .*? -->/gi, "<!-- Generated by Graphviz -->");
|
|
1262
|
+
}
|
|
1263
|
+
async function renderDotWithGraphviz(dot, outputPath, options = {}) {
|
|
1264
|
+
const executable = options.executable ?? "dot";
|
|
1265
|
+
const svg = await new Promise((resolve7, reject) => {
|
|
1266
|
+
const child = spawn(executable, ["-Tsvg"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
1267
|
+
const stdout = [];
|
|
1268
|
+
const stderr = [];
|
|
1269
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
1270
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
1271
|
+
child.on("error", (error) => {
|
|
1272
|
+
if (error.code === "ENOENT") {
|
|
1273
|
+
reject(new Error(`Graphviz executable "${executable}" was not found. Install Graphviz and ensure "dot" is available on PATH.`));
|
|
1274
|
+
} else {
|
|
1275
|
+
reject(new Error(`Unable to start Graphviz: ${error.message}`));
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
child.on("close", (code) => {
|
|
1279
|
+
if (code === 0) resolve7(Buffer.concat(stdout).toString("utf8"));
|
|
1280
|
+
else reject(new Error(`Graphviz exited with code ${code}: ${Buffer.concat(stderr).toString("utf8").trim()}`));
|
|
1281
|
+
});
|
|
1282
|
+
child.stdin.end(dot);
|
|
1283
|
+
});
|
|
1284
|
+
await fs3.mkdir(path8.dirname(outputPath), { recursive: true });
|
|
1285
|
+
await fs3.writeFile(outputPath, normalizeGraphvizSvg(svg));
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// src/cli/commands/graph.ts
|
|
1289
|
+
async function runGraphCommand(args) {
|
|
1290
|
+
let values;
|
|
1291
|
+
try {
|
|
1292
|
+
values = parseArgs3({ args, options: {
|
|
1293
|
+
input: { type: "string", short: "i" },
|
|
1294
|
+
output: { type: "string", short: "o" },
|
|
1295
|
+
cwd: { type: "string" },
|
|
1296
|
+
help: { type: "boolean", short: "h" }
|
|
1297
|
+
} }).values;
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
console.error(`Error parsing arguments: ${error instanceof Error ? error.message : String(error)}`);
|
|
1300
|
+
return 2;
|
|
1301
|
+
}
|
|
1302
|
+
if (values.help) {
|
|
1303
|
+
console.log(`
|
|
1304
|
+
Usage: maritime graph --input <artifact-directory-or-graph.json> --output <graph.svg|graph.dot>
|
|
1305
|
+
|
|
1306
|
+
Renders existing canonical graph evidence without running dependency analysis.
|
|
1307
|
+
|
|
1308
|
+
Options:
|
|
1309
|
+
-i, --input <path> .maritime directory or dependency-graph.json (default: .maritime)
|
|
1310
|
+
-o, --output <path> SVG or DOT output path (required)
|
|
1311
|
+
--cwd <dir> Working directory root for resolution
|
|
1312
|
+
-h, --help Show help message
|
|
1313
|
+
`);
|
|
1314
|
+
return 0;
|
|
1315
|
+
}
|
|
1316
|
+
if (!values.output) {
|
|
1317
|
+
console.error("Error: --output is required.");
|
|
1318
|
+
return 2;
|
|
1319
|
+
}
|
|
1320
|
+
const cwd = path9.resolve(values.cwd ?? process.cwd());
|
|
1321
|
+
const input = path9.resolve(cwd, values.input ?? ".maritime");
|
|
1322
|
+
const output = path9.resolve(cwd, values.output);
|
|
1323
|
+
try {
|
|
1324
|
+
const stat3 = await fs4.stat(input);
|
|
1325
|
+
const graphPath = stat3.isDirectory() ? path9.resolve(input, (await validateArtifacts({ artifactDir: input, cwd })).manifest.artifacts.graph) : input;
|
|
1326
|
+
const parsed = JSON.parse(await fs4.readFile(graphPath, "utf8"));
|
|
1327
|
+
const result = CruiseResultSchema.safeParse(parsed);
|
|
1328
|
+
if (!result.success) throw new Error(`Invalid Maritime dependency graph: ${result.error.message}`);
|
|
1329
|
+
const dot = renderDependencyGraphToDot(result.data);
|
|
1330
|
+
const format = inferGraphvizFormat(output);
|
|
1331
|
+
await fs4.mkdir(path9.dirname(output), { recursive: true });
|
|
1332
|
+
if (format === "dot") await fs4.writeFile(output, dot);
|
|
1333
|
+
else await renderDotWithGraphviz(dot, output);
|
|
1334
|
+
console.log(`\u2705 Dependency graph rendered from ${graphPath} to ${output}`);
|
|
1335
|
+
return 0;
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
console.error(`Error rendering dependency graph: ${error instanceof Error ? error.message : String(error)}`);
|
|
1338
|
+
return 2;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1133
1342
|
// src/cli/main.ts
|
|
1134
1343
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1135
|
-
import * as
|
|
1344
|
+
import * as fs5 from "node:fs";
|
|
1136
1345
|
async function main() {
|
|
1137
|
-
const { positionals } =
|
|
1346
|
+
const { positionals } = parseArgs4({
|
|
1138
1347
|
args: process.argv.slice(2),
|
|
1139
1348
|
allowPositionals: true,
|
|
1140
1349
|
strict: false
|
|
@@ -1148,9 +1357,12 @@ async function main() {
|
|
|
1148
1357
|
const args = process.argv.slice(3);
|
|
1149
1358
|
const exitCode = await runValidateCommand(args);
|
|
1150
1359
|
process.exit(exitCode);
|
|
1360
|
+
} else if (command === "graph") {
|
|
1361
|
+
const exitCode = await runGraphCommand(process.argv.slice(3));
|
|
1362
|
+
process.exit(exitCode);
|
|
1151
1363
|
} else {
|
|
1152
1364
|
console.error(`Unknown command: ${command || "(none)"}`);
|
|
1153
|
-
console.error("Available commands: analyze, validate");
|
|
1365
|
+
console.error("Available commands: analyze, validate, graph");
|
|
1154
1366
|
process.exit(1);
|
|
1155
1367
|
}
|
|
1156
1368
|
}
|
|
@@ -1159,7 +1371,7 @@ if (process.argv[1]) {
|
|
|
1159
1371
|
try {
|
|
1160
1372
|
const currentPath = fileURLToPath2(import.meta.url);
|
|
1161
1373
|
const execPath = process.argv[1];
|
|
1162
|
-
isMain = currentPath === execPath || currentPath ===
|
|
1374
|
+
isMain = currentPath === execPath || currentPath === fs5.realpathSync(execPath);
|
|
1163
1375
|
} catch {
|
|
1164
1376
|
isMain = false;
|
|
1165
1377
|
}
|
|
@@ -178,3 +178,7 @@ export declare const CruiseResultSchema: z.ZodObject<{
|
|
|
178
178
|
optionsUsed: z.ZodUnknown;
|
|
179
179
|
}, z.core.$loose>;
|
|
180
180
|
}, z.core.$loose>;
|
|
181
|
+
/** Canonical graph types guaranteed after Maritime schema validation. */
|
|
182
|
+
export type MaritimeDependency = z.infer<typeof DependencySchema>;
|
|
183
|
+
export type MaritimeModule = z.infer<typeof ModuleSchema>;
|
|
184
|
+
export type MaritimeCruiseResult = z.infer<typeof CruiseResultSchema>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dependency-maritime/cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/cli/index.js",
|
|
6
6
|
"types": "./dist/cli/index.d.ts",
|
|
@@ -39,13 +39,12 @@
|
|
|
39
39
|
"lint": "eslint .",
|
|
40
40
|
"depcruise": "depcruise src --config config/.dependency-cruiser.cjs",
|
|
41
41
|
"check:arch": "depcruise src --config config/.dependency-cruiser.cjs",
|
|
42
|
-
"generate:
|
|
43
|
-
"generate:graph": "dot -T svg config/dependency-graph.dot > docs/images/dependency-graph.svg && dot -T png config/dependency-graph.dot > docs/images/dependency-graph.png",
|
|
42
|
+
"generate:graph": "node dist/cli/main.js graph --input .maritime --output docs/images/dependency-graph.svg",
|
|
44
43
|
"preview": "vite preview",
|
|
45
44
|
"test": "vitest",
|
|
46
45
|
"test:unit": "vitest",
|
|
47
46
|
"test:bench": "vitest bench -c vitest.bench.config.ts",
|
|
48
|
-
"test:cli-package": "vitest run tests/cli-pack-smoke.test.ts",
|
|
47
|
+
"test:cli-package": "vitest run tests/cli-pack-smoke.test.ts tests/action-ref-resolution.test.ts",
|
|
49
48
|
"test:e2e": "playwright test",
|
|
50
49
|
"test:screenshots": "playwright test -c config/playwright.screenshots.config.ts"
|
|
51
50
|
},
|