@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 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 };
@@ -0,0 +1,4 @@
1
+ export declare function normalizeGraphvizSvg(svg: string): string;
2
+ export declare function renderDotWithGraphviz(dot: string, outputPath: string, options?: {
3
+ executable?: string;
4
+ }): Promise<void>;
@@ -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';
@@ -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 stat2 = await fsPromises2.stat(artifactDir);
964
- if (!stat2.isDirectory()) {
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 parseArgs3 } from "node:util";
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 stat2 = await fsPromises2.stat(artifactDir);
969
- if (!stat2.isDirectory()) {
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 fs3 from "node:fs";
1348
+ import * as fs5 from "node:fs";
1136
1349
  async function main() {
1137
- const { positionals } = parseArgs3({
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 === fs3.realpathSync(execPath);
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.2",
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:dot": "depcruise-fmt -T dot .maritime/dependency-graph.json > config/dependency-graph.dot",
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",