@dependency-maritime/cli 0.1.0-beta.2 → 0.1.0-beta.4
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 +220 -2
- package/dist/cli/main.js +223 -7
- package/dist/cli/schema/dependency-cruiser.d.ts +4 -0
- package/package.json +2 -3
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,219 @@ 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
|
+
// Graphviz 2.42 can fail init_rank when the default cluster-local ranker
|
|
1227
|
+
// encounters a large recursively nested directory hierarchy. newrank asks
|
|
1228
|
+
// dot to compute one global ranking across clusters while preserving the
|
|
1229
|
+
// cluster boxes and deterministic left-to-right presentation.
|
|
1230
|
+
' graph [compound="true", newrank="true", rankdir="LR", fontname="Helvetica"];',
|
|
1231
|
+
' node [fontname="Helvetica", fontsize="10"];',
|
|
1232
|
+
' edge [fontname="Helvetica", fontsize="8"];',
|
|
1233
|
+
...renderDirectory(root, [], " ")
|
|
1234
|
+
];
|
|
1235
|
+
if (externalPackages.size > 0) {
|
|
1236
|
+
lines.push(' subgraph "cluster:external-packages" {', ' label="External packages";', ' style="dashed";', ' color="#64748b";');
|
|
1237
|
+
for (const packageName of [...externalPackages].sort()) {
|
|
1238
|
+
lines.push(` ${dotQuote(`external:${packageName}`)} [label=${dotQuote(packageName)}, shape="component", style="filled", fillcolor="#e2e8f0"];`);
|
|
1239
|
+
}
|
|
1240
|
+
lines.push(" }");
|
|
1241
|
+
}
|
|
1242
|
+
for (const edge of edges) {
|
|
1243
|
+
lines.push(` ${dotQuote(edge.from)} -> ${dotQuote(edge.to)}${edgeAttributes(edge.dependency)};`);
|
|
1244
|
+
}
|
|
1245
|
+
lines.push("}", "");
|
|
1246
|
+
return lines.join("\n");
|
|
1247
|
+
}
|
|
1248
|
+
function inferGraphvizFormat(outputPath) {
|
|
1249
|
+
const extension = path7.extname(outputPath).toLowerCase();
|
|
1250
|
+
if (extension === ".svg") return "svg";
|
|
1251
|
+
if (extension === ".dot") return "dot";
|
|
1252
|
+
throw new Error(`Unsupported graph output format "${extension || "(none)"}". Use an .svg or .dot output path.`);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// src/cli/graph/render-graphviz.ts
|
|
1256
|
+
import { spawn } from "node:child_process";
|
|
1257
|
+
import * as fs3 from "node:fs/promises";
|
|
1258
|
+
import * as path8 from "node:path";
|
|
1259
|
+
function normalizeGraphvizSvg(svg) {
|
|
1260
|
+
return svg.replace(/<!-- Generated by graphviz version .*? -->/gi, "<!-- Generated by Graphviz -->");
|
|
1261
|
+
}
|
|
1262
|
+
async function renderDotWithGraphviz(dot, outputPath, options = {}) {
|
|
1263
|
+
const executable = options.executable ?? "dot";
|
|
1264
|
+
const svg = await new Promise((resolve7, reject) => {
|
|
1265
|
+
const child = spawn(executable, ["-Tsvg"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
1266
|
+
const stdout = [];
|
|
1267
|
+
const stderr = [];
|
|
1268
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
1269
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
1270
|
+
child.on("error", (error) => {
|
|
1271
|
+
if (error.code === "ENOENT") {
|
|
1272
|
+
reject(new Error(`Graphviz executable "${executable}" was not found. Install Graphviz and ensure "dot" is available on PATH.`));
|
|
1273
|
+
} else {
|
|
1274
|
+
reject(new Error(`Unable to start Graphviz: ${error.message}`));
|
|
1275
|
+
}
|
|
1276
|
+
});
|
|
1277
|
+
child.on("close", (code) => {
|
|
1278
|
+
if (code === 0) resolve7(Buffer.concat(stdout).toString("utf8"));
|
|
1279
|
+
else reject(new Error(`Graphviz exited with code ${code}: ${Buffer.concat(stderr).toString("utf8").trim()}`));
|
|
1280
|
+
});
|
|
1281
|
+
child.stdin.end(dot);
|
|
1282
|
+
});
|
|
1283
|
+
await fs3.mkdir(path8.dirname(outputPath), { recursive: true });
|
|
1284
|
+
await fs3.writeFile(outputPath, normalizeGraphvizSvg(svg));
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// src/cli/commands/graph.ts
|
|
1288
|
+
async function runGraphCommand(args) {
|
|
1289
|
+
let values;
|
|
1290
|
+
try {
|
|
1291
|
+
values = parseArgs3({ args, options: {
|
|
1292
|
+
input: { type: "string", short: "i" },
|
|
1293
|
+
output: { type: "string", short: "o" },
|
|
1294
|
+
cwd: { type: "string" },
|
|
1295
|
+
help: { type: "boolean", short: "h" }
|
|
1296
|
+
} }).values;
|
|
1297
|
+
} catch (error) {
|
|
1298
|
+
console.error(`Error parsing arguments: ${error instanceof Error ? error.message : String(error)}`);
|
|
1299
|
+
return 2;
|
|
1300
|
+
}
|
|
1301
|
+
if (values.help) {
|
|
1302
|
+
console.log(`
|
|
1303
|
+
Usage: maritime graph --input <artifact-directory-or-graph.json> --output <graph.svg|graph.dot>
|
|
1304
|
+
|
|
1305
|
+
Renders existing canonical graph evidence without running dependency analysis.
|
|
1306
|
+
|
|
1307
|
+
Options:
|
|
1308
|
+
-i, --input <path> .maritime directory or dependency-graph.json (default: .maritime)
|
|
1309
|
+
-o, --output <path> SVG or DOT output path (required)
|
|
1310
|
+
--cwd <dir> Working directory root for resolution
|
|
1311
|
+
-h, --help Show help message
|
|
1312
|
+
`);
|
|
1313
|
+
return 0;
|
|
1314
|
+
}
|
|
1315
|
+
if (!values.output) {
|
|
1316
|
+
console.error("Error: --output is required.");
|
|
1317
|
+
return 2;
|
|
1318
|
+
}
|
|
1319
|
+
const cwd = path9.resolve(values.cwd ?? process.cwd());
|
|
1320
|
+
const input = path9.resolve(cwd, values.input ?? ".maritime");
|
|
1321
|
+
const output = path9.resolve(cwd, values.output);
|
|
1322
|
+
try {
|
|
1323
|
+
const stat3 = await fs4.stat(input);
|
|
1324
|
+
const graphPath = stat3.isDirectory() ? path9.resolve(input, (await validateArtifacts({ artifactDir: input, cwd })).manifest.artifacts.graph) : input;
|
|
1325
|
+
const parsed = JSON.parse(await fs4.readFile(graphPath, "utf8"));
|
|
1326
|
+
const result = CruiseResultSchema.safeParse(parsed);
|
|
1327
|
+
if (!result.success) throw new Error(`Invalid Maritime dependency graph: ${result.error.message}`);
|
|
1328
|
+
const dot = renderDependencyGraphToDot(result.data);
|
|
1329
|
+
const format = inferGraphvizFormat(output);
|
|
1330
|
+
await fs4.mkdir(path9.dirname(output), { recursive: true });
|
|
1331
|
+
if (format === "dot") await fs4.writeFile(output, dot);
|
|
1332
|
+
else await renderDotWithGraphviz(dot, output);
|
|
1333
|
+
console.log(`\u2705 Dependency graph rendered from ${graphPath} to ${output}`);
|
|
1334
|
+
return 0;
|
|
1335
|
+
} catch (error) {
|
|
1336
|
+
console.error(`Error rendering dependency graph: ${error instanceof Error ? error.message : String(error)}`);
|
|
1337
|
+
return 2;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1128
1341
|
// src/cli/index.ts
|
|
1129
1342
|
async function analyzeProject(options) {
|
|
1130
1343
|
const args = [];
|
|
@@ -1181,13 +1394,18 @@ export {
|
|
|
1181
1394
|
countLinesOfCode,
|
|
1182
1395
|
detectEslintConfig,
|
|
1183
1396
|
generateDependencyGraph,
|
|
1397
|
+
inferGraphvizFormat,
|
|
1184
1398
|
isSupportedTypeScriptFile,
|
|
1399
|
+
normalizeGraphvizSvg,
|
|
1185
1400
|
parseEslintComplexityReport,
|
|
1186
1401
|
readDependencyGraph,
|
|
1402
|
+
renderDependencyGraphToDot,
|
|
1403
|
+
renderDotWithGraphviz,
|
|
1187
1404
|
renderMarkdownReport,
|
|
1188
1405
|
resolveDepcruiseConfig,
|
|
1189
1406
|
runAnalyzeCommand,
|
|
1190
1407
|
runEslintComplexityScan,
|
|
1408
|
+
runGraphCommand,
|
|
1191
1409
|
runValidateCommand,
|
|
1192
1410
|
validateArtifacts,
|
|
1193
1411
|
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,224 @@ 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
|
+
// Graphviz 2.42 can fail init_rank when the default cluster-local ranker
|
|
1232
|
+
// encounters a large recursively nested directory hierarchy. newrank asks
|
|
1233
|
+
// dot to compute one global ranking across clusters while preserving the
|
|
1234
|
+
// cluster boxes and deterministic left-to-right presentation.
|
|
1235
|
+
' graph [compound="true", newrank="true", rankdir="LR", fontname="Helvetica"];',
|
|
1236
|
+
' node [fontname="Helvetica", fontsize="10"];',
|
|
1237
|
+
' edge [fontname="Helvetica", fontsize="8"];',
|
|
1238
|
+
...renderDirectory(root, [], " ")
|
|
1239
|
+
];
|
|
1240
|
+
if (externalPackages.size > 0) {
|
|
1241
|
+
lines.push(' subgraph "cluster:external-packages" {', ' label="External packages";', ' style="dashed";', ' color="#64748b";');
|
|
1242
|
+
for (const packageName of [...externalPackages].sort()) {
|
|
1243
|
+
lines.push(` ${dotQuote(`external:${packageName}`)} [label=${dotQuote(packageName)}, shape="component", style="filled", fillcolor="#e2e8f0"];`);
|
|
1244
|
+
}
|
|
1245
|
+
lines.push(" }");
|
|
1246
|
+
}
|
|
1247
|
+
for (const edge of edges) {
|
|
1248
|
+
lines.push(` ${dotQuote(edge.from)} -> ${dotQuote(edge.to)}${edgeAttributes(edge.dependency)};`);
|
|
1249
|
+
}
|
|
1250
|
+
lines.push("}", "");
|
|
1251
|
+
return lines.join("\n");
|
|
1252
|
+
}
|
|
1253
|
+
function inferGraphvizFormat(outputPath) {
|
|
1254
|
+
const extension = path7.extname(outputPath).toLowerCase();
|
|
1255
|
+
if (extension === ".svg") return "svg";
|
|
1256
|
+
if (extension === ".dot") return "dot";
|
|
1257
|
+
throw new Error(`Unsupported graph output format "${extension || "(none)"}". Use an .svg or .dot output path.`);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// src/cli/graph/render-graphviz.ts
|
|
1261
|
+
import { spawn } from "node:child_process";
|
|
1262
|
+
import * as fs3 from "node:fs/promises";
|
|
1263
|
+
import * as path8 from "node:path";
|
|
1264
|
+
function normalizeGraphvizSvg(svg) {
|
|
1265
|
+
return svg.replace(/<!-- Generated by graphviz version .*? -->/gi, "<!-- Generated by Graphviz -->");
|
|
1266
|
+
}
|
|
1267
|
+
async function renderDotWithGraphviz(dot, outputPath, options = {}) {
|
|
1268
|
+
const executable = options.executable ?? "dot";
|
|
1269
|
+
const svg = await new Promise((resolve7, reject) => {
|
|
1270
|
+
const child = spawn(executable, ["-Tsvg"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
1271
|
+
const stdout = [];
|
|
1272
|
+
const stderr = [];
|
|
1273
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
1274
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
1275
|
+
child.on("error", (error) => {
|
|
1276
|
+
if (error.code === "ENOENT") {
|
|
1277
|
+
reject(new Error(`Graphviz executable "${executable}" was not found. Install Graphviz and ensure "dot" is available on PATH.`));
|
|
1278
|
+
} else {
|
|
1279
|
+
reject(new Error(`Unable to start Graphviz: ${error.message}`));
|
|
1280
|
+
}
|
|
1281
|
+
});
|
|
1282
|
+
child.on("close", (code) => {
|
|
1283
|
+
if (code === 0) resolve7(Buffer.concat(stdout).toString("utf8"));
|
|
1284
|
+
else reject(new Error(`Graphviz exited with code ${code}: ${Buffer.concat(stderr).toString("utf8").trim()}`));
|
|
1285
|
+
});
|
|
1286
|
+
child.stdin.end(dot);
|
|
1287
|
+
});
|
|
1288
|
+
await fs3.mkdir(path8.dirname(outputPath), { recursive: true });
|
|
1289
|
+
await fs3.writeFile(outputPath, normalizeGraphvizSvg(svg));
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// src/cli/commands/graph.ts
|
|
1293
|
+
async function runGraphCommand(args) {
|
|
1294
|
+
let values;
|
|
1295
|
+
try {
|
|
1296
|
+
values = parseArgs3({ args, options: {
|
|
1297
|
+
input: { type: "string", short: "i" },
|
|
1298
|
+
output: { type: "string", short: "o" },
|
|
1299
|
+
cwd: { type: "string" },
|
|
1300
|
+
help: { type: "boolean", short: "h" }
|
|
1301
|
+
} }).values;
|
|
1302
|
+
} catch (error) {
|
|
1303
|
+
console.error(`Error parsing arguments: ${error instanceof Error ? error.message : String(error)}`);
|
|
1304
|
+
return 2;
|
|
1305
|
+
}
|
|
1306
|
+
if (values.help) {
|
|
1307
|
+
console.log(`
|
|
1308
|
+
Usage: maritime graph --input <artifact-directory-or-graph.json> --output <graph.svg|graph.dot>
|
|
1309
|
+
|
|
1310
|
+
Renders existing canonical graph evidence without running dependency analysis.
|
|
1311
|
+
|
|
1312
|
+
Options:
|
|
1313
|
+
-i, --input <path> .maritime directory or dependency-graph.json (default: .maritime)
|
|
1314
|
+
-o, --output <path> SVG or DOT output path (required)
|
|
1315
|
+
--cwd <dir> Working directory root for resolution
|
|
1316
|
+
-h, --help Show help message
|
|
1317
|
+
`);
|
|
1318
|
+
return 0;
|
|
1319
|
+
}
|
|
1320
|
+
if (!values.output) {
|
|
1321
|
+
console.error("Error: --output is required.");
|
|
1322
|
+
return 2;
|
|
1323
|
+
}
|
|
1324
|
+
const cwd = path9.resolve(values.cwd ?? process.cwd());
|
|
1325
|
+
const input = path9.resolve(cwd, values.input ?? ".maritime");
|
|
1326
|
+
const output = path9.resolve(cwd, values.output);
|
|
1327
|
+
try {
|
|
1328
|
+
const stat3 = await fs4.stat(input);
|
|
1329
|
+
const graphPath = stat3.isDirectory() ? path9.resolve(input, (await validateArtifacts({ artifactDir: input, cwd })).manifest.artifacts.graph) : input;
|
|
1330
|
+
const parsed = JSON.parse(await fs4.readFile(graphPath, "utf8"));
|
|
1331
|
+
const result = CruiseResultSchema.safeParse(parsed);
|
|
1332
|
+
if (!result.success) throw new Error(`Invalid Maritime dependency graph: ${result.error.message}`);
|
|
1333
|
+
const dot = renderDependencyGraphToDot(result.data);
|
|
1334
|
+
const format = inferGraphvizFormat(output);
|
|
1335
|
+
await fs4.mkdir(path9.dirname(output), { recursive: true });
|
|
1336
|
+
if (format === "dot") await fs4.writeFile(output, dot);
|
|
1337
|
+
else await renderDotWithGraphviz(dot, output);
|
|
1338
|
+
console.log(`\u2705 Dependency graph rendered from ${graphPath} to ${output}`);
|
|
1339
|
+
return 0;
|
|
1340
|
+
} catch (error) {
|
|
1341
|
+
console.error(`Error rendering dependency graph: ${error instanceof Error ? error.message : String(error)}`);
|
|
1342
|
+
return 2;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1133
1346
|
// src/cli/main.ts
|
|
1134
1347
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1135
|
-
import * as
|
|
1348
|
+
import * as fs5 from "node:fs";
|
|
1136
1349
|
async function main() {
|
|
1137
|
-
const { positionals } =
|
|
1350
|
+
const { positionals } = parseArgs4({
|
|
1138
1351
|
args: process.argv.slice(2),
|
|
1139
1352
|
allowPositionals: true,
|
|
1140
1353
|
strict: false
|
|
@@ -1148,9 +1361,12 @@ async function main() {
|
|
|
1148
1361
|
const args = process.argv.slice(3);
|
|
1149
1362
|
const exitCode = await runValidateCommand(args);
|
|
1150
1363
|
process.exit(exitCode);
|
|
1364
|
+
} else if (command === "graph") {
|
|
1365
|
+
const exitCode = await runGraphCommand(process.argv.slice(3));
|
|
1366
|
+
process.exit(exitCode);
|
|
1151
1367
|
} else {
|
|
1152
1368
|
console.error(`Unknown command: ${command || "(none)"}`);
|
|
1153
|
-
console.error("Available commands: analyze, validate");
|
|
1369
|
+
console.error("Available commands: analyze, validate, graph");
|
|
1154
1370
|
process.exit(1);
|
|
1155
1371
|
}
|
|
1156
1372
|
}
|
|
@@ -1159,7 +1375,7 @@ if (process.argv[1]) {
|
|
|
1159
1375
|
try {
|
|
1160
1376
|
const currentPath = fileURLToPath2(import.meta.url);
|
|
1161
1377
|
const execPath = process.argv[1];
|
|
1162
|
-
isMain = currentPath === execPath || currentPath ===
|
|
1378
|
+
isMain = currentPath === execPath || currentPath === fs5.realpathSync(execPath);
|
|
1163
1379
|
} catch {
|
|
1164
1380
|
isMain = false;
|
|
1165
1381
|
}
|
|
@@ -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.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/cli/index.js",
|
|
6
6
|
"types": "./dist/cli/index.d.ts",
|
|
@@ -39,8 +39,7 @@
|
|
|
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",
|