c4-architect-mcp 0.2.0-alpha.1 → 0.2.0-alpha.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  User-visible changes are recorded here. Unreleased work is not a published package or release.
4
4
 
5
+ ## 0.2.0-alpha.2 — 2026-11-06
6
+
7
+ ### Added
8
+
9
+ - Render a focused neighborhood from a saved graph through `render_architecture_graph` or CLI `render --node`. Choose a direction and set depth, node, and relationship limits. Focused artifacts show the view's scope and include relationship source locations.
10
+
11
+ ### Fixed
12
+
13
+ - Recognize TypeScript `export type *` re-exports as type-only dependencies.
14
+ - Parse UTF-8, UTF-16LE, and UTF-16BE sources that start with a byte order mark and preserve the hash of the original bytes.
15
+ - Report existing local assets reached through relative imports, TypeScript `paths` aliases, or `baseUrl` as unsupported local modules.
16
+ - Compare snapshots across checkout directories and reconcile equivalent import spellings by resolved endpoints, kind, label, and protocol while preserving relationship multiplicity.
17
+
5
18
  ## 0.2.0-alpha.1 — 2026-11-06
6
19
 
7
20
  ### Added
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  C4 Architect is a local [Model Context Protocol](https://modelcontextprotocol.io/) server that turns source imports into a versioned dependency graph, lets you review and name its architectural boundaries, and produces compact C4 diagrams. Use it from Codex, Claude Code, Claude Desktop, or another client that supports local MCP servers over `stdio`.
8
8
 
9
- **0.2.0-alpha.1 · TypeScript / TSX · Local parsing · MIT licensed**
9
+ **0.2.0-alpha.2 · TypeScript / TSX · Local parsing · MIT licensed**
10
10
 
11
11
  [Get started](#get-started) · [npm package](https://www.npmjs.com/package/c4-architect-mcp) · [See the diagrams](#read-the-diagram) · [Contribute](https://github.com/zrack/c4-architect-mcp/blob/main/CONTRIBUTING.md) · [Roadmap](https://github.com/zrack/c4-architect-mcp/blob/main/ROADMAP.md)
12
12
 
@@ -36,10 +36,10 @@ Draw.io, Structurizr DSL, wiki publishing, cross-repository mapping, additional
36
36
  Use Node.js 22 or 24 and npm. Run the pinned alpha without a checkout:
37
37
 
38
38
  ```sh
39
- npx -y c4-architect-mcp@0.2.0-alpha.1 --help
39
+ npx -y c4-architect-mcp@0.2.0-alpha.2 --help
40
40
  ```
41
41
 
42
- The examples pin `0.2.0-alpha.1` so a later release does not silently change your MCP setup. Alpha releases are an opt-in prerelease channel: `c4-architect-mcp@alpha` selects the version currently assigned to that tag and can advance over time. Use the explicit version for a repeatable setup.
42
+ The examples pin `0.2.0-alpha.2` for a repeatable setup. `c4-architect-mcp@alpha` follows the prerelease channel and currently selects `0.2.0-alpha.2`. npm's `latest` tag remains on `0.2.0-alpha.1`, the initial prerelease. There is no stable release yet.
43
43
 
44
44
  The server communicates over `stdio`; starting it without a subcommand waits for an MCP client rather than opening a browser page or an HTTP port. The following configurations let your client start it through `npx`.
45
45
 
@@ -51,13 +51,13 @@ Add this to `~/.codex/config.toml`, or use the MCP server settings in the Codex
51
51
  ```toml
52
52
  [mcp_servers.c4-architect]
53
53
  command = "npx"
54
- args = ["-y", "c4-architect-mcp@0.2.0-alpha.1"]
54
+ args = ["-y", "c4-architect-mcp@0.2.0-alpha.2"]
55
55
  ```
56
56
 
57
57
  You can also register it from the Codex CLI:
58
58
 
59
59
  ```sh
60
- codex mcp add c4-architect -- npx -y c4-architect-mcp@0.2.0-alpha.1
60
+ codex mcp add c4-architect -- npx -y c4-architect-mcp@0.2.0-alpha.2
61
61
  ```
62
62
 
63
63
  Reconnect the server after configuration changes. See the [official Codex MCP setup guide](https://learn.chatgpt.com/docs/extend/mcp).
@@ -68,7 +68,7 @@ Reconnect the server after configuration changes. See the [official Codex MCP se
68
68
  <summary><strong>Claude Code</strong></summary>
69
69
 
70
70
  ```sh
71
- claude mcp add --transport stdio c4-architect -- npx -y c4-architect-mcp@0.2.0-alpha.1
71
+ claude mcp add --transport stdio c4-architect -- npx -y c4-architect-mcp@0.2.0-alpha.2
72
72
  ```
73
73
 
74
74
  Use `/mcp` to inspect the connection. See the [official Claude Code MCP documentation](https://code.claude.com/docs/en/mcp).
@@ -85,7 +85,7 @@ Open **Settings → Developer → Edit Config** and merge this entry with your e
85
85
  "mcpServers": {
86
86
  "c4-architect": {
87
87
  "command": "npx",
88
- "args": ["-y", "c4-architect-mcp@0.2.0-alpha.1"]
88
+ "args": ["-y", "c4-architect-mcp@0.2.0-alpha.2"]
89
89
  }
90
90
  }
91
91
  }
@@ -95,12 +95,12 @@ Completely restart Claude Desktop after saving. For Windows JSON paths, use forw
95
95
 
96
96
  </details>
97
97
 
98
- These are local `stdio` configurations. Verify the connection in your chosen client. If a desktop app cannot find `npx`, use the executable's absolute path. On Windows, a client that cannot launch `npx` directly can use `command: "cmd"` with `args: ["/c", "npx", "-y", "c4-architect-mcp@0.2.0-alpha.1"]`.
98
+ These are local `stdio` configurations. Verify the connection in your chosen client. If a desktop app cannot find `npx`, use the executable's absolute path. On Windows, a client that cannot launch `npx` directly can use `command: "cmd"` with `args: ["/c", "npx", "-y", "c4-architect-mcp@0.2.0-alpha.2"]`.
99
99
 
100
100
  ### Optional global installation
101
101
 
102
102
  ```sh
103
- npm install -g c4-architect-mcp@0.2.0-alpha.1
103
+ npm install -g c4-architect-mcp@0.2.0-alpha.2
104
104
  c4-architect-mcp --help
105
105
  ```
106
106
 
@@ -111,9 +111,9 @@ With a global installation, use `c4-architect-mcp` as the MCP command with no ar
111
111
  You can analyze and render without an MCP client:
112
112
 
113
113
  ```sh
114
- npx -y c4-architect-mcp@0.2.0-alpha.1 analyze /absolute/path/to/project/src --level Component --out /absolute/path/to/project/architecture.graph.json --summary
115
- npx -y c4-architect-mcp@0.2.0-alpha.1 summary /absolute/path/to/project/architecture.graph.json
116
- npx -y c4-architect-mcp@0.2.0-alpha.1 render /absolute/path/to/project/architecture.graph.json --out /absolute/path/to/project/ARCHITECTURE.md
114
+ npx -y c4-architect-mcp@0.2.0-alpha.2 analyze /absolute/path/to/project/src --level Component --out /absolute/path/to/project/architecture.graph.json --summary
115
+ npx -y c4-architect-mcp@0.2.0-alpha.2 summary /absolute/path/to/project/architecture.graph.json
116
+ npx -y c4-architect-mcp@0.2.0-alpha.2 render /absolute/path/to/project/architecture.graph.json --out /absolute/path/to/project/ARCHITECTURE.md
117
117
  ```
118
118
 
119
119
  Replace `/absolute/path/to/project` with your project's location. Open the generated `ARCHITECTURE.html` beside the Markdown to explore the diagram.
@@ -158,6 +158,8 @@ Use absolute paths: an MCP client's working directory may differ from the reposi
158
158
 
159
159
  The current parser covers `.ts` and `.tsx`, excluding declaration files, dependency/build/output directories, and symlink trees. It uses one nearest or explicitly supplied `tsconfig.json` per scan; nested project configurations are not orchestrated automatically. Static imports, re-exports, and literal `import()` / `require()` dependencies are supported; computed dependencies and parse problems remain visible through diagnostics.
160
160
 
161
+ Existing CSS, SVG, and other local assets found through relative imports, `paths` aliases, or `baseUrl` produce an `UNSUPPORTED_MODULE_TYPE` diagnostic. The graph contains the TypeScript/TSX dependencies. An unresolved specifier that matches a configured alias produces an `UNRESOLVED_IMPORT` diagnostic.
162
+
161
163
  To name architectural boundaries, supply `configPath` explicitly. The [example configuration](https://github.com/zrack/c4-architect-mcp/blob/main/examples/c4-architect.config.json) demonstrates the format:
162
164
 
163
165
  ```json
@@ -197,7 +199,7 @@ To name architectural boundaries, supply `configPath` explicitly. The [example c
197
199
  | `get_architecture_summary` | `graphPath` | A compact overview of a saved graph and its diagnostics. |
198
200
  | `query_architecture_graph` | `graphPath`; optional `nodeId`, `search`, `direction`, `maxDepth`, `maxNodes`, `maxEdges`, `maxBytes`, `includeEvidence` | A bounded neighborhood or search, with truncation reported. |
199
201
  | `compare_architecture_graphs` | `beforePath`, `afterPath`; optional `limit`, `maxBytes` | Change counts and bounded details for snapshots at the same C4 level. |
200
- | `render_architecture_graph` | `graphPath`; optional `title`, `outputPath` | Render a saved, validated graph without passing the graph through the model. |
202
+ | `render_architecture_graph` | `graphPath`; optional `title`, `outputPath`, `focus` | Render a saved graph or a focused neighborhood. |
201
203
  | `render_c4_mermaid` | `title`, `c4Level`, `nodes`, `relationships`, optional `outputPath` | Mermaid source and a compact server-rendered PNG; saved SVG, PNG, HTML, and Markdown when requested. |
202
204
 
203
205
  Queries default to `direction: "both"`, `maxDepth: 1`, `maxNodes: 25`, `maxEdges: 50`, and `maxBytes: 16000`. Source evidence is opt-in. Start with a summary, then search for a component or request its incoming or outgoing dependencies. Smaller views are usually easier to review than a complete repository drawing.
@@ -224,6 +226,35 @@ const relationships = analysis.edges.map(edge => ({
224
226
 
225
227
  Analysis levels are `Context`, `Container`, and `Component`; renderer levels are `C4Context`, `C4Container`, and `C4Component`. Do not invent a protocol when the analysis has no evidence for it.
226
228
 
229
+ ### Focus a diagram on one node
230
+
231
+ Use `query --search` to find the exact node ID for a focused diagram. These commands use the graph from the checkout example and show the outgoing dependencies of Order orchestration.
232
+
233
+ ```sh
234
+ npx -y c4-architect-mcp@0.2.0-alpha.2 query output/order-service.graph.json --search "Order orchestration" --depth 0
235
+ npx -y c4-architect-mcp@0.2.0-alpha.2 render output/order-service.graph.json --node component:orders --direction outgoing --out output/ORDER-SERVICE.outgoing.md
236
+ ```
237
+
238
+ With `--node`, render defaults to both directions, depth 1, 25 nodes, and 50 relationships. Use `--direction incoming` or `--direction outgoing` to trace one direction. Set `--depth`, `--nodes`, and `--edges` to adjust the view. The diagram caption shows how much of the saved graph is included. The render result reports omitted nodes and relationships and whether a node or relationship limit was reached.
239
+
240
+ The focused Markdown relationship table and interactive HTML details show up to three source locations per relationship. Use the saved graph for the full evidence.
241
+
242
+ For MCP, pass `focus` to `render_architecture_graph`. Find the ID with `query_architecture_graph` using `search`.
243
+
244
+ ```json
245
+ {
246
+ "graphPath": "/absolute/path/to/project/architecture.graph.json",
247
+ "outputPath": "/absolute/path/to/project/ARCHITECTURE.orders.md",
248
+ "focus": {
249
+ "nodeId": "component:orders",
250
+ "direction": "outgoing",
251
+ "maxDepth": 1,
252
+ "maxNodes": 25,
253
+ "maxEdges": 50
254
+ }
255
+ }
256
+ ```
257
+
227
258
  ## Read the diagram
228
259
 
229
260
  ![Five-component order-service diagram derived from the checked-in TypeScript example](https://raw.githubusercontent.com/zrack/c4-architect-mcp/main/docs/images/component-example.png)
package/dist/cli.js CHANGED
@@ -2,14 +2,15 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { analyzeDirectory } from "./parsers/astParser.js";
4
4
  import { compareArchitectureGraphs, queryArchitectureGraph, readArchitectureGraph, summarizeArchitectureGraph, writeArchitectureGraph } from "./model/graph.js";
5
- import { graphToDiagram, renderArtifacts } from "./renderers/artifacts.js";
5
+ import { graphFocusSchema } from "./model/neighborhood.js";
6
+ import { renderArchitectureGraph } from "./renderers/artifacts.js";
6
7
  import { version } from "./version.js";
7
8
  const help = `C4-Architect ${version}
8
9
 
9
10
  No arguments: start the MCP server over stdio.
10
11
 
11
12
  analyze <directory> --level Component --out graph.json [--config config.json] [--tsconfig tsconfig.json] [--summary]
12
- render <graph.json> --out ARCHITECTURE.md [--title "Architecture"]
13
+ render <graph.json> --out ARCHITECTURE.md [--title "Architecture"] [--node ID [--direction both] [--depth 1] [--nodes 25] [--edges 50]]
13
14
  summary <graph.json>
14
15
  query <graph.json> [--node ID | --search TEXT] [--direction both] [--depth 1] [--nodes 25] [--edges 50] [--bytes 16000] [--evidence]
15
16
  diff <before.json> <after.json> [--limit 25] [--bytes 16000]
@@ -32,7 +33,7 @@ export async function runCli(args) {
32
33
  }
33
34
  const [command, ...rest] = args;
34
35
  const specs = {
35
- analyze: ["level", "out", "config", "tsconfig", "summary"], render: ["out", "title"], summary: [],
36
+ analyze: ["level", "out", "config", "tsconfig", "summary"], render: ["out", "title", "node", "direction", "depth", "nodes", "edges"], summary: [],
36
37
  query: ["node", "search", "direction", "depth", "nodes", "edges", "bytes", "evidence"], diff: ["limit", "bytes"],
37
38
  };
38
39
  if (!Object.hasOwn(specs, command))
@@ -76,7 +77,13 @@ export async function runCli(args) {
76
77
  const out = str("out");
77
78
  if (!out)
78
79
  throw new Error("render requires --out ARCHITECTURE.md");
79
- const result = await renderArtifacts(graphToDiagram(await readArchitectureGraph(positional[0]), str("title")), out);
80
+ const nodeId = str("node");
81
+ if (!nodeId && ["direction", "depth", "nodes", "edges"].some(flag => flags.has(flag)))
82
+ throw new Error("Render scope options require --node ID");
83
+ const focus = nodeId ? graphFocusSchema.parse({
84
+ nodeId, direction: str("direction"), maxDepth: num("depth"), maxNodes: num("nodes"), maxEdges: num("edges"),
85
+ }) : undefined;
86
+ const result = await renderArchitectureGraph(await readArchitectureGraph(positional[0]), { title: str("title"), outputPath: out, focus });
80
87
  json(result.structuredContent);
81
88
  }
82
89
  else if (command === "summary")
package/dist/index.js CHANGED
@@ -6,7 +6,8 @@ import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { analyzeDirectory } from "./parsers/astParser.js";
8
8
  import { compareArchitectureGraphs, queryArchitectureGraph, readArchitectureGraph, summarizeArchitectureGraph, writeArchitectureGraph } from "./model/graph.js";
9
- import { graphToDiagram, renderArtifacts } from "./renderers/artifacts.js";
9
+ import { graphFocusSchema } from "./model/neighborhood.js";
10
+ import { renderArchitectureGraph, renderArtifacts } from "./renderers/artifacts.js";
10
11
  import { runCli } from "./cli.js";
11
12
  import { version } from "./version.js";
12
13
  const server = new McpServer({ name: "c4-architect-mcp", version });
@@ -35,7 +36,8 @@ server.tool("query_architecture_graph", "Read a bounded neighborhood or search r
35
36
  includeEvidence: z.boolean().default(false), maxBytes: z.number().int().min(2048).max(64000).default(16000),
36
37
  }, async ({ graphPath, ...options }) => textResult(queryArchitectureGraph(await readArchitectureGraph(graphPath), options)));
37
38
  server.tool("compare_architecture_graphs", "Compare two saved graphs at the same abstraction level. Reports full change counts with bounded detail; timestamps and scan-root location do not create changes.", { beforePath: z.string(), afterPath: z.string(), limit: z.number().int().min(1).max(100).default(25), maxBytes: z.number().int().min(2048).max(64000).default(16000) }, async ({ beforePath, afterPath, ...options }) => textResult(compareArchitectureGraphs(await readArchitectureGraph(beforePath), await readArchitectureGraph(afterPath), options)));
38
- server.tool("render_architecture_graph", "Render a saved validated graph directly to a compact C4 image, Mermaid source, and optional Markdown/SVG/PNG/interactive HTML files. Inferred boundaries are labeled.", { graphPath: z.string(), title: z.string().optional(), outputPath: z.string().optional() }, async ({ graphPath, title, outputPath }) => renderArtifacts(graphToDiagram(await readArchitectureGraph(graphPath), title), outputPath));
39
+ server.tool("render_architecture_graph", "Render a saved graph or a focused node neighborhood as a compact C4 image, Mermaid source, and optional Markdown/SVG/PNG/interactive HTML files.", { graphPath: z.string(), title: z.string().optional(), outputPath: z.string().optional(),
40
+ focus: graphFocusSchema.optional().describe("Render only a node and its neighbors. Use query_architecture_graph to find the node ID.") }, async ({ graphPath, ...options }) => renderArchitectureGraph(await readArchitectureGraph(graphPath), options));
39
41
  server.tool("render_c4_mermaid", "Renders a compact C4 PNG using server-side orthogonal layout, plus Mermaid source. With outputPath saves Markdown, SVG, PNG and interactive HTML with connection highlighting and full-diagram PNG/SVG exports.", {
40
42
  title: z.string(), c4Level: z.enum(["C4Context", "C4Container", "C4Component"]),
41
43
  nodes: z.array(z.object({ id: z.string(), label: z.string(), type: z.enum(["Person", "System", "System_Ext", "ExternalSystem", "Container", "ContainerDb", "Component"]), technology: z.string().optional(), description: z.string().optional() })),
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
+ import path from "node:path";
3
4
  import { z } from "zod";
4
5
  export const GRAPH_SCHEMA_VERSION = "1.0";
5
6
  const nonempty = z.string().min(1);
@@ -267,10 +268,41 @@ export function queryArchitectureGraph(input, options = {}) {
267
268
  export const queryGraph = queryArchitectureGraph;
268
269
  const canonical = (value) => JSON.stringify(value, (_key, item) => item && typeof item === "object" && !Array.isArray(item)
269
270
  ? Object.fromEntries(Object.entries(item).filter(([, value]) => value !== undefined).sort(([a], [b]) => a.localeCompare(b))) : item);
270
- const nodeSemantics = (node) => ({ id: node.id, label: node.label, type: node.type, technology: node.technology,
271
+ function labelForComparison(element, scanDirectory) {
272
+ if (element.type !== "System" || element.classification.basis !== "inferred"
273
+ || element.classification.rule !== "default-scan-scope-boundary")
274
+ return element.label;
275
+ const localPath = path.win32.isAbsolute(scanDirectory) && !path.posix.isAbsolute(scanDirectory) ? path.win32 : path.posix;
276
+ const generatedLabel = localPath.basename(scanDirectory) || "Scanned system";
277
+ return element.label === generatedLabel ? undefined : element.label;
278
+ }
279
+ const nodeSemantics = (node, scanDirectory) => ({ id: node.id, label: labelForComparison(node, scanDirectory), type: node.type, technology: node.technology,
271
280
  path: node.path, exports: [...(node.exports ?? [])].sort(), parentId: node.parentId, description: node.description, classification: node.classification });
272
- const edgeSemantics = (edge) => ({ id: edge.id, source: edge.source, target: edge.target,
281
+ const edgeSemantics = (edge) => ({ source: edge.source, target: edge.target,
273
282
  label: edge.label, protocol: edge.protocol, kind: edge.kind });
283
+ function reconcileUnmatchedEdges(before, after) {
284
+ const beforeIds = new Set(before.map(edge => edge.id));
285
+ const afterIds = new Set(after.map(edge => edge.id));
286
+ const unmatchedByDependency = new Map();
287
+ for (const edge of before) {
288
+ if (afterIds.has(edge.id))
289
+ continue;
290
+ const key = JSON.stringify(edgeSemantics(edge));
291
+ const ids = unmatchedByDependency.get(key) ?? [];
292
+ ids.push(edge.id);
293
+ unmatchedByDependency.set(key, ids);
294
+ }
295
+ for (const ids of unmatchedByDependency.values())
296
+ ids.sort().reverse();
297
+ // Existing IDs take precedence. Pair leftover equivalent dependencies once
298
+ // each so a specifier spelling change does not hide a parallel edge change.
299
+ return [...after].sort((left, right) => left.id.localeCompare(right.id)).map(edge => {
300
+ if (beforeIds.has(edge.id))
301
+ return edge;
302
+ const previousId = unmatchedByDependency.get(JSON.stringify(edgeSemantics(edge)))?.pop();
303
+ return previousId === undefined ? edge : { ...edge, id: previousId };
304
+ });
305
+ }
274
306
  export function compareArchitectureGraphs(beforeInput, afterInput, options = {}) {
275
307
  const before = validateArchitectureGraph(beforeInput), after = validateArchitectureGraph(afterInput);
276
308
  if (before.metadata.abstractionLevel !== after.metadata.abstractionLevel)
@@ -280,15 +312,16 @@ export function compareArchitectureGraphs(beforeInput, afterInput, options = {})
280
312
  const previous = new Map(left.map(item => [key(item), item])), next = new Map(right.map(item => [key(item), item]));
281
313
  const added = [...next.keys()].filter(id => !previous.has(id)).sort();
282
314
  const removed = [...previous.keys()].filter(id => !next.has(id)).sort();
283
- const changed = [...next.keys()].filter(id => previous.has(id) && canonical(value(previous.get(id))) !== canonical(value(next.get(id)))).sort();
315
+ const changed = [...next.keys()].filter(id => previous.has(id)
316
+ && canonical(value(previous.get(id), before.metadata.scannedDirectory)) !== canonical(value(next.get(id), after.metadata.scannedDirectory))).sort();
284
317
  return { counts: { added: added.length, removed: removed.length, changed: changed.length },
285
318
  added: added.slice(0, limit), removed: removed.slice(0, limit), changed: changed.slice(0, limit) };
286
319
  };
287
320
  const result = {
288
321
  graphVersion: before.schemaVersion, abstractionLevel: before.metadata.abstractionLevel,
289
322
  nodes: compare(before.nodes, after.nodes, node => node.id, nodeSemantics),
290
- edges: compare(before.edges, after.edges, edge => edge.id, edgeSemantics),
291
- hierarchy: compare(before.hierarchy, after.hierarchy, boundary => boundary.id, boundary => boundary),
323
+ edges: compare(before.edges, reconcileUnmatchedEdges(before.edges, after.edges), edge => edge.id, edgeSemantics),
324
+ hierarchy: compare(before.hierarchy, after.hierarchy, boundary => boundary.id, (boundary, scanDirectory) => ({ ...boundary, label: labelForComparison(boundary, scanDirectory) })),
292
325
  sources: compare(before.sources, after.sources, source => source.path, source => source),
293
326
  truncated: false,
294
327
  };
@@ -0,0 +1,64 @@
1
+ import { z } from "zod";
2
+ export const graphFocusSchema = z.object({
3
+ nodeId: z.string().min(1),
4
+ direction: z.enum(["incoming", "outgoing", "both"]).default("both"),
5
+ maxDepth: z.number().int().min(0).max(5).default(1),
6
+ maxNodes: z.number().int().min(1).max(100).default(25),
7
+ maxEdges: z.number().int().min(0).max(200).default(50),
8
+ });
9
+ export function selectGraphNeighborhood(graph, input) {
10
+ const options = graphFocusSchema.parse(input);
11
+ const center = graph.nodes.find(node => node.id === options.nodeId);
12
+ if (!center)
13
+ throw new Error(`Unknown node ID: ${options.nodeId}`);
14
+ const adjacent = new Map();
15
+ function connect(id, edge, neighbor) {
16
+ const entries = adjacent.get(id) ?? [];
17
+ entries.push({ edge, neighbor });
18
+ adjacent.set(id, entries);
19
+ }
20
+ for (const edge of graph.edges) {
21
+ if (options.direction !== "incoming")
22
+ connect(edge.source, edge, edge.target);
23
+ if (options.direction !== "outgoing")
24
+ connect(edge.target, edge, edge.source);
25
+ }
26
+ const selectedNodes = new Set([center.id]);
27
+ const selectedEdges = new Set();
28
+ const queue = [{ id: center.id, depth: 0 }];
29
+ const truncated = { nodes: false, edges: false };
30
+ for (let cursor = 0; cursor < queue.length; cursor++) {
31
+ const { id, depth } = queue[cursor];
32
+ if (depth >= options.maxDepth)
33
+ continue;
34
+ for (const { edge, neighbor } of adjacent.get(id) ?? []) {
35
+ if (selectedEdges.has(edge.id))
36
+ continue;
37
+ const isNewNode = !selectedNodes.has(neighbor);
38
+ if (isNewNode && selectedNodes.size >= options.maxNodes) {
39
+ truncated.nodes = true;
40
+ continue;
41
+ }
42
+ if (selectedEdges.size >= options.maxEdges) {
43
+ truncated.edges = true;
44
+ continue;
45
+ }
46
+ selectedEdges.add(edge.id);
47
+ if (isNewNode) {
48
+ selectedNodes.add(neighbor);
49
+ queue.push({ id: neighbor, depth: depth + 1 });
50
+ }
51
+ }
52
+ }
53
+ return {
54
+ center,
55
+ nodes: graph.nodes.filter(node => selectedNodes.has(node.id)),
56
+ edges: graph.edges.filter(edge => selectedEdges.has(edge.id)),
57
+ focus: {
58
+ ...options,
59
+ omittedNodes: graph.nodes.length - selectedNodes.size,
60
+ omittedEdges: graph.edges.length - selectedEdges.size,
61
+ truncated,
62
+ },
63
+ };
64
+ }
@@ -83,6 +83,12 @@ function relationshipLabel(fact, external) {
83
83
  return "Literal require syntax";
84
84
  return external ? "Imports package" : "Imports module";
85
85
  }
86
+ function decodeSource(bytes) {
87
+ const encoding = bytes[0] === 0xff && bytes[1] === 0xfe ? "utf-16le"
88
+ : bytes[0] === 0xfe && bytes[1] === 0xff ? "utf-16be" : "utf-8";
89
+ // Strip the BOM for source positions, while hashing the original bytes.
90
+ return new TextDecoder(encoding, { fatal: true }).decode(bytes);
91
+ }
86
92
  /** Build a source-backed, versioned graph. Analysis reads source/configuration
87
93
  * data only; it never imports scanned code or executes repository scripts. */
88
94
  export async function analyzeDirectory(targetDir, c4Level, options = {}) {
@@ -112,19 +118,30 @@ export async function analyzeDirectory(targetDir, c4Level, options = {}) {
112
118
  message: `Could not read source file: ${error instanceof Error ? error.message : String(error)}` });
113
119
  continue;
114
120
  }
115
- const sourceCode = sourceBytes.toString("utf8");
121
+ const isTsx = absolutePath.endsWith(".tsx");
122
+ const sourceRecord = { path: relativePath, contentHash: createHash("sha256").update(sourceBytes).digest("hex"),
123
+ language: isTsx ? "TSX" : "TypeScript" };
124
+ let sourceCode;
125
+ try {
126
+ sourceCode = decodeSource(sourceBytes);
127
+ }
128
+ catch {
129
+ diagnostics.push({ code: "SOURCE_ENCODING_ERROR", severity: "error", path: relativePath,
130
+ message: "Source is not valid UTF-8 or BOM-marked UTF-16; it could not be parsed." });
131
+ sources.push({ ...sourceRecord, parseStatus: "error" });
132
+ continue;
133
+ }
116
134
  let facts;
117
135
  try {
118
- facts = extractSourceFacts(sourceCode, relativePath, absolutePath.endsWith(".tsx"));
136
+ facts = extractSourceFacts(sourceCode, relativePath, isTsx);
119
137
  }
120
138
  catch (error) {
121
139
  diagnostics.push({ code: "PARSER_ERROR", severity: "error", path: relativePath,
122
140
  message: `Source parsing failed: ${error instanceof Error ? error.message : String(error)}` });
123
- sources.push({ path: relativePath, contentHash: createHash("sha256").update(sourceBytes).digest("hex"), parseStatus: "error" });
141
+ sources.push({ ...sourceRecord, parseStatus: "error" });
124
142
  continue;
125
143
  }
126
- sources.push({ path: relativePath, contentHash: createHash("sha256").update(sourceBytes).digest("hex"), parseStatus: facts.parseStatus,
127
- language: absolutePath.endsWith(".tsx") ? "TSX" : "TypeScript" });
144
+ sources.push({ ...sourceRecord, parseStatus: facts.parseStatus });
128
145
  diagnostics.push(...facts.diagnostics);
129
146
  const assignment = mapper.forFile(relativePath);
130
147
  const selected = nodeForFile(relativePath, assignment, c4Level);
@@ -164,6 +181,10 @@ export async function analyzeDirectory(targetDir, c4Level, options = {}) {
164
181
  connect(file.nodeId, targetFile.nodeId, fact, false);
165
182
  continue;
166
183
  }
184
+ if (resolved.localAsset) {
185
+ diagnostic(fact, "UNSUPPORTED_MODULE_TYPE", "warning", "Module specifier names an existing local asset outside supported TypeScript/TSX source; no dependency node was fabricated.");
186
+ continue;
187
+ }
167
188
  if (resolved.builtin) {
168
189
  diagnostic(fact, "BUILTIN_MODULE", "info", "Observed a platform built-in import; a built-in library is not inferred to be an external system.");
169
190
  continue;
@@ -123,22 +123,58 @@ export function createModuleResolver(scanRoot, manifestFiles, explicitTsconfig)
123
123
  return mode;
124
124
  }
125
125
  const outputDirectories = [options.outDir, options.declarationDir].filter((item) => Boolean(item) && path.resolve(item) !== scanRoot).map(item => path.resolve(item));
126
- function matchesPathAlias(specifier) {
127
- return Object.keys(options.paths ?? {}).some(pattern => {
126
+ const aliases = options.paths ?? {};
127
+ function pathAliasTargets(specifier) {
128
+ if (Object.hasOwn(aliases, specifier))
129
+ return aliases[specifier];
130
+ let best;
131
+ for (const [pattern, targets] of Object.entries(aliases)) {
128
132
  const star = pattern.indexOf("*");
129
- return star < 0 ? specifier === pattern : specifier.startsWith(pattern.slice(0, star)) && specifier.endsWith(pattern.slice(star + 1));
130
- });
133
+ if (star < 0 || pattern.indexOf("*", star + 1) >= 0)
134
+ continue;
135
+ const prefix = pattern.slice(0, star), suffix = pattern.slice(star + 1);
136
+ if (specifier.length >= prefix.length + suffix.length && specifier.startsWith(prefix) && specifier.endsWith(suffix)
137
+ && (!best || prefix.length > best.prefixLength)) {
138
+ best = { prefixLength: prefix.length, wildcard: specifier.slice(prefix.length, specifier.length - suffix.length), targets };
139
+ }
140
+ }
141
+ if (!best)
142
+ return undefined;
143
+ const { targets, wildcard } = best;
144
+ return targets.map(target => target.replace("*", () => wildcard));
145
+ }
146
+ function existingLocalAsset(specifier, containingFile, mapped) {
147
+ // TS resolves source/declaration modules, but bundlers may accept these
148
+ // literal asset imports as well. Check existence without reading the asset.
149
+ if (/[?#]/.test(specifier))
150
+ return undefined;
151
+ function isExistingAsset(candidate) {
152
+ const extension = path.extname(candidate);
153
+ return Boolean(extension) && !/^\.[cm]?[jt]sx?$/i.test(extension) && ts.sys.fileExists(candidate);
154
+ }
155
+ if (path.isAbsolute(specifier) || specifier.startsWith("./") || specifier.startsWith("../")) {
156
+ const candidate = path.resolve(path.dirname(containingFile), specifier);
157
+ return isExistingAsset(candidate) ? candidate : undefined;
158
+ }
159
+ const pathsBase = options.baseUrl ?? (typeof options.pathsBasePath === "string" ? options.pathsBasePath : undefined)
160
+ ?? (configPath ? path.dirname(configPath) : undefined);
161
+ const candidates = pathsBase ? (mapped ?? []).map(target => path.resolve(pathsBase, target)) : [];
162
+ if (options.baseUrl)
163
+ candidates.push(path.resolve(options.baseUrl, specifier));
164
+ return candidates.find(isExistingAsset);
131
165
  }
132
166
  return { configPath, outputDirectories, diagnostics, resolve(specifier, containingFile, kind, modeOverride) {
133
167
  const resolved = ts.resolveModuleName(specifier, containingFile, options, host, cache, undefined, resolutionMode(containingFile, modeOverride === "require" ? "require" : kind)).resolvedModule;
134
- const resolvedPath = resolved ? path.resolve(host.realpath?.(resolved.resolvedFileName) ?? resolved.resolvedFileName) : undefined;
135
168
  const bare = !specifier.startsWith(".") && !path.isAbsolute(specifier) && !specifier.startsWith("#");
169
+ const aliasTargets = bare ? pathAliasTargets(specifier) : undefined;
170
+ const localAsset = !resolved ? existingLocalAsset(specifier, containingFile, aliasTargets) : undefined;
171
+ const resolvedPath = resolved ? path.resolve(host.realpath?.(resolved.resolvedFileName) ?? resolved.resolvedFileName) : localAsset;
136
172
  // URL/runtime schemes and invalid package names are not npm packages.
137
173
  const candidate = bare ? packageName(specifier) : undefined;
138
174
  const name = candidate && /^(?:@[^/\s:]+\/)?[^/\s:]+$/.test(candidate) ? candidate : undefined;
139
- const localSpecifier = !bare || specifier.startsWith("node:") || matchesPathAlias(specifier) && (!resolved || !resolved.isExternalLibraryImport)
175
+ const localSpecifier = !bare || Boolean(localAsset) || specifier.startsWith("node:") || Boolean(aliasTargets && (!resolved || !resolved.isExternalLibraryImport))
140
176
  || Boolean(name && (workspacePackages.has(name) || ambiguousPackages.has(name)));
141
- return { ...(resolvedPath ? { resolvedPath } : {}), ...(name ? { packageName: name } : {}),
177
+ return { ...(resolvedPath ? { resolvedPath } : {}), ...(name ? { packageName: name } : {}), ...(localAsset ? { localAsset: true } : {}),
142
178
  externalPackage: bare && !localSpecifier && (!resolved || Boolean(resolved.isExternalLibraryImport && resolvedPath?.split(path.sep).includes("node_modules"))), localSpecifier,
143
179
  builtin: isBuiltin(specifier) };
144
180
  } };
@@ -30,6 +30,23 @@ function allSpecifiersAreTypes(node, kind) {
30
30
  const specifiers = container?.namedChildren.filter(child => child.type === `${kind}_specifier`) ?? [];
31
31
  return specifiers.length > 0 && specifiers.every(child => hasKeyword(child, "type"));
32
32
  }
33
+ function recoverTypeStarExport(node) {
34
+ if (!node.hasError)
35
+ return undefined;
36
+ // The current native grammar recovers TypeScript 5's type-only star export
37
+ // as a regular star export with a single ERROR for the `type` token. Match
38
+ // that exact shape so unrelated syntax errors remain visible.
39
+ const tokens = node.children.filter(child => child.type !== "comment");
40
+ const [keyword, modifier, star, from, source, terminator] = tokens;
41
+ if (tokens.length < 5 || tokens.length > 6 || keyword.type !== "export"
42
+ || modifier.type !== "ERROR" || modifier.text !== "type"
43
+ || !["*", "namespace_export"].includes(star.type) || star.hasError
44
+ || from.type !== "from" || source.type !== "string" || source.hasError
45
+ || (terminator && terminator.type !== ";")
46
+ || tokens.some(token => token.isMissing))
47
+ return undefined;
48
+ return modifier;
49
+ }
33
50
  function inTypeContext(node) {
34
51
  for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
35
52
  if (["type_alias_declaration", "type_annotation", "type_query", "type_arguments", "interface_declaration"].includes(ancestor.type))
@@ -62,6 +79,7 @@ export function extractSourceFacts(sourceCode, filePath, isTsx = false) {
62
79
  const exportEvidence = [];
63
80
  const seen = new Set();
64
81
  let syntaxErrors = 0;
82
+ let recoveredTypeStars = 0;
65
83
  function addDependency(source, node, kind, typeOnly, resolutionMode) {
66
84
  const specifier = literalValue(source);
67
85
  if (specifier === undefined) {
@@ -77,6 +95,7 @@ export function extractSourceFacts(sourceCode, filePath, isTsx = false) {
77
95
  dependencies.push({ specifier, kind, typeOnly, ...(resolutionMode ? { resolutionMode } : {}), evidence: location(source ?? node, filePath, kind, specifier, typeOnly) });
78
96
  }
79
97
  function walk(node) {
98
+ let recoveredModifier;
80
99
  if ((node.type === "ERROR" || node.isMissing) && syntaxErrors++ < 10) {
81
100
  diagnostics.push({ code: "PARSE_ERROR", severity: "error", path: filePath,
82
101
  line: node.startPosition.row + 1, column: node.startPosition.column + 1,
@@ -90,7 +109,8 @@ export function extractSourceFacts(sourceCode, filePath, isTsx = false) {
90
109
  }
91
110
  else if (node.type === "export_statement") {
92
111
  const source = node.childForFieldName("source");
93
- const typeOnly = hasKeyword(node, "type") || allSpecifiersAreTypes(node, "export");
112
+ recoveredModifier = recoverTypeStarExport(node);
113
+ const typeOnly = Boolean(recoveredModifier) || hasKeyword(node, "type") || allSpecifiersAreTypes(node, "export");
94
114
  if (source)
95
115
  addDependency(source, node, "re-export", typeOnly);
96
116
  // Namespace member exports do not become exports of the containing file.
@@ -136,13 +156,18 @@ export function extractSourceFacts(sourceCode, filePath, isTsx = false) {
136
156
  addDependency(source, node, typeOnly ? "type-import" : dynamic ? "dynamic-import" : "require", typeOnly);
137
157
  }
138
158
  }
139
- for (const child of node.namedChildren)
140
- walk(child);
159
+ for (const child of node.hasError ? node.children : node.namedChildren) {
160
+ if (recoveredModifier && child.id === recoveredModifier.id)
161
+ recoveredTypeStars++;
162
+ else
163
+ walk(child);
164
+ }
141
165
  }
142
166
  walk(tree.rootNode);
143
- if (tree.rootNode.hasError && !diagnostics.some(item => item.code === "PARSE_ERROR")) {
167
+ if (tree.rootNode.hasError && syntaxErrors === 0 && recoveredTypeStars === 0) {
168
+ syntaxErrors++;
144
169
  diagnostics.push({ code: "PARSE_ERROR", severity: "error", path: filePath, line: 1, column: 1,
145
170
  message: "Tree-sitter found invalid or incomplete syntax; extracted dependencies may be partial." });
146
171
  }
147
- return { dependencies, exports: [...exports].sort(), exportEvidence, diagnostics, parseStatus: tree.rootNode.hasError ? "partial" : "complete" };
172
+ return { dependencies, exports: [...exports].sort(), exportEvidence, diagnostics, parseStatus: syntaxErrors > 0 ? "partial" : "complete" };
148
173
  }
@@ -3,23 +3,62 @@ import path from "node:path";
3
3
  import { renderC4Mermaid } from "./c4Mermaid.js";
4
4
  import { renderCompactC4 } from "./compactC4.js";
5
5
  import { validateArchitectureGraph } from "../model/graph.js";
6
+ import { selectGraphNeighborhood } from "../model/neighborhood.js";
6
7
  export function graphToDiagram(input, title) {
7
- const graph = validateArchitectureGraph(input);
8
+ return diagramFromView(validateArchitectureGraph(input), title);
9
+ }
10
+ function diagramFromView(graph, title, view) {
8
11
  const boundaries = new Map(graph.hierarchy.map(item => [item.id, item]));
12
+ const { nodes, edges } = view ?? graph;
13
+ const labelCounts = new Map();
14
+ const labelKey = (edge) => JSON.stringify([edge.source, edge.target, edge.label]);
15
+ if (view)
16
+ for (const edge of edges) {
17
+ const key = labelKey(edge);
18
+ labelCounts.set(key, (labelCounts.get(key) ?? 0) + 1);
19
+ }
20
+ let caption;
21
+ if (view) {
22
+ const { direction, maxDepth, truncated } = view.focus;
23
+ const directionLabel = direction === "both" ? "Both directions" : direction === "incoming" ? "Incoming" : "Outgoing";
24
+ caption = `${nodes.length} of ${graph.nodes.length} nodes · ${edges.length} of ${graph.edges.length} relationships · ${directionLabel} · Depth ${maxDepth}`;
25
+ if (truncated.nodes || truncated.edges)
26
+ caption += " · Limit reached";
27
+ }
9
28
  return {
10
- title: title ?? `${graph.hierarchy.find(item => item.type === "System")?.label ?? "Architecture"} — ${graph.metadata.abstractionLevel}`,
29
+ title: title ?? (view ? `${view.center.label} · Neighborhood`
30
+ : `${graph.hierarchy.find(item => item.type === "System")?.label ?? "Architecture"} — ${graph.metadata.abstractionLevel}`),
31
+ caption,
11
32
  c4Level: `C4${graph.metadata.abstractionLevel}`,
12
- nodes: graph.nodes.map(node => ({
33
+ nodes: nodes.map(node => ({
13
34
  id: node.id, label: node.classification.basis === "inferred" && !node.label.includes("(inferred)") ? `${node.label} (inferred)` : node.label, type: node.type,
14
35
  technology: node.technology,
15
36
  description: [node.classification.basis === "inferred" ? "Inferred boundary" : undefined,
16
37
  node.parentId ? `Within ${boundaries.get(node.parentId)?.label ?? node.parentId}${boundaries.get(node.parentId)?.classification.basis === "inferred" ? " (inferred)" : ""}` : undefined,
17
38
  node.description].filter(Boolean).join(" · ") || undefined,
18
39
  })),
19
- relationships: graph.edges.map(edge => ({ from: edge.source, to: edge.target, label: edge.label,
20
- technology: edge.protocol === "unknown" ? undefined : edge.protocol })),
40
+ relationships: edges.map(edge => {
41
+ const specifier = (labelCounts.get(labelKey(edge)) ?? 0) > 1
42
+ ? edge.evidence.find(item => item.specifier)?.specifier : undefined;
43
+ const evidence = view ? edge.evidence.slice(0, 3).map(item => `${item.path}:${item.line}:${item.column}${item.specifier ? ` (${item.specifier})` : ""}`) : [];
44
+ if (view && edge.evidence.length > evidence.length)
45
+ evidence.push(`+${edge.evidence.length - evidence.length} more in the saved graph`);
46
+ return {
47
+ from: edge.source,
48
+ to: edge.target,
49
+ label: specifier ? `${edge.label} · ${specifier}` : edge.label,
50
+ technology: edge.protocol === "unknown" ? undefined : edge.protocol,
51
+ description: evidence.length ? evidence.join("\n") : undefined,
52
+ };
53
+ }),
21
54
  };
22
55
  }
56
+ export async function renderArchitectureGraph(input, options = {}) {
57
+ const graph = validateArchitectureGraph(input);
58
+ const view = options.focus ? selectGraphNeighborhood(graph, options.focus) : undefined;
59
+ const result = await renderArtifacts(diagramFromView(graph, options.title, view), options.outputPath);
60
+ return view ? { ...result, structuredContent: { ...result.structuredContent, focus: view.focus } } : result;
61
+ }
23
62
  export async function renderArtifacts(diagram, outputPath) {
24
63
  const mermaidOutput = renderC4Mermaid(diagram);
25
64
  const compact = await renderCompactC4(diagram);
@@ -34,9 +73,17 @@ export async function renderArtifacts(diagram, outputPath) {
34
73
  await Promise.all([fs.writeFile(artifacts.svg, compact.svg), fs.writeFile(artifacts.png, compact.png), fs.writeFile(artifacts.html, compact.html)]);
35
74
  const fileLink = (filename) => encodeURIComponent(path.basename(filename));
36
75
  const cell = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/[\\`*_{}\[\]|]/g, "\\$&").replace(/\s+/g, " ");
37
- const key = diagram.relationships.map((r, i) => `| R${String(i + 1).padStart(2, "0")} | ${cell(r.from)} | ${cell(r.to)} | ${cell(r.label)} | ${cell(r.technology ?? "")} |`).join("\n");
76
+ const hasEvidence = diagram.relationships.some(r => r.description);
77
+ const labels = new Map(diagram.nodes.map(node => [node.id, node.label]));
78
+ const key = diagram.relationships.map((r, i) => {
79
+ const from = hasEvidence ? labels.get(r.from) : r.from;
80
+ const to = hasEvidence ? labels.get(r.to) : r.to;
81
+ const row = `| R${String(i + 1).padStart(2, "0")} | ${cell(from)} | ${cell(to)} | ${cell(r.label)} | ${cell(r.technology ?? "")} |`;
82
+ return hasEvidence ? `${row} ${cell(r.description ?? "")} |` : row;
83
+ }).join("\n");
84
+ const scope = diagram.caption ? `${cell(diagram.caption)}\n\n` : "";
38
85
  const heading = diagram.title.replace(/\s+/g, " ").trim().replace(/[\\`*_{}\[\]<>]/g, "\\$&");
39
- await fs.writeFile(markdown, `# ${heading}\n\n![Compact C4 diagram](${fileLink(artifacts.png)})\n\n[Interactive view](${fileLink(artifacts.html)}) · [Scalable SVG](${fileLink(artifacts.svg)})\n\n<details>\n<summary>Mermaid source export</summary>\n\n${mermaidOutput}\n\n</details>\n\n## Relationships\n\n| ID | From | To | Action | Technology |\n|---|---|---|---|---|\n${key}\n`, "utf8");
86
+ await fs.writeFile(markdown, `# ${heading}\n\n${scope}![Compact C4 diagram](${fileLink(artifacts.png)})\n\n[Interactive view](${fileLink(artifacts.html)}) · [Scalable SVG](${fileLink(artifacts.svg)})\n\n<details>\n<summary>Mermaid source export</summary>\n\n${mermaidOutput}\n\n</details>\n\n## Relationships\n\n| ID | From | To | Action | Technology |${hasEvidence ? " Source evidence |" : ""}\n|---|---|---|---|---|${hasEvidence ? "---|" : ""}\n${key}\n`, "utf8");
40
87
  }
41
88
  return {
42
89
  content: [{ type: "text", text: mermaidOutput }, { type: "image", data: compact.png.toString("base64"), mimeType: "image/png" }],
@@ -91,7 +91,8 @@ async function layoutDiagram(diagram) {
91
91
  const { graph, direction } = candidates.sort((a, b) => a.score - b.score)[0];
92
92
  const titleLines = wrap(diagram.title.replace(/\s+/g, " "), 90);
93
93
  const headerExtra = Math.max(0, titleLines.length - 1) * 24;
94
- const width = Math.ceil(Math.max(graph.width + 48, await textWidth(titleLines, 21) + 48));
94
+ const captionWidth = diagram.caption ? await textWidth([diagram.caption], 12) : 0;
95
+ const width = Math.ceil(Math.max(graph.width, await textWidth(titleLines, 21), captionWidth) + 48);
95
96
  const offset = { x: (width - graph.width) / 2, y: 88 + headerExtra };
96
97
  const positionedNodes = nodes.map(node => {
97
98
  const position = graph.children.find(candidate => candidate.id === node.alias);
@@ -106,7 +107,7 @@ async function layoutDiagram(diagram) {
106
107
  .map(point => ({ x: point.x + offset.x, y: point.y + offset.y }));
107
108
  const box = edge.labels[0];
108
109
  return { id: relationship.id, from: relationship.from, to: relationship.to, label: relationship.label,
109
- technology: relationship.technology, displayLines: relationship.displayLines, points,
110
+ technology: relationship.technology, description: relationship.description, displayLines: relationship.displayLines, points,
110
111
  labelBox: { x: box.x + offset.x, y: box.y + offset.y, width: box.width, height: box.height } };
111
112
  });
112
113
  return { width, height: Math.ceil(graph.height + 112 + headerExtra), direction,
@@ -115,9 +116,10 @@ async function layoutDiagram(diagram) {
115
116
  function drawSvg(diagram, layout) {
116
117
  const titleLines = wrap(diagram.title.replace(/\s+/g, " "), 90);
117
118
  const title = svgText(titleLines, layout.width / 2, 28, 21, 24, "diagram-title");
119
+ const caption = diagram.caption ?? `${layout.nodes.length} nodes · ${layout.relationships.length} relationships · R IDs identify individual arrows`;
118
120
  const paths = layout.relationships.map(relationship => {
119
121
  const d = relationship.points.map((point, index) => `${index ? "L" : "M"}${point.x},${point.y}`).join(" ");
120
- const description = `${relationship.id}: ${relationship.from} → ${relationship.to}. ${relationship.label}${relationship.technology ? ` [${relationship.technology}]` : ""}`;
122
+ const description = [`${relationship.id}: ${relationship.from} → ${relationship.to}. ${relationship.label}${relationship.technology ? ` [${relationship.technology}]` : ""}`, relationship.description].filter(Boolean).join("\n");
121
123
  return `<g class="edge" data-edge-id="${relationship.id}"><title>${xml(description)}</title><path class="edge-halo" d="${d}"/><path class="flowchart-link" id="${relationship.id}" data-edge-id="${relationship.id}" d="${d}" marker-end="url(#arrow)"/></g>`;
122
124
  }).join("");
123
125
  const nodeShapes = layout.nodes.map(node => {
@@ -129,11 +131,12 @@ function drawSvg(diagram, layout) {
129
131
  }).join("");
130
132
  const labels = layout.relationships.map(relationship => {
131
133
  const box = relationship.labelBox;
132
- return `<g class="edgeLabel" data-edge-id="${relationship.id}"><title>${xml(`${relationship.id}: ${relationship.label}${relationship.technology ? ` [${relationship.technology}]` : ""}`)}</title><g class="label" data-id="${relationship.id}"><rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="3"/>${svgText(relationship.displayLines, box.x + box.width / 2, box.y + 16, 14, 18, "edge-label-text")}</g></g>`;
134
+ const description = [`${relationship.id}: ${relationship.label}${relationship.technology ? ` [${relationship.technology}]` : ""}`, relationship.description].filter(Boolean).join("\n");
135
+ return `<g class="edgeLabel" data-edge-id="${relationship.id}"><title>${xml(description)}</title><g class="label" data-id="${relationship.id}"><rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="3"/>${svgText(relationship.displayLines, box.x + box.width / 2, box.y + 16, 14, 18, "edge-label-text")}</g></g>`;
133
136
  }).join("");
134
137
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${layout.width}" height="${layout.height}" viewBox="0 0 ${layout.width} ${layout.height}" data-engine="elk" role="img" aria-labelledby="diagram-title diagram-description"><title id="diagram-title">${xml(diagram.title)}</title><desc id="diagram-description">${layout.nodes.length} architecture nodes and ${layout.relationships.length} directed relationships. Select a component in the interactive view to trace its connections.</desc><defs><marker id="arrow" viewBox="0 0 8 8" refX="8" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L8 4L0 8Z" fill="#4d657c"/></marker></defs><style>
135
138
  text{font-family:Arial,Helvetica,sans-serif;fill:#172b43}.diagram-title{font-weight:500}.node rect{fill:#e7f0fb;stroke:#5381b7;stroke-width:1.2}.node.external rect{fill:#f0f2f5;stroke:#818d9d}.node-kind,.node-technology{fill:#4a5e76}.edge-halo,.flowchart-link{fill:none;stroke-linejoin:round}.edge-halo{stroke:white;stroke-width:5}.flowchart-link{stroke:#4d657c;stroke-width:1.5}.edgeLabel rect{fill:white}.node:focus rect{stroke:#174fbb;stroke-width:2.5}.node{cursor:pointer}.edge,.edgeLabel{cursor:pointer}.dimmed{opacity:.12}.selected rect{stroke:#174fbb;stroke-width:2.5}.outgoing .flowchart-link{stroke:#155dd5;stroke-width:2.5}.incoming .flowchart-link{stroke:#a94a10;stroke-width:2.5}.outgoing .edge-label-text{fill:#124da7}.incoming .edge-label-text{fill:#8f3e0d}
136
- </style><rect class="background" width="100%" height="100%" fill="white"/>${title}<text x="${layout.width / 2}" y="${55 + Math.max(0, titleLines.length - 1) * 24}" text-anchor="middle" font-size="12">${layout.nodes.length} nodes · ${layout.relationships.length} relationships · R IDs identify individual arrows</text>${paths}${nodeShapes}${labels}</svg>`;
139
+ </style><rect class="background" width="100%" height="100%" fill="white"/>${title}<text x="${layout.width / 2}" y="${55 + Math.max(0, titleLines.length - 1) * 24}" text-anchor="middle" font-size="12">${xml(caption)}</text>${paths}${nodeShapes}${labels}</svg>`;
137
140
  }
138
141
  function drawHtml(diagram, svg, layout) {
139
142
  const data = JSON.stringify(layout).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
@@ -151,7 +154,7 @@ function show(selection){
151
154
  root.querySelectorAll('.node').forEach(element=>{const id=element.dataset.nodeId;element.classList.toggle('dimmed',!!selection&&!visibleNodes.has(id));element.classList.toggle('selected',selection?.kind==='node'&&id===selection.id);});
152
155
  root.querySelectorAll('.edge,.edgeLabel').forEach(element=>{const edge=graph.relationships.find(edge=>edge.id===element.dataset.edgeId),active=ids.has(edge.id);element.classList.toggle('dimmed',!!selection&&!active);element.classList.toggle('outgoing',!!selection&&active&&(selection.kind==='edge'||edge.from===selection.id));element.classList.toggle('incoming',!!selection&&active&&selection.kind==='node'&&edge.to===selection.id&&edge.from!==selection.id);});
153
156
  const node=selection?.kind==='node'?nodes.get(selection.id):null,edge=selection?.kind==='edge'?related[0]:null;
154
- title.textContent=node?.label??(edge?edge.id+' · '+edge.label:'All connections');details.textContent=node?[node.technology,node.description,related.length+' connected relationships'].filter(Boolean).join('\\n'):edge?[nodes.get(edge.from).label+' → '+nodes.get(edge.to).label,edge.technology].filter(Boolean).join('\\n'):'Select a component to highlight its incoming and outgoing arrows. Select an arrow or relationship below to isolate it.';
157
+ title.textContent=node?.label??(edge?edge.id+' · '+edge.label:'All connections');details.textContent=node?[node.technology,node.description,related.length+' connected relationships'].filter(Boolean).join('\\n'):edge?[nodes.get(edge.from).label+' → '+nodes.get(edge.to).label,edge.technology,edge.description].filter(Boolean).join('\\n'):'Select a component to highlight its incoming and outgoing arrows. Select an arrow or relationship below to isolate it.';
155
158
  list.replaceChildren();for(const edge of related){const item=document.createElement('li'),button=document.createElement('button'),small=document.createElement('small');button.textContent=edge.id+' · '+nodes.get(edge.from).label+' → '+nodes.get(edge.to).label;small.textContent=edge.label+(edge.technology?' · '+edge.technology:'');button.append(small);button.addEventListener('click',()=>{locked={kind:'edge',id:edge.id};show(locked);});item.append(button);list.append(item);}
156
159
  }
157
160
  root.querySelectorAll('.node').forEach(element=>{const select=()=>{locked={kind:'node',id:element.dataset.nodeId};show(locked);};element.addEventListener('click',select);element.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();select();}});element.addEventListener('mouseenter',()=>{if(!locked)show({kind:'node',id:element.dataset.nodeId});});element.addEventListener('mouseleave',()=>{if(!locked)show(null);});});
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c4-architect-mcp",
3
- "version": "0.2.0-alpha.1",
3
+ "version": "0.2.0-alpha.2",
4
4
  "description": "Local TypeScript architecture analysis with source evidence, C4 diagrams, and interactive exports over MCP",
5
5
  "type": "module",
6
6
  "bin": {