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 +13 -0
- package/README.md +44 -13
- package/dist/cli.js +11 -4
- package/dist/index.js +4 -2
- package/dist/model/graph.js +38 -5
- package/dist/model/neighborhood.js +64 -0
- package/dist/parsers/astParser.js +26 -5
- package/dist/parsers/moduleResolver.js +43 -7
- package/dist/parsers/syntaxFacts.js +30 -5
- package/dist/renderers/artifacts.js +54 -7
- package/dist/renderers/compactC4.js +9 -6
- package/docs/images/component-20.png +0 -0
- package/package.json +1 -1
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.
|
|
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.
|
|
39
|
+
npx -y c4-architect-mcp@0.2.0-alpha.2 --help
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
The examples pin `0.2.0-alpha.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
115
|
-
npx -y c4-architect-mcp@0.2.0-alpha.
|
|
116
|
-
npx -y c4-architect-mcp@0.2.0-alpha.
|
|
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
|
|
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
|

|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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() })),
|
package/dist/model/graph.js
CHANGED
|
@@ -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
|
-
|
|
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) => ({
|
|
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)
|
|
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
|
|
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,
|
|
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({
|
|
141
|
+
sources.push({ ...sourceRecord, parseStatus: "error" });
|
|
124
142
|
continue;
|
|
125
143
|
}
|
|
126
|
-
sources.push({
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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:") ||
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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:
|
|
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
|
-
|
|
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 ?? `${
|
|
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:
|
|
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:
|
|
20
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/[\\`*_{}\[\]|]/g, "\\$&").replace(/\s+/g, " ");
|
|
37
|
-
const
|
|
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})\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
|
|
86
|
+
await fs.writeFile(markdown, `# ${heading}\n\n${scope}})\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
|
|
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
|
-
|
|
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">${
|
|
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