archtracker-mcp 0.1.0

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.
@@ -0,0 +1,1853 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import { Command } from "commander";
5
+ import { watch } from "fs";
6
+ import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
7
+ import { join as join2 } from "path";
8
+
9
+ // src/analyzer/analyze.ts
10
+ import { resolve } from "path";
11
+ import { cruise } from "dependency-cruiser";
12
+ var DEFAULT_EXCLUDE = [
13
+ "node_modules",
14
+ "\\.d\\.ts$",
15
+ "dist",
16
+ "build",
17
+ "coverage",
18
+ "\\.archtracker"
19
+ ];
20
+ async function analyzeProject(rootDir, options = {}) {
21
+ const {
22
+ exclude = [],
23
+ maxDepth = 0,
24
+ tsConfigPath,
25
+ includeTypeOnly = true
26
+ } = options;
27
+ const absRootDir = resolve(rootDir);
28
+ const allExclude = [...DEFAULT_EXCLUDE, ...exclude];
29
+ const excludePattern = allExclude.join("|");
30
+ const cruiseOptions = {
31
+ baseDir: absRootDir,
32
+ exclude: { path: excludePattern },
33
+ doNotFollow: { path: "node_modules" },
34
+ maxDepth,
35
+ tsPreCompilationDeps: includeTypeOnly ? true : false,
36
+ combinedDependencies: false
37
+ };
38
+ if (tsConfigPath) {
39
+ cruiseOptions.tsConfig = { fileName: tsConfigPath };
40
+ }
41
+ let result;
42
+ try {
43
+ result = await cruise(["."], cruiseOptions);
44
+ } catch (error) {
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ throw new AnalyzerError(
47
+ `dependency-cruiser \u306E\u5B9F\u884C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: ${message}`,
48
+ { cause: error }
49
+ );
50
+ }
51
+ if (result.exitCode !== 0 && !result.output) {
52
+ throw new AnalyzerError(
53
+ `\u89E3\u6790\u304C\u30A8\u30E9\u30FC\u30B3\u30FC\u30C9 ${result.exitCode} \u3067\u7D42\u4E86\u3057\u307E\u3057\u305F`
54
+ );
55
+ }
56
+ const cruiseResult = result.output;
57
+ return buildGraph(absRootDir, cruiseResult);
58
+ }
59
+ function buildGraph(rootDir, cruiseResult) {
60
+ const files = {};
61
+ const edges = [];
62
+ const circularSet = /* @__PURE__ */ new Set();
63
+ const circularDependencies = [];
64
+ for (const mod of cruiseResult.modules) {
65
+ if (isExternalModule(mod)) continue;
66
+ files[mod.source] = {
67
+ path: mod.source,
68
+ exists: !mod.couldNotResolve,
69
+ dependencies: [],
70
+ dependents: []
71
+ };
72
+ }
73
+ for (const mod of cruiseResult.modules) {
74
+ for (const dep of mod.dependencies) {
75
+ if (dep.couldNotResolve || dep.coreModule) continue;
76
+ if (!files[mod.source] || isExternalDep(dep)) continue;
77
+ const edgeType = dep.typeOnly ? "type-only" : dep.dynamic ? "dynamic" : "static";
78
+ edges.push({
79
+ source: mod.source,
80
+ target: dep.resolved,
81
+ type: edgeType
82
+ });
83
+ if (files[mod.source]) {
84
+ files[mod.source].dependencies.push(dep.resolved);
85
+ }
86
+ if (files[dep.resolved]) {
87
+ files[dep.resolved].dependents.push(mod.source);
88
+ }
89
+ if (dep.circular && dep.cycle) {
90
+ const cyclePath = dep.cycle.map((c) => c.name);
91
+ const cycleKey = [...cyclePath].sort().join("\u2192");
92
+ if (!circularSet.has(cycleKey)) {
93
+ circularSet.add(cycleKey);
94
+ circularDependencies.push({ cycle: cyclePath });
95
+ }
96
+ }
97
+ }
98
+ }
99
+ return {
100
+ rootDir,
101
+ files,
102
+ edges,
103
+ circularDependencies,
104
+ totalFiles: Object.keys(files).length,
105
+ totalEdges: edges.length
106
+ };
107
+ }
108
+ function isExternalModule(mod) {
109
+ if (mod.coreModule) return true;
110
+ const depTypes = mod.dependencyTypes ?? [];
111
+ if (depTypes.some((t2) => t2.startsWith("npm") || t2 === "core")) return true;
112
+ return isExternalPath(mod.source);
113
+ }
114
+ function isExternalDep(dep) {
115
+ if (dep.coreModule) return true;
116
+ if (dep.dependencyTypes.some((t2) => t2.startsWith("npm") || t2 === "core")) return true;
117
+ return isExternalPath(dep.resolved);
118
+ }
119
+ function isExternalPath(source) {
120
+ if (source.startsWith("@")) return true;
121
+ if (!source.includes("/") && !source.includes("\\") && !source.includes(".")) return true;
122
+ if (source.startsWith("node:")) return true;
123
+ return false;
124
+ }
125
+ var AnalyzerError = class extends Error {
126
+ constructor(message, options) {
127
+ super(message, options);
128
+ this.name = "AnalyzerError";
129
+ }
130
+ };
131
+
132
+ // src/i18n/index.ts
133
+ var currentLocale = detectLocale();
134
+ function getLocale() {
135
+ return currentLocale;
136
+ }
137
+ function setLocale(locale) {
138
+ currentLocale = locale;
139
+ }
140
+ function detectLocale() {
141
+ const env = process.env.LC_ALL || process.env.LANG || "";
142
+ if (env.startsWith("ja")) return "ja";
143
+ return "en";
144
+ }
145
+ function t(key, vars) {
146
+ const messages = currentLocale === "ja" ? ja : en;
147
+ let msg = messages[key] ?? en[key] ?? key;
148
+ if (vars) {
149
+ for (const [k, v] of Object.entries(vars)) {
150
+ msg = msg.replaceAll(`{${k}}`, String(v));
151
+ }
152
+ }
153
+ return msg;
154
+ }
155
+ var en = {
156
+ // Analyzer
157
+ "analyzer.failed": "dependency-cruiser failed: {message}",
158
+ "analyzer.exitCode": "Analysis finished with error code {code}",
159
+ // Storage
160
+ "storage.parseFailed": "Failed to parse snapshot.json. File may be corrupted: {path}",
161
+ "storage.readFailed": "Failed to read snapshot.json: {path}",
162
+ "storage.invalidSchema": "snapshot.json schema is invalid. Please regenerate with `archtracker init`:\n{issues}",
163
+ "storage.versionMismatch": "snapshot.json version ({version}) is incompatible with current schema ({expected}). Regenerate with `archtracker init`.",
164
+ // Path guard
165
+ "pathGuard.traversal": 'Path points outside project root: "{input}" \u2192 "{resolved}" (allowed: "{boundary}")',
166
+ // Diff report
167
+ "diff.title": "# Architecture Change Report\n",
168
+ "diff.noChanges": "No changes \u2014 snapshot matches current code.\n",
169
+ "diff.added": "## Added Files ({count})",
170
+ "diff.removed": "## Removed Files ({count})",
171
+ "diff.modified": "## Modified Dependencies ({count})",
172
+ "diff.affected": "## Files Requiring Review ({count})",
173
+ "diff.reasonRemoved": 'Dependency "{file}" was removed',
174
+ "diff.reasonModified": 'Dependency "{file}" had its dependencies changed',
175
+ "diff.reasonAdded": 'New dependency "{file}" was added',
176
+ // Search
177
+ "search.pathMatch": 'Path matches "{pattern}"',
178
+ "search.affected": 'May be affected by changes to "{file}" (via: {via})',
179
+ "search.critical": "{count} files depend on this component",
180
+ "search.orphan": "Orphan file (no dependencies, no dependents)",
181
+ "search.noResults": 'No results: "{query}" (mode: {mode})',
182
+ "search.results": "Results: {count} (mode: {mode})",
183
+ // CLI
184
+ "cli.analyzing": "Analyzing...",
185
+ "cli.snapshotSaved": "Snapshot saved",
186
+ "cli.timestamp": " Timestamp: {ts}",
187
+ "cli.fileCount": " Files: {count}",
188
+ "cli.edgeCount": " Edges: {count}",
189
+ "cli.circularCount": " Circular deps: {count}",
190
+ "cli.keyComponents": "\nKey components:",
191
+ "cli.dependedBy": "{path} ({count} dependents)",
192
+ "cli.noSnapshot": "No snapshot found. Run `archtracker init` first.",
193
+ "cli.ciFailed": "\nCI check failed: {count} file(s) require review",
194
+ "cli.autoGenerating": "No snapshot found, auto-generating...",
195
+ "cli.project": "Project: {path}",
196
+ "cli.validPaths": "\nValid file paths:",
197
+ "cli.snapshot": "Snapshot: {ts}",
198
+ // MCP
199
+ "mcp.analyzeComplete": "Analysis complete: {files} files, {edges} edges",
200
+ "mcp.circularFound": "Circular deps: {count} found",
201
+ "mcp.circularNone": "Circular deps: none",
202
+ "mcp.snapshotSaved": "Snapshot saved",
203
+ "mcp.autoInit": "No snapshot existed. Initial snapshot auto-generated.",
204
+ "mcp.nextCheckEnabled": "Diff checking will be active from the next run.",
205
+ "mcp.queryRequired": '"{mode}" mode requires the query parameter',
206
+ // Analyze report
207
+ "analyze.title": "# Architecture Analysis Report\n",
208
+ "analyze.overview": "## Overview",
209
+ "analyze.totalFiles": " Total files: {count}",
210
+ "analyze.totalEdges": " Total edges: {count}",
211
+ "analyze.totalCircular": " Circular dependencies: {count}",
212
+ "analyze.criticalTitle": "\n## Critical Components (Top {count})",
213
+ "analyze.criticalItem": " {path} ({count} dependents)",
214
+ "analyze.circularTitle": "\n## Circular Dependencies ({count})",
215
+ "analyze.circularItem": " {files}",
216
+ "analyze.orphanTitle": "\n## Orphan Files ({count})",
217
+ "analyze.couplingTitle": "\n## High Coupling (Top {count} by import count)",
218
+ "analyze.couplingItem": " {path} ({count} imports)",
219
+ "analyze.layerTitle": "\n## Directory Breakdown",
220
+ "analyze.layerItem": " {dir}/ \u2014 {count} files",
221
+ "analyze.noIssues": "\nNo architectural issues detected.",
222
+ "analyze.snapshotSaved": "\nSnapshot saved alongside analysis.",
223
+ // CI
224
+ "ci.generated": "GitHub Actions workflow generated: {path}",
225
+ // Web viewer
226
+ "web.starting": "Starting architecture viewer...",
227
+ "web.listening": "Architecture graph available at: http://localhost:{port}",
228
+ "web.stop": "Press Ctrl+C to stop",
229
+ "web.watching": "Watching {dir}/ for changes...",
230
+ "web.reloading": "File change detected, reloading...",
231
+ "web.reloaded": "Graph reloaded",
232
+ // Errors
233
+ "error.analyzer": "[Analysis Error] {message}",
234
+ "error.storage": "[Storage Error] {message}",
235
+ "error.pathTraversal": "[Security Error] {message}",
236
+ "error.generic": "[Error] {message}",
237
+ "error.unexpected": "[Error] Unexpected error: {message}",
238
+ "error.cli.analyzer": "Analysis error: {message}",
239
+ "error.cli.storage": "Storage error: {message}",
240
+ "error.cli.generic": "Error: {message}",
241
+ "error.cli.unexpected": "Unexpected error: {message}"
242
+ };
243
+ var ja = {
244
+ // Analyzer
245
+ "analyzer.failed": "dependency-cruiser \u306E\u5B9F\u884C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {message}",
246
+ "analyzer.exitCode": "\u89E3\u6790\u304C\u30A8\u30E9\u30FC\u30B3\u30FC\u30C9 {code} \u3067\u7D42\u4E86\u3057\u307E\u3057\u305F",
247
+ // Storage
248
+ "storage.parseFailed": "snapshot.json \u306E\u30D1\u30FC\u30B9\u306B\u5931\u6557\u3057\u307E\u3057\u305F\u3002\u30D5\u30A1\u30A4\u30EB\u304C\u7834\u640D\u3057\u3066\u3044\u308B\u53EF\u80FD\u6027\u304C\u3042\u308A\u307E\u3059: {path}",
249
+ "storage.readFailed": "snapshot.json \u306E\u8AAD\u307F\u53D6\u308A\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {path}",
250
+ "storage.invalidSchema": "snapshot.json \u306E\u30B9\u30AD\u30FC\u30DE\u304C\u4E0D\u6B63\u3067\u3059\u3002archtracker init \u3067\u518D\u751F\u6210\u3057\u3066\u304F\u3060\u3055\u3044:\n{issues}",
251
+ "storage.versionMismatch": "snapshot.json \u306E\u30D0\u30FC\u30B8\u30E7\u30F3 ({version}) \u304C\u73FE\u5728\u306E\u30B9\u30AD\u30FC\u30DE ({expected}) \u3068\u4E92\u63DB\u6027\u304C\u3042\u308A\u307E\u305B\u3093\u3002archtracker init \u3067\u518D\u751F\u6210\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
252
+ // Path guard
253
+ "pathGuard.traversal": '\u30D1\u30B9\u304C\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8\u30EB\u30FC\u30C8\u306E\u5916\u90E8\u3092\u6307\u3057\u3066\u3044\u307E\u3059: "{input}" \u2192 "{resolved}" (\u8A31\u53EF\u7BC4\u56F2: "{boundary}")',
254
+ // Diff report
255
+ "diff.title": "# \u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3\u5909\u66F4\u30EC\u30DD\u30FC\u30C8\n",
256
+ "diff.noChanges": "\u5909\u66F4\u306A\u3057 \u2014 \u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3068\u73FE\u5728\u306E\u30B3\u30FC\u30C9\u306F\u4E00\u81F4\u3057\u3066\u3044\u307E\u3059\u3002\n",
257
+ "diff.added": "## \u8FFD\u52A0\u3055\u308C\u305F\u30D5\u30A1\u30A4\u30EB ({count}\u4EF6)",
258
+ "diff.removed": "## \u524A\u9664\u3055\u308C\u305F\u30D5\u30A1\u30A4\u30EB ({count}\u4EF6)",
259
+ "diff.modified": "## \u4F9D\u5B58\u95A2\u4FC2\u304C\u5909\u66F4\u3055\u308C\u305F\u30D5\u30A1\u30A4\u30EB ({count}\u4EF6)",
260
+ "diff.affected": "## \u78BA\u8A8D\u304C\u5FC5\u8981\u306A\u30D5\u30A1\u30A4\u30EB ({count}\u4EF6)",
261
+ "diff.reasonRemoved": '\u4F9D\u5B58\u5148 "{file}" \u304C\u524A\u9664\u3055\u308C\u307E\u3057\u305F',
262
+ "diff.reasonModified": '\u4F9D\u5B58\u5148 "{file}" \u306E\u4F9D\u5B58\u95A2\u4FC2\u304C\u5909\u66F4\u3055\u308C\u307E\u3057\u305F',
263
+ "diff.reasonAdded": '\u65B0\u3057\u3044\u4F9D\u5B58\u5148 "{file}" \u304C\u8FFD\u52A0\u3055\u308C\u307E\u3057\u305F',
264
+ // Search
265
+ "search.pathMatch": '\u30D1\u30B9\u304C "{pattern}" \u306B\u30DE\u30C3\u30C1',
266
+ "search.affected": '"{file}" \u306E\u5909\u66F4\u306B\u3088\u308A\u5F71\u97FF\u3092\u53D7\u3051\u308B\u53EF\u80FD\u6027\uFF08\u7D4C\u7531: {via}\uFF09',
267
+ "search.critical": "{count}\u4EF6\u306E\u30D5\u30A1\u30A4\u30EB\u304C\u4F9D\u5B58\u3059\u308B\u91CD\u8981\u30B3\u30F3\u30DD\u30FC\u30CD\u30F3\u30C8",
268
+ "search.orphan": "\u5B64\u7ACB\u30D5\u30A1\u30A4\u30EB\uFF08\u4F9D\u5B58\u306A\u3057\u30FB\u88AB\u4F9D\u5B58\u306A\u3057\uFF09",
269
+ "search.noResults": '\u691C\u7D22\u7D50\u679C\u306A\u3057: "{query}" (\u30E2\u30FC\u30C9: {mode})',
270
+ "search.results": "\u691C\u7D22\u7D50\u679C: {count}\u4EF6 (\u30E2\u30FC\u30C9: {mode})",
271
+ // CLI
272
+ "cli.analyzing": "\u89E3\u6790\u4E2D...",
273
+ "cli.snapshotSaved": "\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3092\u4FDD\u5B58\u3057\u307E\u3057\u305F",
274
+ "cli.timestamp": " \u30BF\u30A4\u30E0\u30B9\u30BF\u30F3\u30D7: {ts}",
275
+ "cli.fileCount": " \u30D5\u30A1\u30A4\u30EB\u6570: {count}",
276
+ "cli.edgeCount": " \u30A8\u30C3\u30B8\u6570: {count}",
277
+ "cli.circularCount": " \u5FAA\u74B0\u53C2\u7167: {count}\u4EF6",
278
+ "cli.keyComponents": "\n\u4E3B\u8981\u30B3\u30F3\u30DD\u30FC\u30CD\u30F3\u30C8:",
279
+ "cli.dependedBy": "{path} ({count}\u4EF6\u304C\u4F9D\u5B58)",
280
+ "cli.noSnapshot": "\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002`archtracker init` \u3092\u5148\u306B\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
281
+ "cli.ciFailed": "\nCI \u30C1\u30A7\u30C3\u30AF\u5931\u6557: {count}\u4EF6\u306E\u8981\u78BA\u8A8D\u30D5\u30A1\u30A4\u30EB\u304C\u3042\u308A\u307E\u3059",
282
+ "cli.autoGenerating": "\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u304C\u7121\u3044\u305F\u3081\u81EA\u52D5\u751F\u6210\u3057\u307E\u3059...",
283
+ "cli.project": "\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8: {path}",
284
+ "cli.validPaths": "\n\u6709\u52B9\u306A\u30D5\u30A1\u30A4\u30EB\u30D1\u30B9:",
285
+ "cli.snapshot": "\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8: {ts}",
286
+ // MCP
287
+ "mcp.analyzeComplete": "\u89E3\u6790\u5B8C\u4E86: {files}\u30D5\u30A1\u30A4\u30EB, {edges}\u30A8\u30C3\u30B8",
288
+ "mcp.circularFound": "\u5FAA\u74B0\u53C2\u7167: {count}\u4EF6\u691C\u51FA",
289
+ "mcp.circularNone": "\u5FAA\u74B0\u53C2\u7167: \u306A\u3057",
290
+ "mcp.snapshotSaved": "\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3092\u4FDD\u5B58\u3057\u307E\u3057\u305F",
291
+ "mcp.autoInit": "\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u304C\u5B58\u5728\u3057\u306A\u304B\u3063\u305F\u305F\u3081\u3001\u521D\u671F\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3092\u81EA\u52D5\u751F\u6210\u3057\u307E\u3057\u305F\u3002",
292
+ "mcp.nextCheckEnabled": "\u6B21\u56DE\u306E\u5B9F\u884C\u6642\u304B\u3089\u5DEE\u5206\u30C1\u30A7\u30C3\u30AF\u304C\u6709\u52B9\u306B\u306A\u308A\u307E\u3059\u3002",
293
+ "mcp.queryRequired": '"{mode}" \u30E2\u30FC\u30C9\u3067\u306F query \u30D1\u30E9\u30E1\u30FC\u30BF\u304C\u5FC5\u9808\u3067\u3059',
294
+ // Analyze report
295
+ "analyze.title": "# \u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3\u5206\u6790\u30EC\u30DD\u30FC\u30C8\n",
296
+ "analyze.overview": "## \u6982\u8981",
297
+ "analyze.totalFiles": " \u7DCF\u30D5\u30A1\u30A4\u30EB\u6570: {count}",
298
+ "analyze.totalEdges": " \u7DCF\u30A8\u30C3\u30B8\u6570: {count}",
299
+ "analyze.totalCircular": " \u5FAA\u74B0\u53C2\u7167: {count}\u4EF6",
300
+ "analyze.criticalTitle": "\n## \u91CD\u8981\u30B3\u30F3\u30DD\u30FC\u30CD\u30F3\u30C8 (\u4E0A\u4F4D{count}\u4EF6)",
301
+ "analyze.criticalItem": " {path} ({count}\u4EF6\u304C\u4F9D\u5B58)",
302
+ "analyze.circularTitle": "\n## \u5FAA\u74B0\u53C2\u7167 ({count}\u4EF6)",
303
+ "analyze.circularItem": " {files}",
304
+ "analyze.orphanTitle": "\n## \u5B64\u7ACB\u30D5\u30A1\u30A4\u30EB ({count}\u4EF6)",
305
+ "analyze.couplingTitle": "\n## \u9AD8\u7D50\u5408\u30D5\u30A1\u30A4\u30EB (import\u6570 \u4E0A\u4F4D{count}\u4EF6)",
306
+ "analyze.couplingItem": " {path} ({count}\u4EF6\u3092import)",
307
+ "analyze.layerTitle": "\n## \u30C7\u30A3\u30EC\u30AF\u30C8\u30EA\u69CB\u6210",
308
+ "analyze.layerItem": " {dir}/ \u2014 {count}\u30D5\u30A1\u30A4\u30EB",
309
+ "analyze.noIssues": "\n\u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3\u4E0A\u306E\u554F\u984C\u306F\u691C\u51FA\u3055\u308C\u307E\u305B\u3093\u3067\u3057\u305F\u3002",
310
+ "analyze.snapshotSaved": "\n\u5206\u6790\u3068\u540C\u6642\u306B\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3092\u4FDD\u5B58\u3057\u307E\u3057\u305F\u3002",
311
+ // CI
312
+ "ci.generated": "GitHub Actions \u30EF\u30FC\u30AF\u30D5\u30ED\u30FC\u3092\u751F\u6210\u3057\u307E\u3057\u305F: {path}",
313
+ // Web viewer
314
+ "web.starting": "\u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3\u30D3\u30E5\u30FC\u30A2\u30FC\u3092\u8D77\u52D5\u4E2D...",
315
+ "web.listening": "\u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3\u30B0\u30E9\u30D5: http://localhost:{port}",
316
+ "web.stop": "Ctrl+C \u3067\u505C\u6B62",
317
+ "web.watching": "{dir}/ \u3092\u76E3\u8996\u4E2D...",
318
+ "web.reloading": "\u30D5\u30A1\u30A4\u30EB\u5909\u66F4\u3092\u691C\u51FA\u3001\u30EA\u30ED\u30FC\u30C9\u4E2D...",
319
+ "web.reloaded": "\u30B0\u30E9\u30D5\u3092\u66F4\u65B0\u3057\u307E\u3057\u305F",
320
+ // Errors
321
+ "error.analyzer": "[\u89E3\u6790\u30A8\u30E9\u30FC] {message}",
322
+ "error.storage": "[\u30B9\u30C8\u30EC\u30FC\u30B8\u30A8\u30E9\u30FC] {message}",
323
+ "error.pathTraversal": "[\u30BB\u30AD\u30E5\u30EA\u30C6\u30A3\u30A8\u30E9\u30FC] {message}",
324
+ "error.generic": "[\u30A8\u30E9\u30FC] {message}",
325
+ "error.unexpected": "[\u30A8\u30E9\u30FC] \u4E88\u671F\u3057\u306A\u3044\u30A8\u30E9\u30FC\u304C\u767A\u751F\u3057\u307E\u3057\u305F: {message}",
326
+ "error.cli.analyzer": "\u89E3\u6790\u30A8\u30E9\u30FC: {message}",
327
+ "error.cli.storage": "\u30B9\u30C8\u30EC\u30FC\u30B8\u30A8\u30E9\u30FC: {message}",
328
+ "error.cli.generic": "\u30A8\u30E9\u30FC: {message}",
329
+ "error.cli.unexpected": "\u4E88\u671F\u3057\u306A\u3044\u30A8\u30E9\u30FC: {message}"
330
+ };
331
+
332
+ // src/analyzer/report.ts
333
+ function formatAnalysisReport(graph, options = {}) {
334
+ const topN = options.topN ?? 10;
335
+ const lines = [];
336
+ const files = Object.values(graph.files);
337
+ lines.push(t("analyze.title"));
338
+ lines.push(t("analyze.overview"));
339
+ lines.push(t("analyze.totalFiles", { count: graph.totalFiles }));
340
+ lines.push(t("analyze.totalEdges", { count: graph.totalEdges }));
341
+ lines.push(t("analyze.totalCircular", { count: graph.circularDependencies.length }));
342
+ const critical = files.filter((f) => f.dependents.length > 0).sort((a, b) => b.dependents.length - a.dependents.length).slice(0, topN);
343
+ if (critical.length > 0) {
344
+ lines.push(t("analyze.criticalTitle", { count: critical.length }));
345
+ for (const f of critical) {
346
+ lines.push(t("analyze.criticalItem", { path: f.path, count: f.dependents.length }));
347
+ }
348
+ }
349
+ if (graph.circularDependencies.length > 0) {
350
+ lines.push(t("analyze.circularTitle", { count: graph.circularDependencies.length }));
351
+ for (const c of graph.circularDependencies) {
352
+ lines.push(t("analyze.circularItem", { files: c.cycle.join(" \u2192 ") }));
353
+ }
354
+ }
355
+ const highCoupling = files.filter((f) => f.dependencies.length > 0).sort((a, b) => b.dependencies.length - a.dependencies.length).slice(0, topN);
356
+ if (highCoupling.length > 0) {
357
+ lines.push(t("analyze.couplingTitle", { count: highCoupling.length }));
358
+ for (const f of highCoupling) {
359
+ lines.push(t("analyze.couplingItem", { path: f.path, count: f.dependencies.length }));
360
+ }
361
+ }
362
+ const orphans = files.filter(
363
+ (f) => f.dependents.length === 0 && f.dependencies.length === 0
364
+ );
365
+ if (orphans.length > 0) {
366
+ lines.push(t("analyze.orphanTitle", { count: orphans.length }));
367
+ for (const f of orphans) {
368
+ lines.push(` ${f.path}`);
369
+ }
370
+ }
371
+ const dirCounts = /* @__PURE__ */ new Map();
372
+ for (const f of files) {
373
+ const dir = f.path.includes("/") ? f.path.substring(0, f.path.lastIndexOf("/")) : ".";
374
+ dirCounts.set(dir, (dirCounts.get(dir) ?? 0) + 1);
375
+ }
376
+ if (dirCounts.size > 1) {
377
+ lines.push(t("analyze.layerTitle"));
378
+ const sorted = [...dirCounts.entries()].sort((a, b) => b[1] - a[1]);
379
+ for (const [dir, count] of sorted) {
380
+ lines.push(t("analyze.layerItem", { dir, count }));
381
+ }
382
+ }
383
+ if (graph.circularDependencies.length === 0 && orphans.length === 0) {
384
+ lines.push(t("analyze.noIssues"));
385
+ }
386
+ return lines.join("\n");
387
+ }
388
+
389
+ // src/storage/snapshot.ts
390
+ import { mkdir, writeFile, readFile, access } from "fs/promises";
391
+ import { join } from "path";
392
+ import { z } from "zod";
393
+
394
+ // src/types/schema.ts
395
+ var SCHEMA_VERSION = "1.0";
396
+
397
+ // src/storage/snapshot.ts
398
+ var ARCHTRACKER_DIR = ".archtracker";
399
+ var SNAPSHOT_FILE = "snapshot.json";
400
+ var FileNodeSchema = z.object({
401
+ path: z.string(),
402
+ exists: z.boolean(),
403
+ dependencies: z.array(z.string()),
404
+ dependents: z.array(z.string())
405
+ });
406
+ var DependencyGraphSchema = z.object({
407
+ rootDir: z.string(),
408
+ files: z.record(z.string(), FileNodeSchema),
409
+ edges: z.array(z.object({
410
+ source: z.string(),
411
+ target: z.string(),
412
+ type: z.enum(["static", "dynamic", "type-only"])
413
+ })),
414
+ circularDependencies: z.array(z.object({ cycle: z.array(z.string()) })),
415
+ totalFiles: z.number(),
416
+ totalEdges: z.number()
417
+ });
418
+ var SnapshotSchema = z.object({
419
+ version: z.literal(SCHEMA_VERSION),
420
+ timestamp: z.string(),
421
+ rootDir: z.string(),
422
+ graph: DependencyGraphSchema
423
+ });
424
+ async function saveSnapshot(projectRoot, graph) {
425
+ const dirPath = join(projectRoot, ARCHTRACKER_DIR);
426
+ const filePath = join(dirPath, SNAPSHOT_FILE);
427
+ const snapshot = {
428
+ version: SCHEMA_VERSION,
429
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
430
+ rootDir: graph.rootDir,
431
+ graph
432
+ };
433
+ await mkdir(dirPath, { recursive: true });
434
+ await writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf-8");
435
+ return snapshot;
436
+ }
437
+ async function loadSnapshot(projectRoot) {
438
+ const filePath = join(projectRoot, ARCHTRACKER_DIR, SNAPSHOT_FILE);
439
+ let raw;
440
+ try {
441
+ raw = await readFile(filePath, "utf-8");
442
+ } catch (error) {
443
+ if (isNodeError(error) && error.code === "ENOENT") {
444
+ return null;
445
+ }
446
+ throw new StorageError(
447
+ t("storage.readFailed", { path: filePath }),
448
+ { cause: error }
449
+ );
450
+ }
451
+ let parsed;
452
+ try {
453
+ parsed = JSON.parse(raw);
454
+ } catch {
455
+ throw new StorageError(
456
+ t("storage.parseFailed", { path: filePath })
457
+ );
458
+ }
459
+ const result = SnapshotSchema.safeParse(parsed);
460
+ if (!result.success) {
461
+ const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).slice(0, 5).join("\n");
462
+ throw new StorageError(
463
+ t("storage.invalidSchema", { issues })
464
+ );
465
+ }
466
+ return result.data;
467
+ }
468
+ var StorageError = class extends Error {
469
+ constructor(message, options) {
470
+ super(message, options);
471
+ this.name = "StorageError";
472
+ }
473
+ };
474
+ function isNodeError(error) {
475
+ return error instanceof Error && "code" in error;
476
+ }
477
+
478
+ // src/storage/diff.ts
479
+ function computeDiff(oldGraph, newGraph) {
480
+ const oldFiles = new Set(Object.keys(oldGraph.files));
481
+ const newFiles = new Set(Object.keys(newGraph.files));
482
+ const added = [...newFiles].filter((f) => !oldFiles.has(f));
483
+ const removed = [...oldFiles].filter((f) => !newFiles.has(f));
484
+ const modified = [];
485
+ for (const file of newFiles) {
486
+ if (!oldFiles.has(file)) continue;
487
+ const oldDeps = oldGraph.files[file].dependencies.slice().sort();
488
+ const newDeps = newGraph.files[file].dependencies.slice().sort();
489
+ if (!arraysEqual(oldDeps, newDeps)) {
490
+ modified.push(file);
491
+ }
492
+ }
493
+ const removedSet = new Set(removed);
494
+ const changedFiles = /* @__PURE__ */ new Set([...removed, ...modified]);
495
+ const affectedDependents = [];
496
+ const seenAffected = /* @__PURE__ */ new Set();
497
+ for (const changedFile of changedFiles) {
498
+ const graph = removedSet.has(changedFile) ? oldGraph : newGraph;
499
+ const node = graph.files[changedFile];
500
+ if (!node) continue;
501
+ for (const dependent of node.dependents) {
502
+ const key = `${dependent}\u2192${changedFile}`;
503
+ if (seenAffected.has(key)) continue;
504
+ seenAffected.add(key);
505
+ const reason = removedSet.has(changedFile) ? t("diff.reasonRemoved", { file: changedFile }) : t("diff.reasonModified", { file: changedFile });
506
+ affectedDependents.push({
507
+ file: dependent,
508
+ reason,
509
+ dependsOn: changedFile
510
+ });
511
+ }
512
+ }
513
+ for (const addedFile of added) {
514
+ const node = newGraph.files[addedFile];
515
+ if (!node) continue;
516
+ for (const dependent of node.dependents) {
517
+ const key = `${dependent}\u2192${addedFile}`;
518
+ if (seenAffected.has(key)) continue;
519
+ seenAffected.add(key);
520
+ affectedDependents.push({
521
+ file: dependent,
522
+ reason: t("diff.reasonAdded", { file: addedFile }),
523
+ dependsOn: addedFile
524
+ });
525
+ }
526
+ }
527
+ return { added, removed, modified, affectedDependents };
528
+ }
529
+ function formatDiffReport(diff) {
530
+ const lines = [];
531
+ lines.push(t("diff.title"));
532
+ if (diff.added.length === 0 && diff.removed.length === 0 && diff.modified.length === 0) {
533
+ lines.push(t("diff.noChanges"));
534
+ return lines.join("\n");
535
+ }
536
+ if (diff.added.length > 0) {
537
+ lines.push(t("diff.added", { count: diff.added.length }));
538
+ for (const f of diff.added) {
539
+ lines.push(` + ${f}`);
540
+ }
541
+ lines.push("");
542
+ }
543
+ if (diff.removed.length > 0) {
544
+ lines.push(t("diff.removed", { count: diff.removed.length }));
545
+ for (const f of diff.removed) {
546
+ lines.push(` - ${f}`);
547
+ }
548
+ lines.push("");
549
+ }
550
+ if (diff.modified.length > 0) {
551
+ lines.push(t("diff.modified", { count: diff.modified.length }));
552
+ for (const f of diff.modified) {
553
+ lines.push(` ~ ${f}`);
554
+ }
555
+ lines.push("");
556
+ }
557
+ if (diff.affectedDependents.length > 0) {
558
+ lines.push(t("diff.affected", { count: diff.affectedDependents.length }));
559
+ for (const a of diff.affectedDependents) {
560
+ lines.push(` ! ${a.file}`);
561
+ lines.push(` ${a.reason}`);
562
+ }
563
+ lines.push("");
564
+ }
565
+ return lines.join("\n");
566
+ }
567
+ function arraysEqual(a, b) {
568
+ if (a.length !== b.length) return false;
569
+ for (let i = 0; i < a.length; i++) {
570
+ if (a[i] !== b[i]) return false;
571
+ }
572
+ return true;
573
+ }
574
+
575
+ // src/web/server.ts
576
+ import { createServer } from "http";
577
+
578
+ // src/web/template.ts
579
+ function buildGraphPage(graph, options = {}) {
580
+ const locale = options.locale ?? "en";
581
+ const diff = options.diff ?? null;
582
+ const files = Object.values(graph.files);
583
+ const nodes = files.map((f) => ({
584
+ id: f.path,
585
+ deps: f.dependencies.length,
586
+ dependents: f.dependents.length,
587
+ dependencies: f.dependencies,
588
+ dependentsList: f.dependents,
589
+ isOrphan: f.dependencies.length === 0 && f.dependents.length === 0,
590
+ dir: f.path.includes("/") ? f.path.substring(0, f.path.lastIndexOf("/")) : "."
591
+ }));
592
+ const links = graph.edges.map((e) => ({
593
+ source: e.source,
594
+ target: e.target,
595
+ type: e.type
596
+ }));
597
+ const circularFiles = /* @__PURE__ */ new Set();
598
+ for (const c of graph.circularDependencies) {
599
+ for (const f of c.cycle) circularFiles.add(f);
600
+ }
601
+ const dirs = [...new Set(nodes.map((n) => n.dir))].sort();
602
+ const projectName = graph.rootDir.split("/").filter(Boolean).pop() || "Project";
603
+ const diffData = diff ? JSON.stringify(diff) : "null";
604
+ const graphData = JSON.stringify({ nodes, links, circularFiles: [...circularFiles], dirs, projectName });
605
+ return (
606
+ /* html */
607
+ `<!DOCTYPE html>
608
+ <html lang="${locale}">
609
+ <head>
610
+ <meta charset="utf-8">
611
+ <meta name="viewport" content="width=device-width, initial-scale=1">
612
+ <title>${projectName} \u2014 Architecture Viewer</title>
613
+ <style>
614
+ :root {
615
+ --bg: #0d1117; --bg-card: #161b22; --bg-hover: #1c2129;
616
+ --border: #30363d; --border-active: #58a6ff;
617
+ --text: #c9d1d9; --text-dim: #8b949e; --text-muted: #484f58;
618
+ --accent: #58a6ff; --green: #3fb950; --red: #f97583; --yellow: #f0e68c;
619
+ --radius: 8px; --font-size: 13px;
620
+ }
621
+ [data-theme="light"] {
622
+ --bg: #ffffff; --bg-card: #f6f8fa; --bg-hover: #eef1f5;
623
+ --border: #d0d7de; --border-active: #0969da;
624
+ --text: #1f2328; --text-dim: #656d76; --text-muted: #8b949e;
625
+ --accent: #0969da; --green: #1a7f37; --red: #cf222e; --yellow: #9a6700;
626
+ }
627
+ * { margin: 0; padding: 0; box-sizing: border-box; }
628
+ body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; font-size: 13px; overflow: hidden; transition: background 0.3s, color 0.3s; }
629
+
630
+ /* \u2500\u2500\u2500 Tab bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
631
+ #tab-bar { position: fixed; top: 0; left: 0; right: 0; height: 44px; background: var(--bg-card); border-bottom: 1px solid var(--border); display: flex; align-items: center; z-index: 30; padding: 0 16px; gap: 2px; transition: background 0.3s; }
632
+ #tab-bar .logo { font-weight: 700; font-size: 14px; color: var(--accent); margin-right: 16px; letter-spacing: -0.3px; outline: none; border-bottom: 1px dashed transparent; cursor: text; min-width: 40px; }
633
+ #tab-bar .logo:hover { border-bottom-color: var(--text-muted); }
634
+ #tab-bar .logo:focus { border-bottom-color: var(--accent); }
635
+ .tab { padding: 8px 16px; font-size: 13px; color: var(--text-dim); cursor: pointer; border-radius: 6px 6px 0 0; border: 1px solid transparent; border-bottom: none; transition: all 0.15s; user-select: none; position: relative; top: 1px; }
636
+ .tab:hover { color: var(--text); background: var(--bg-hover); }
637
+ .tab.active { color: var(--text); background: var(--bg); border-color: var(--border); }
638
+ .tab-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
639
+ .tab-stats { font-size: 12px; color: var(--text-muted); display: flex; gap: 14px; }
640
+ .tab-stats span b { color: var(--text-dim); }
641
+ .settings-btn { background: none; border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; cursor: pointer; color: var(--text-dim); font-size: 16px; transition: all 0.15s; }
642
+ .settings-btn:hover { border-color: var(--accent); color: var(--text); }
643
+
644
+ /* \u2500\u2500\u2500 Views \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
645
+ .view { position: fixed; top: 44px; left: 0; right: 0; bottom: 0; display: none; }
646
+ .view.active { display: block; }
647
+ .view svg { width: 100%; height: 100%; }
648
+
649
+ /* \u2500\u2500\u2500 HUD \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
650
+ #hud { position: absolute; top: 12px; left: 12px; z-index: 10; display: flex; flex-direction: column; gap: 8px; }
651
+ .hud-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; font-size: 12px; backdrop-filter: blur(8px); transition: background 0.3s; }
652
+ #search-box { display: flex; align-items: center; gap: 8px; }
653
+ #search-box input { background: transparent; border: none; outline: none; color: var(--text); font-size: 13px; width: 180px; }
654
+ #search-box input::placeholder { color: var(--text-muted); }
655
+ kbd { background: #21262d; border: 1px solid var(--border); border-radius: 3px; padding: 1px 5px; font-size: 10px; color: var(--text-muted); font-family: inherit; }
656
+ .legend-item { display: flex; align-items: center; gap: 6px; margin: 3px 0; color: var(--text-dim); }
657
+ .legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
658
+
659
+ /* \u2500\u2500\u2500 Tooltip (interactive \u2014 mouse can enter) \u2500 */
660
+ #tooltip { position: fixed; display: none; background: var(--bg-card); border: 1px solid var(--accent); border-radius: var(--radius); padding: 14px 16px; font-size: 13px; z-index: 40; max-width: 420px; pointer-events: auto; box-shadow: 0 8px 24px rgba(0,0,0,0.5); transition: background 0.3s; }
661
+ #tooltip .tt-name { color: var(--accent); font-size: 14px; font-weight: 600; margin-bottom: 8px; word-break: break-all; }
662
+ #tooltip .tt-badge { display: inline-block; background: var(--bg-hover); border-radius: 10px; padding: 1px 8px; font-size: 11px; margin: 0 2px; }
663
+ #tooltip .tt-section { margin-top: 8px; font-size: 12px; color: var(--text-dim); max-height: 140px; overflow-y: auto; }
664
+ #tooltip .tt-section div { padding: 2px 0; }
665
+ #tooltip .tt-out { color: var(--accent); }
666
+ #tooltip .tt-in { color: var(--green); }
667
+
668
+ /* \u2500\u2500\u2500 Filters \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
669
+ #filters { position: absolute; bottom: 12px; left: 12px; right: 120px; z-index: 10; display: flex; flex-wrap: wrap; gap: 5px; }
670
+ .filter-pill { background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; padding: 3px 10px; font-size: 11px; cursor: pointer; user-select: none; transition: all 0.15s; display: flex; align-items: center; gap: 5px; }
671
+ .filter-pill:hover { border-color: var(--text-dim); }
672
+ .filter-pill.active { border-color: var(--accent); }
673
+ .filter-pill .pill-dot { width: 6px; height: 6px; border-radius: 50%; }
674
+ .filter-pill .pill-count { color: var(--text-muted); font-size: 10px; }
675
+
676
+ /* \u2500\u2500\u2500 Zoom controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
677
+ #zoom-ctrl { position: absolute; bottom: 52px; right: 12px; z-index: 10; display: flex; flex-direction: column; gap: 2px; }
678
+ #zoom-ctrl button { width: 32px; height: 32px; background: var(--bg-card); border: 1px solid var(--border); color: var(--text-dim); border-radius: 6px; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; transition: all 0.1s; }
679
+ #zoom-ctrl button:hover { background: var(--bg-hover); color: var(--text); }
680
+
681
+ /* \u2500\u2500\u2500 Detail panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
682
+ #detail { position: absolute; top: 12px; right: 12px; width: 280px; z-index: 10; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; font-size: 13px; display: none; max-height: calc(100vh - 100px); overflow-y: auto; transition: background 0.3s; }
683
+ #detail.open { display: block; }
684
+ #detail .detail-name { color: var(--accent); font-weight: 600; font-size: 14px; word-break: break-all; margin-bottom: 8px; }
685
+ #detail .detail-meta { color: var(--text-dim); margin-bottom: 12px; }
686
+ #detail .detail-section { margin-top: 10px; }
687
+ #detail .detail-section h4 { font-size: 11px; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px; margin-bottom: 4px; }
688
+ #detail .detail-list { list-style: none; }
689
+ #detail .detail-list li { padding: 3px 0; font-size: 12px; color: var(--text-dim); cursor: pointer; }
690
+ #detail .detail-list li:hover { color: var(--accent); }
691
+ #detail .close-btn { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 16px; }
692
+
693
+ /* \u2500\u2500\u2500 Settings panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
694
+ #settings-panel { position: fixed; top: 44px; right: 0; width: 280px; height: calc(100vh - 44px); background: var(--bg-card); border-left: 1px solid var(--border); z-index: 25; padding: 20px; transform: translateX(100%); transition: transform 0.25s ease, background 0.3s; overflow-y: auto; }
695
+ #settings-panel.open { transform: translateX(0); }
696
+ #settings-panel h3 { font-size: 14px; color: var(--text); margin-bottom: 16px; }
697
+ .setting-group { margin-bottom: 18px; }
698
+ .setting-group label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; }
699
+ .setting-group select, .setting-group input[type=range] { width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 6px 8px; font-size: 13px; }
700
+ .setting-group input[type=range] { padding: 4px 0; border: none; accent-color: var(--accent); }
701
+ .setting-value { font-size: 11px; color: var(--text-muted); text-align: right; }
702
+ .theme-toggle { display: flex; gap: 6px; }
703
+ .theme-btn { flex: 1; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; text-align: center; font-size: 12px; color: var(--text-dim); transition: all 0.15s; }
704
+ .theme-btn:hover { border-color: var(--text-dim); }
705
+ .theme-btn.active { border-color: var(--accent); color: var(--accent); }
706
+
707
+ /* \u2500\u2500\u2500 Hierarchy detail panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
708
+ #hier-detail { position: absolute; top: 12px; right: 12px; width: 280px; z-index: 10; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; font-size: 13px; display: none; max-height: calc(100vh - 100px); overflow-y: auto; transition: background 0.3s; }
709
+ #hier-detail.open { display: block; }
710
+ #hier-detail .detail-name { color: var(--accent); font-weight: 600; font-size: 14px; word-break: break-all; margin-bottom: 8px; }
711
+ #hier-detail .detail-meta { color: var(--text-dim); margin-bottom: 12px; }
712
+ #hier-detail .detail-section { margin-top: 10px; }
713
+ #hier-detail .detail-section h4 { font-size: 11px; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px; margin-bottom: 4px; }
714
+ #hier-detail .detail-list { list-style: none; }
715
+ #hier-detail .detail-list li { padding: 3px 0; font-size: 12px; color: var(--text-dim); cursor: pointer; }
716
+ #hier-detail .detail-list li:hover { color: var(--accent); }
717
+ #hier-detail .close-btn { position: absolute; top: 8px; right: 10px; background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 16px; }
718
+
719
+ /* \u2500\u2500\u2500 Hierarchy \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
720
+ .hier-node { cursor: pointer; }
721
+ .hier-node rect { rx: 6; ry: 6; stroke-width: 1.5; transition: stroke 0.15s; }
722
+ .hier-node:hover rect { stroke: var(--accent) !important; stroke-width: 2; }
723
+ .hier-node text { fill: var(--text); pointer-events: none; }
724
+ .hier-link { fill: none; stroke: var(--border); stroke-width: 1; }
725
+ .hier-layer-label { fill: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
726
+
727
+ /* \u2500\u2500\u2500 Impact mode \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
728
+ #impact-btn.active { background: var(--accent) !important; color: #fff !important; border-color: var(--accent) !important; }
729
+ #impact-badge { position: absolute; bottom: 52px; left: 12px; z-index: 10; display: none; background: var(--accent); color: #fff; font-size: 12px; font-weight: 600; padding: 6px 12px; border-radius: var(--radius); }
730
+
731
+ /* \u2500\u2500\u2500 Help bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
732
+ #help-bar { position: absolute; bottom: 12px; right: 12px; z-index: 10; font-size: 11px; color: var(--text-muted); background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 10px; transition: background 0.3s; }
733
+ </style>
734
+ </head>
735
+ <body>
736
+
737
+ <!-- Tab bar -->
738
+ <div id="tab-bar">
739
+ <span class="logo" id="project-title" contenteditable="true" spellcheck="false" title="Click to edit project name"></span>
740
+ <div class="tab active" data-view="graph-view" data-i18n="tab.graph">Graph</div>
741
+ <div class="tab" data-view="hier-view" data-i18n="tab.hierarchy">Hierarchy</div>
742
+ <div class="tab" data-view="diff-view" id="diff-tab" style="display:none" data-i18n="tab.diff">Diff</div>
743
+ <div class="tab-right">
744
+ <div class="tab-stats">
745
+ <span><span data-i18n="stats.files">Files</span> <b id="s-files">0</b></span>
746
+ <span><span data-i18n="stats.edges">Edges</span> <b id="s-edges">0</b></span>
747
+ <span><span data-i18n="stats.circular">Circular</span> <b id="s-circular">0</b></span>
748
+ </div>
749
+ <button class="settings-btn" onclick="toggleSettings()" title="Settings">\u2699</button>
750
+ </div>
751
+ </div>
752
+
753
+ <!-- Settings panel -->
754
+ <div id="settings-panel">
755
+ <h3 data-i18n="settings.title">Settings</h3>
756
+ <div class="setting-group">
757
+ <label data-i18n="settings.theme">Theme</label>
758
+ <div class="theme-toggle">
759
+ <div class="theme-btn active" data-theme-val="dark" onclick="setTheme('dark')">\u{1F319} Dark</div>
760
+ <div class="theme-btn" data-theme-val="light" onclick="setTheme('light')">\u2600\uFE0F Light</div>
761
+ </div>
762
+ </div>
763
+ <div class="setting-group">
764
+ <label data-i18n="settings.fontSize">Font Size</label>
765
+ <input type="range" id="font-size-slider" min="10" max="18" value="13" oninput="setFontSize(this.value)">
766
+ <div class="setting-value"><span id="font-size-val">13</span>px</div>
767
+ </div>
768
+ <div class="setting-group">
769
+ <label data-i18n="settings.nodeSize">Node Size</label>
770
+ <input type="range" id="node-size-slider" min="50" max="200" value="100" oninput="setNodeScale(this.value)">
771
+ <div class="setting-value"><span id="node-size-val">100</span>%</div>
772
+ </div>
773
+ <div class="setting-group">
774
+ <label data-i18n="settings.linkOpacity">Link Opacity</label>
775
+ <input type="range" id="link-opacity-slider" min="10" max="100" value="40" oninput="setLinkOpacity(this.value)">
776
+ <div class="setting-value"><span id="link-opacity-val">40</span>%</div>
777
+ </div>
778
+ <div class="setting-group">
779
+ <label data-i18n="settings.gravity">Gravity</label>
780
+ <input type="range" id="gravity-slider" min="10" max="500" value="150" oninput="setGravity(this.value)">
781
+ <div class="setting-value"><span id="gravity-val">150</span></div>
782
+ </div>
783
+ <div class="setting-group">
784
+ <label data-i18n="settings.language">Language</label>
785
+ <div class="theme-toggle">
786
+ <div class="theme-btn lang-btn" data-lang="en" onclick="setLang('en')">English</div>
787
+ <div class="theme-btn lang-btn" data-lang="ja" onclick="setLang('ja')">\u65E5\u672C\u8A9E</div>
788
+ </div>
789
+ </div>
790
+ <div class="setting-group" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
791
+ <label data-i18n="settings.export">Export</label>
792
+ <div class="theme-toggle">
793
+ <div class="theme-btn" onclick="exportSVG()">SVG</div>
794
+ <div class="theme-btn" onclick="exportPNG()">PNG</div>
795
+ </div>
796
+ </div>
797
+ </div>
798
+
799
+ <!-- Graph View -->
800
+ <div id="graph-view" class="view active">
801
+ <svg id="graph-svg"></svg>
802
+ <div id="hud">
803
+ <div class="hud-panel" id="search-box">
804
+ <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" style="color:var(--text-muted)"><path d="M11.5 7a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Zm-.82 4.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04Z"/></svg>
805
+ <input id="search" type="text" data-i18n-placeholder="search.placeholder" placeholder="Search files..." autocomplete="off">
806
+ <kbd>/</kbd>
807
+ </div>
808
+ <div class="hud-panel" id="legend-panel">
809
+ <div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div> <span data-i18n="legend.circular">Circular dep</span></div>
810
+ <div class="legend-item"><div class="legend-dot" style="background:var(--text-muted)"></div> <span data-i18n="legend.orphan">Orphan</span></div>
811
+ <div class="legend-item"><div class="legend-dot" style="border:2px solid var(--yellow);width:6px;height:6px"></div> <span data-i18n="legend.highCoupling">High coupling</span></div>
812
+ <div class="legend-item" style="margin-top:4px;font-size:11px;gap:3px"><span style="color:var(--accent)">\u2014\u2192</span> <span data-i18n="legend.imports">imports</span> <span style="margin-left:6px;color:var(--green)">\u2190\u2014</span> <span data-i18n="legend.importedBy">imported by</span></div>
813
+ </div>
814
+ </div>
815
+ <div id="detail">
816
+ <button class="close-btn" onclick="closeDetail()">\u2715</button>
817
+ <div class="detail-name" id="d-name"></div>
818
+ <div class="detail-meta" id="d-meta"></div>
819
+ <div class="detail-section"><h4 data-i18n="detail.importedBy">Imported by</h4><ul class="detail-list" id="d-dependents"></ul></div>
820
+ <div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="d-deps"></ul></div>
821
+ </div>
822
+ <div id="filters"></div>
823
+ <div id="zoom-ctrl">
824
+ <button onclick="zoomIn()" title="Zoom in">+</button>
825
+ <button onclick="zoomOut()" title="Zoom out">\u2212</button>
826
+ <button onclick="zoomFit()" title="Fit">\u22A1</button>
827
+ <button id="impact-btn" onclick="toggleImpactMode()" title="Impact simulation" style="font-size:12px;margin-top:4px" data-i18n="impact.btn">Impact</button>
828
+ </div>
829
+ <div id="impact-badge"></div>
830
+ <div id="help-bar" data-i18n="help.graph">Scroll: zoom \xB7 Drag: pan \xB7 Click: select \xB7 / search</div>
831
+ </div>
832
+
833
+ <!-- Hierarchy View -->
834
+ <div id="hier-view" class="view">
835
+ <svg id="hier-svg"></svg>
836
+ <div id="hier-hud" style="position:absolute;top:12px;left:12px;z-index:10;display:flex;flex-direction:column;gap:8px;">
837
+ <div class="hud-panel" id="hier-legend">
838
+ <div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div> <span data-i18n="legend.circular">Circular dep</span></div>
839
+ <div class="legend-item"><div class="legend-dot" style="background:var(--text-muted)"></div> <span data-i18n="legend.orphan">Orphan</span></div>
840
+ <div class="legend-item"><div class="legend-dot" style="border:2px solid var(--yellow);width:6px;height:6px"></div> <span data-i18n="legend.highCoupling">High coupling</span></div>
841
+ </div>
842
+ </div>
843
+ <div id="hier-detail">
844
+ <button class="close-btn" onclick="closeHierDetail()">\u2715</button>
845
+ <div class="detail-name" id="hd-name"></div>
846
+ <div class="detail-meta" id="hd-meta"></div>
847
+ <div class="detail-section"><h4 data-i18n="detail.importedBy">Imported by</h4><ul class="detail-list" id="hd-dependents"></ul></div>
848
+ <div class="detail-section"><h4 data-i18n="detail.imports">Imports</h4><ul class="detail-list" id="hd-deps"></ul></div>
849
+ </div>
850
+ <div id="hier-filters" style="position:absolute;bottom:42px;left:12px;right:120px;z-index:10;display:flex;flex-wrap:wrap;gap:5px;"></div>
851
+ <div id="help-bar" style="position:absolute" data-i18n="help.hierarchy">Scroll to navigate \xB7 Click to highlight</div>
852
+ </div>
853
+
854
+ <!-- Diff View -->
855
+ <div id="diff-view" class="view">
856
+ <svg id="diff-svg"></svg>
857
+ <div id="diff-legend" style="position:absolute;top:12px;left:12px;z-index:10;">
858
+ <div class="hud-panel">
859
+ <div class="legend-item"><div class="legend-dot" style="background:var(--green)"></div> <span data-i18n="diff.addedLabel">Added</span></div>
860
+ <div class="legend-item"><div class="legend-dot" style="background:var(--red)"></div> <span data-i18n="diff.removedLabel">Removed</span></div>
861
+ <div class="legend-item"><div class="legend-dot" style="background:var(--yellow)"></div> <span data-i18n="diff.modifiedLabel">Modified</span></div>
862
+ <div class="legend-item"><div class="legend-dot" style="background:var(--accent)"></div> <span data-i18n="diff.affectedLabel">Affected</span></div>
863
+ </div>
864
+ </div>
865
+ <div id="help-bar" style="position:absolute" data-i18n="help.diff">Green=added \xB7 Red=removed \xB7 Yellow=modified \xB7 Blue=affected</div>
866
+ </div>
867
+
868
+ <!-- Tooltip (shared, interactive) -->
869
+ <div id="tooltip">
870
+ <div class="tt-name" id="tt-name"></div>
871
+ <div>
872
+ <span class="tt-badge tt-out" id="tt-dep-count"></span> <span data-i18n="tooltip.imports">imports</span>
873
+ <span class="tt-badge tt-in" id="tt-dpt-count" style="margin-left:6px"></span> <span data-i18n="tooltip.importedBy">imported by</span>
874
+ </div>
875
+ <div class="tt-section" id="tt-details"></div>
876
+ </div>
877
+
878
+ <script src="https://d3js.org/d3.v7.min.js"></script>
879
+ <script>
880
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
881
+ // i18n
882
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
883
+ const I18N = {
884
+ en: {
885
+ 'tab.graph': 'Graph', 'tab.hierarchy': 'Hierarchy',
886
+ 'stats.files': 'Files', 'stats.edges': 'Edges', 'stats.circular': 'Circular',
887
+ 'settings.title': 'Settings', 'settings.theme': 'Theme', 'settings.fontSize': 'Font Size',
888
+ 'settings.nodeSize': 'Node Size', 'settings.linkOpacity': 'Link Opacity', 'settings.gravity': 'Gravity', 'settings.language': 'Language', 'settings.export': 'Export',
889
+ 'impact.title': 'Impact Simulation', 'impact.btn': 'Impact', 'impact.transitive': 'files affected',
890
+ 'search.placeholder': 'Search files...',
891
+ 'legend.circular': 'Circular dep', 'legend.orphan': 'Orphan', 'legend.highCoupling': 'High coupling',
892
+ 'legend.imports': 'imports', 'legend.importedBy': 'imported by',
893
+ 'detail.importedBy': 'Imported by', 'detail.imports': 'Imports',
894
+ 'detail.none': 'none', 'detail.dir': 'Dir', 'detail.dependencies': 'Dependencies', 'detail.dependents': 'Dependents',
895
+ 'tooltip.imports': 'imports', 'tooltip.importedBy': 'imported by',
896
+ 'help.graph': 'Scroll: zoom \xB7 Drag: pan \xB7 Click: select \xB7 / search',
897
+ 'help.hierarchy': 'Scroll to navigate \xB7 Click to highlight',
898
+ 'help.diff': 'Green=added \xB7 Red=removed \xB7 Yellow=modified \xB7 Blue=affected',
899
+ 'tab.diff': 'Diff',
900
+ 'diff.addedLabel': 'Added', 'diff.removedLabel': 'Removed', 'diff.modifiedLabel': 'Modified', 'diff.affectedLabel': 'Affected',
901
+ },
902
+ ja: {
903
+ 'tab.graph': '\u30B0\u30E9\u30D5', 'tab.hierarchy': '\u968E\u5C64\u56F3',
904
+ 'stats.files': '\u30D5\u30A1\u30A4\u30EB', 'stats.edges': '\u30A8\u30C3\u30B8', 'stats.circular': '\u5FAA\u74B0\u53C2\u7167',
905
+ 'settings.title': '\u8A2D\u5B9A', 'settings.theme': '\u30C6\u30FC\u30DE', 'settings.fontSize': '\u30D5\u30A9\u30F3\u30C8\u30B5\u30A4\u30BA',
906
+ 'settings.nodeSize': '\u30CE\u30FC\u30C9\u30B5\u30A4\u30BA', 'settings.linkOpacity': '\u30EA\u30F3\u30AF\u900F\u660E\u5EA6', 'settings.gravity': '\u91CD\u529B', 'settings.language': '\u8A00\u8A9E', 'settings.export': '\u30A8\u30AF\u30B9\u30DD\u30FC\u30C8',
907
+ 'impact.title': '\u5F71\u97FF\u7BC4\u56F2\u30B7\u30DF\u30E5\u30EC\u30FC\u30B7\u30E7\u30F3', 'impact.btn': '\u5F71\u97FF', 'impact.transitive': '\u30D5\u30A1\u30A4\u30EB\u306B\u5F71\u97FF',
908
+ 'search.placeholder': '\u30D5\u30A1\u30A4\u30EB\u691C\u7D22...',
909
+ 'legend.circular': '\u5FAA\u74B0\u53C2\u7167', 'legend.orphan': '\u5B64\u7ACB', 'legend.highCoupling': '\u9AD8\u7D50\u5408',
910
+ 'legend.imports': 'import\u5148', 'legend.importedBy': 'import\u5143',
911
+ 'detail.importedBy': 'import\u5143', 'detail.imports': 'import\u5148',
912
+ 'detail.none': '\u306A\u3057', 'detail.dir': '\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA', 'detail.dependencies': '\u4F9D\u5B58\u5148', 'detail.dependents': '\u88AB\u4F9D\u5B58',
913
+ 'tooltip.imports': 'import\u5148', 'tooltip.importedBy': 'import\u5143',
914
+ 'help.graph': '\u30B9\u30AF\u30ED\u30FC\u30EB: \u30BA\u30FC\u30E0 \xB7 \u30C9\u30E9\u30C3\u30B0: \u79FB\u52D5 \xB7 \u30AF\u30EA\u30C3\u30AF: \u9078\u629E \xB7 / \u691C\u7D22',
915
+ 'help.hierarchy': '\u30B9\u30AF\u30ED\u30FC\u30EB\u3067\u79FB\u52D5 \xB7 \u30AF\u30EA\u30C3\u30AF\u3067\u30CF\u30A4\u30E9\u30A4\u30C8',
916
+ 'help.diff': '\u7DD1=\u8FFD\u52A0 \xB7 \u8D64=\u524A\u9664 \xB7 \u9EC4=\u5909\u66F4 \xB7 \u9752=\u5F71\u97FF',
917
+ 'tab.diff': '\u5DEE\u5206',
918
+ 'diff.addedLabel': '\u8FFD\u52A0', 'diff.removedLabel': '\u524A\u9664', 'diff.modifiedLabel': '\u5909\u66F4', 'diff.affectedLabel': '\u5F71\u97FF',
919
+ }
920
+ };
921
+ let currentLang = '${locale}';
922
+ function applyI18n() {
923
+ const msgs = I18N[currentLang] || I18N.en;
924
+ document.querySelectorAll('[data-i18n]').forEach(el => {
925
+ const key = el.getAttribute('data-i18n');
926
+ if (msgs[key]) el.textContent = msgs[key];
927
+ });
928
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
929
+ const key = el.getAttribute('data-i18n-placeholder');
930
+ if (msgs[key]) el.placeholder = msgs[key];
931
+ });
932
+ document.querySelectorAll('.lang-btn').forEach(b => b.classList.toggle('active', b.dataset.lang === currentLang));
933
+ }
934
+ window.setLang = (lang) => { currentLang = lang; applyI18n(); saveSettings(); };
935
+ function i(key) { return (I18N[currentLang] || I18N.en)[key] || key; }
936
+
937
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
938
+ // SETTINGS (persisted to localStorage)
939
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
940
+ const STORAGE_KEY = 'archtracker-settings';
941
+ function saveSettings() {
942
+ const s = { theme: document.body.getAttribute('data-theme') || 'dark', fontSize: document.getElementById('font-size-val').textContent, nodeSize: document.getElementById('node-size-val').textContent, linkOpacity: document.getElementById('link-opacity-val').textContent, gravity: document.getElementById('gravity-val').textContent, lang: currentLang, projectTitle: document.getElementById('project-title').textContent };
943
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch(e) {}
944
+ }
945
+ function loadSettings() {
946
+ try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || null; } catch(e) { return null; }
947
+ }
948
+
949
+ let nodeScale = 1, baseLinkOpacity = 0.4;
950
+ window.toggleSettings = () => document.getElementById('settings-panel').classList.toggle('open');
951
+ window.setTheme = (theme) => {
952
+ document.body.setAttribute('data-theme', theme === 'light' ? 'light' : '');
953
+ document.querySelectorAll('.theme-btn[data-theme-val]').forEach(b => b.classList.toggle('active', b.dataset.themeVal === theme));
954
+ saveSettings();
955
+ };
956
+ window.setFontSize = (v) => {
957
+ document.getElementById('font-size-val').textContent = v;
958
+ const scale = v / 13;
959
+ if (typeof node !== 'undefined') {
960
+ node.select('text').attr('font-size', d => (d.dependents>=3?12:10) * scale);
961
+ }
962
+ saveSettings();
963
+ };
964
+ window.setNodeScale = (v) => {
965
+ nodeScale = v / 100;
966
+ document.getElementById('node-size-val').textContent = v;
967
+ if (typeof node !== 'undefined') {
968
+ node.select('circle').attr('r', d => nodeRadius(d) * nodeScale);
969
+ node.select('text').attr('dx', d => nodeRadius(d) * nodeScale + 4);
970
+ simulation.force('collision', d3.forceCollide().radius(d => nodeRadius(d) * nodeScale + 4));
971
+ simulation.alpha(0.3).restart();
972
+ }
973
+ saveSettings();
974
+ };
975
+ window.setLinkOpacity = (v) => {
976
+ baseLinkOpacity = v / 100;
977
+ document.getElementById('link-opacity-val').textContent = v;
978
+ if (typeof link !== 'undefined') link.attr('opacity', baseLinkOpacity);
979
+ saveSettings();
980
+ };
981
+ let gravityStrength = 150;
982
+ window.setGravity = (v) => {
983
+ gravityStrength = +v;
984
+ document.getElementById('gravity-val').textContent = v;
985
+ if (typeof simulation !== 'undefined') {
986
+ simulation.force('charge', d3.forceManyBody().strength(-gravityStrength).distanceMax(500));
987
+ simulation.alpha(0.5).restart();
988
+ }
989
+ saveSettings();
990
+ };
991
+
992
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
993
+ // EXPORT
994
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
995
+ window.exportSVG = () => {
996
+ const activeView = document.querySelector('.view.active svg');
997
+ if (!activeView) return;
998
+ const clone = activeView.cloneNode(true);
999
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
1000
+ const blob = new Blob([clone.outerHTML], {type: 'image/svg+xml'});
1001
+ const a = document.createElement('a');
1002
+ a.href = URL.createObjectURL(blob);
1003
+ a.download = (document.getElementById('project-title').textContent || 'graph') + '.svg';
1004
+ a.click(); URL.revokeObjectURL(a.href);
1005
+ };
1006
+ window.exportPNG = () => {
1007
+ const activeView = document.querySelector('.view.active svg');
1008
+ if (!activeView) return;
1009
+ const clone = activeView.cloneNode(true);
1010
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
1011
+ const svgStr = new XMLSerializer().serializeToString(clone);
1012
+ const canvas = document.createElement('canvas');
1013
+ const bbox = activeView.getBoundingClientRect();
1014
+ canvas.width = bbox.width * 2; canvas.height = bbox.height * 2;
1015
+ const ctx = canvas.getContext('2d');
1016
+ ctx.scale(2, 2);
1017
+ const img = new Image();
1018
+ img.onload = () => { ctx.drawImage(img, 0, 0); const a = document.createElement('a'); a.href = canvas.toDataURL('image/png'); a.download = (document.getElementById('project-title').textContent || 'graph') + '.png'; a.click(); };
1019
+ img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgStr)));
1020
+ };
1021
+
1022
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1023
+ // DATA
1024
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1025
+ const DATA = ${graphData};
1026
+ const W = window.innerWidth, H = window.innerHeight - 44;
1027
+ const circularSet = new Set(DATA.circularFiles);
1028
+
1029
+ // Project title (editable)
1030
+ const titleEl = document.getElementById('project-title');
1031
+ titleEl.textContent = DATA.projectName;
1032
+ titleEl.addEventListener('blur', () => { if (!titleEl.textContent.trim()) titleEl.textContent = DATA.projectName; document.title = titleEl.textContent + ' \u2014 Architecture Viewer'; saveSettings(); });
1033
+ titleEl.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); titleEl.blur(); } });
1034
+
1035
+ // Restore saved settings \u2014 phase 1: non-graph settings (before graph init)
1036
+ const _savedSettings = loadSettings();
1037
+ if (_savedSettings) {
1038
+ if (_savedSettings.theme) setTheme(_savedSettings.theme);
1039
+ if (_savedSettings.lang) { currentLang = _savedSettings.lang; applyI18n(); }
1040
+ if (_savedSettings.projectTitle) { titleEl.textContent = _savedSettings.projectTitle; document.title = _savedSettings.projectTitle + ' \u2014 Architecture Viewer'; }
1041
+ // Set slider positions (visual only \u2014 graph not built yet)
1042
+ if (_savedSettings.fontSize) { document.getElementById('font-size-slider').value = _savedSettings.fontSize; document.getElementById('font-size-val').textContent = _savedSettings.fontSize; }
1043
+ if (_savedSettings.nodeSize) { document.getElementById('node-size-slider').value = _savedSettings.nodeSize; document.getElementById('node-size-val').textContent = _savedSettings.nodeSize; nodeScale = _savedSettings.nodeSize / 100; }
1044
+ if (_savedSettings.linkOpacity) { document.getElementById('link-opacity-slider').value = _savedSettings.linkOpacity; document.getElementById('link-opacity-val').textContent = _savedSettings.linkOpacity; baseLinkOpacity = _savedSettings.linkOpacity / 100; }
1045
+ if (_savedSettings.gravity) { document.getElementById('gravity-slider').value = _savedSettings.gravity; document.getElementById('gravity-val').textContent = _savedSettings.gravity; gravityStrength = +_savedSettings.gravity; }
1046
+ }
1047
+
1048
+ document.getElementById('s-files').textContent = DATA.nodes.length;
1049
+ document.getElementById('s-edges').textContent = DATA.links.length;
1050
+ document.getElementById('s-circular').textContent = DATA.circularFiles.length;
1051
+
1052
+ const dirColor = d3.scaleOrdinal()
1053
+ .domain(DATA.dirs)
1054
+ .range(['#58a6ff','#3fb950','#d2a8ff','#f0883e','#79c0ff','#56d4dd','#db61a2','#f778ba','#ffa657','#7ee787']);
1055
+ function nodeColor(d) {
1056
+ if (circularSet.has(d.id)) return '#f97583';
1057
+ if (d.isOrphan) return '#484f58';
1058
+ return dirColor(d.dir);
1059
+ }
1060
+ function nodeRadius(d) { return Math.max(5, Math.min(22, 4 + d.dependents * 1.8)); }
1061
+ function fileName(id) { return id.split('/').pop(); }
1062
+
1063
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1064
+ // TAB SWITCHING
1065
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1066
+ let hierBuilt = false;
1067
+ document.querySelectorAll('.tab').forEach(tab => {
1068
+ tab.addEventListener('click', () => {
1069
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
1070
+ document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
1071
+ tab.classList.add('active');
1072
+ document.getElementById(tab.dataset.view).classList.add('active');
1073
+ if (tab.dataset.view === 'hier-view' && !hierBuilt) { buildHierarchy(); hierBuilt = true; }
1074
+ });
1075
+ });
1076
+
1077
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1078
+ // TOOLTIP \u2014 delayed hide + interactive
1079
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1080
+ const tooltip = document.getElementById('tooltip');
1081
+ let tooltipHideTimer = null;
1082
+ let tooltipLocked = false;
1083
+
1084
+ function showTooltip(e, d) {
1085
+ clearTimeout(tooltipHideTimer);
1086
+ document.getElementById('tt-name').textContent = d.id;
1087
+ document.getElementById('tt-dep-count').textContent = d.deps;
1088
+ document.getElementById('tt-dpt-count').textContent = d.dependents;
1089
+ const out = (d.dependencies||[]).map(x => '<div class="tt-out">\u2192 '+x+'</div>');
1090
+ const inc = (d.dependentsList||[]).map(x => '<div class="tt-in">\u2190 '+x+'</div>');
1091
+ document.getElementById('tt-details').innerHTML = [...out, ...inc].join('');
1092
+ tooltip.style.display = 'block';
1093
+ positionTooltip(e);
1094
+ }
1095
+ function positionTooltip(e) {
1096
+ const gap = 24;
1097
+ const tw = 420, th = tooltip.offsetHeight || 200;
1098
+ // Prefer placing to the right and above the cursor so it doesn't cover nodes below
1099
+ let x = e.clientX + gap;
1100
+ let y = e.clientY - th - 12;
1101
+ // If no room on the right, flip left
1102
+ if (x + tw > window.innerWidth) x = e.clientX - tw - gap;
1103
+ // If no room above, place below the cursor with gap
1104
+ if (y < 50) y = e.clientY + gap;
1105
+ // Final clamp
1106
+ if (y + th > window.innerHeight) y = window.innerHeight - th - 8;
1107
+ if (x < 8) x = 8;
1108
+ tooltip.style.left = x + 'px';
1109
+ tooltip.style.top = y + 'px';
1110
+ }
1111
+ function scheduleHideTooltip() {
1112
+ clearTimeout(tooltipHideTimer);
1113
+ tooltipHideTimer = setTimeout(() => {
1114
+ if (!tooltipLocked) {
1115
+ tooltip.style.display = 'none';
1116
+ if (!pinnedNode) resetGraphHighlight();
1117
+ }
1118
+ }, 250);
1119
+ }
1120
+
1121
+ // Keep tooltip visible when mouse enters it
1122
+ tooltip.addEventListener('mouseenter', () => {
1123
+ clearTimeout(tooltipHideTimer);
1124
+ tooltipLocked = true;
1125
+ });
1126
+ tooltip.addEventListener('mouseleave', () => {
1127
+ tooltipLocked = false;
1128
+ scheduleHideTooltip();
1129
+ });
1130
+
1131
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1132
+ // GRAPH VIEW
1133
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1134
+ const svg = d3.select('#graph-svg').attr('width', W).attr('height', H);
1135
+ const g = svg.append('g');
1136
+ const zoom = d3.zoom().scaleExtent([0.05, 10]).on('zoom', e => g.attr('transform', e.transform));
1137
+ svg.call(zoom);
1138
+ svg.call(zoom.transform, d3.zoomIdentity.translate(W/2, H/2).scale(0.7));
1139
+
1140
+ window.zoomIn = () => svg.transition().duration(300).call(zoom.scaleBy, 1.4);
1141
+ window.zoomOut = () => svg.transition().duration(300).call(zoom.scaleBy, 0.7);
1142
+ window.zoomFit = () => {
1143
+ const b = g.node().getBBox(); if (!b.width) return;
1144
+ const s = Math.min(W/(b.width+80), H/(b.height+80))*0.9;
1145
+ svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(W/2-(b.x+b.width/2)*s, H/2-(b.y+b.height/2)*s).scale(s));
1146
+ };
1147
+
1148
+ // Defs
1149
+ const defs = svg.append('defs');
1150
+ ['#30363d','#58a6ff','#3fb950'].forEach((c,i) => {
1151
+ defs.append('marker').attr('id','arrow-'+i).attr('viewBox','0 -4 8 8')
1152
+ .attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
1153
+ .append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill',c);
1154
+ });
1155
+
1156
+ // Links
1157
+ const link = g.append('g').selectAll('line').data(DATA.links).join('line')
1158
+ .attr('stroke', d => d.type==='type-only'?'#1f3d5c':'#30363d')
1159
+ .attr('stroke-width',1)
1160
+ .attr('stroke-dasharray', d => d.type==='type-only'?'4,3':d.type==='dynamic'?'6,3':null)
1161
+ .attr('marker-end','url(#arrow-0)')
1162
+ .attr('opacity', baseLinkOpacity);
1163
+
1164
+ // Nodes
1165
+ const node = g.append('g').selectAll('g').data(DATA.nodes).join('g')
1166
+ .attr('cursor','pointer')
1167
+ .call(d3.drag().on('start',dragStart).on('drag',dragging).on('end',dragEnd));
1168
+
1169
+ node.append('circle')
1170
+ .attr('r', d => nodeRadius(d) * nodeScale)
1171
+ .attr('fill', nodeColor)
1172
+ .attr('stroke', d => d.deps>=5?'var(--yellow)':nodeColor(d))
1173
+ .attr('stroke-width', d => d.deps>=5?2.5:1.5)
1174
+ .attr('stroke-opacity', d => d.deps>=5?0.8:0.3);
1175
+
1176
+ node.append('text')
1177
+ .text(d => fileName(d.id).replace(/\\.tsx?$/,''))
1178
+ .attr('dx', d => nodeRadius(d)*nodeScale+4)
1179
+ .attr('dy',3.5)
1180
+ .attr('font-size', d => d.dependents>=3?12:10)
1181
+ .attr('font-weight', d => d.dependents>=3?600:400)
1182
+ .attr('fill', d => d.dependents>=3?'var(--text)':'var(--text-dim)')
1183
+ .attr('opacity', d => d.dependents>=1||d.deps>=3?1:0.5)
1184
+ .attr('pointer-events','none');
1185
+
1186
+ // Simulation
1187
+ const simulation = d3.forceSimulation(DATA.nodes)
1188
+ .force('link', d3.forceLink(DATA.links).id(d=>d.id).distance(70).strength(0.25))
1189
+ .force('charge', d3.forceManyBody().strength(-gravityStrength).distanceMax(500))
1190
+ .force('center', d3.forceCenter(0,0))
1191
+ .force('collision', d3.forceCollide().radius(d=>nodeRadius(d)*nodeScale+4))
1192
+ .force('x', d3.forceX(0).strength(0.03))
1193
+ .force('y', d3.forceY(0).strength(0.03))
1194
+ .on('tick', () => {
1195
+ link.each(function(d) {
1196
+ const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y;
1197
+ const dist=Math.sqrt(dx*dx+dy*dy)||1;
1198
+ const rT=nodeRadius(d.target)*nodeScale, rS=nodeRadius(d.source)*nodeScale;
1199
+ d3.select(this)
1200
+ .attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
1201
+ .attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
1202
+ });
1203
+ node.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
1204
+ });
1205
+
1206
+ setTimeout(()=>zoomFit(), 1500);
1207
+
1208
+ // Restore saved settings \u2014 phase 2: apply to graph elements now that they exist
1209
+ if (_savedSettings) {
1210
+ if (_savedSettings.fontSize) setFontSize(_savedSettings.fontSize);
1211
+ }
1212
+
1213
+ // \u2500\u2500\u2500 Highlight helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1214
+ let pinnedNode = null;
1215
+
1216
+ function highlightNode(d) {
1217
+ const conn = new Set([d.id]);
1218
+ DATA.links.forEach(l => { const s=l.source.id??l.source,t=l.target.id??l.target; if(s===d.id)conn.add(t); if(t===d.id)conn.add(s); });
1219
+ node.select('circle').transition().duration(150).attr('opacity',n=>conn.has(n.id)?1:0.1);
1220
+ node.select('text').transition().duration(150).attr('opacity',n=>conn.has(n.id)?1:0.05);
1221
+ link.transition().duration(150)
1222
+ .attr('opacity',l=>{const s=l.source.id??l.source,t=l.target.id??l.target;return s===d.id||t===d.id?0.9:0.03;})
1223
+ .attr('stroke',l=>{const s=l.source.id??l.source,t=l.target.id??l.target;if(s===d.id)return'#58a6ff';if(t===d.id)return'#3fb950';return l.type==='type-only'?'#1f3d5c':'#30363d';})
1224
+ .attr('stroke-width',l=>{const s=l.source.id??l.source,t=l.target.id??l.target;return s===d.id||t===d.id?2:1;})
1225
+ .attr('marker-end',l=>{const s=l.source.id??l.source,t=l.target.id??l.target;if(s===d.id)return'url(#arrow-1)';if(t===d.id)return'url(#arrow-2)';return'url(#arrow-0)';});
1226
+ }
1227
+
1228
+ function resetGraphHighlight() {
1229
+ pinnedNode = null;
1230
+ node.select('circle').transition().duration(200).attr('opacity',1);
1231
+ node.select('text').transition().duration(200).attr('opacity',d=>d.dependents>=1||d.deps>=3?1:0.5);
1232
+ link.transition().duration(200)
1233
+ .attr('opacity',baseLinkOpacity)
1234
+ .attr('stroke',d=>d.type==='type-only'?'#1f3d5c':'#30363d')
1235
+ .attr('stroke-width',1).attr('marker-end','url(#arrow-0)');
1236
+ }
1237
+
1238
+ // \u2500\u2500\u2500 Hover \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1239
+ node.on('mouseover', (e,d) => {
1240
+ showTooltip(e,d);
1241
+ if (!pinnedNode) highlightNode(d);
1242
+ })
1243
+ .on('mousemove', e=>positionTooltip(e))
1244
+ .on('mouseout', () => { scheduleHideTooltip(); if (!pinnedNode) { /* highlight resets via scheduleHideTooltip */ } });
1245
+
1246
+ // \u2500\u2500\u2500 Click: pin highlight + detail panel \u2500\u2500\u2500\u2500\u2500
1247
+ node.on('click', (e,d) => {
1248
+ e.stopPropagation();
1249
+ pinnedNode = d;
1250
+ highlightNode(d);
1251
+ showDetail(d);
1252
+ });
1253
+ svg.on('click', () => {
1254
+ resetGraphHighlight();
1255
+ tooltip.style.display = 'none';
1256
+ tooltipLocked = false;
1257
+ closeDetail();
1258
+ });
1259
+
1260
+ function showDetail(d) {
1261
+ const p=document.getElementById('detail');
1262
+ document.getElementById('d-name').textContent=d.id;
1263
+ document.getElementById('d-meta').innerHTML=i('detail.dir')+': '+d.dir+'<br>'+i('detail.dependencies')+': '+d.deps+' \xB7 '+i('detail.dependents')+': '+d.dependents;
1264
+ const deptL=document.getElementById('d-dependents'), depsL=document.getElementById('d-deps');
1265
+ deptL.innerHTML=(d.dependentsList||[]).map(x=>'<li onclick="focusNode(\\''+x+'\\')">\u2190 '+x+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
1266
+ depsL.innerHTML=(d.dependencies||[]).map(x=>'<li onclick="focusNode(\\''+x+'\\')">\u2192 '+x+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
1267
+ p.classList.add('open');
1268
+ }
1269
+ window.closeDetail=()=>document.getElementById('detail').classList.remove('open');
1270
+ window.focusNode=(id)=>{
1271
+ const n=DATA.nodes.find(x=>x.id===id); if(!n)return; showDetail(n);
1272
+ svg.transition().duration(500).call(zoom.transform,d3.zoomIdentity.translate(W/2-n.x*1.5,H/2-n.y*1.5).scale(1.5));
1273
+ };
1274
+
1275
+ // Drag
1276
+ function dragStart(e,d){if(!e.active)simulation.alphaTarget(0.3).restart();d.fx=d.x;d.fy=d.y;}
1277
+ function dragging(e,d){d.fx=e.x;d.fy=e.y;}
1278
+ function dragEnd(e,d){if(!e.active)simulation.alphaTarget(0);}
1279
+
1280
+ // \u2500\u2500\u2500 Search \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1281
+ const searchInput=document.getElementById('search');
1282
+ document.addEventListener('keydown',e=>{
1283
+ if(e.key==='/'&&document.activeElement!==searchInput){e.preventDefault();searchInput.focus();}
1284
+ if(e.key==='Escape'){searchInput.value='';searchInput.blur();resetGraphHighlight();}
1285
+ });
1286
+ searchInput.addEventListener('input',e=>{
1287
+ const q=e.target.value.toLowerCase();
1288
+ if(!q){resetGraphHighlight();return;}
1289
+ node.select('circle').attr('opacity',d=>d.id.toLowerCase().includes(q)?1:0.06);
1290
+ node.select('text').attr('opacity',d=>d.id.toLowerCase().includes(q)?1:0.04);
1291
+ link.attr('opacity',0.03);
1292
+ });
1293
+
1294
+ // \u2500\u2500\u2500 Filters (click=toggle, hover=highlight nodes) \u2500\u2500
1295
+ const filtersEl=document.getElementById('filters');
1296
+ const activeDirs=new Set(DATA.dirs);
1297
+ const dirCounts={};
1298
+ DATA.nodes.forEach(n=>dirCounts[n.dir]=(dirCounts[n.dir]||0)+1);
1299
+ DATA.dirs.forEach(dir=>{
1300
+ const pill=document.createElement('div');
1301
+ pill.className='filter-pill active';
1302
+ pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+(dir||'.')+' <span class="pill-count">'+dirCounts[dir]+'</span>';
1303
+ pill.onclick=()=>{
1304
+ if(activeDirs.has(dir)){activeDirs.delete(dir);pill.classList.remove('active');}
1305
+ else{activeDirs.add(dir);pill.classList.add('active');}
1306
+ applyFilter();
1307
+ };
1308
+ pill.onmouseenter=()=>{
1309
+ if(pinnedNode)return;
1310
+ node.select('circle').transition().duration(120).attr('opacity',d=>d.dir===dir?1:0.1);
1311
+ node.select('text').transition().duration(120).attr('opacity',d=>d.dir===dir?1:0.05);
1312
+ };
1313
+ pill.onmouseleave=()=>{
1314
+ if(pinnedNode)return;
1315
+ node.select('circle').transition().duration(150).attr('opacity',1);
1316
+ node.select('text').transition().duration(150).attr('opacity',d=>d.dependents>=1||d.deps>=3?1:0.5);
1317
+ };
1318
+ filtersEl.appendChild(pill);
1319
+ });
1320
+ function applyFilter(){
1321
+ node.attr('display',d=>activeDirs.has(d.dir)?null:'none');
1322
+ link.attr('display',l=>{
1323
+ const s=l.source.id??l.source,t=l.target.id??l.target;
1324
+ const sD=DATA.nodes.find(n=>n.id===s)?.dir,tD=DATA.nodes.find(n=>n.id===t)?.dir;
1325
+ return activeDirs.has(sD)&&activeDirs.has(tD)?null:'none';
1326
+ });
1327
+ }
1328
+
1329
+ // \u2500\u2500\u2500 Impact simulation mode \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1330
+ let impactMode=false;
1331
+ const impactBadge=document.getElementById('impact-badge');
1332
+ window.toggleImpactMode=()=>{
1333
+ impactMode=!impactMode;
1334
+ document.getElementById('impact-btn').classList.toggle('active',impactMode);
1335
+ if(!impactMode){impactBadge.style.display='none';resetGraphHighlight();}
1336
+ };
1337
+ function getTransitiveDependents(startId){
1338
+ const result=new Set();const queue=[startId];
1339
+ const revMap={};
1340
+ DATA.links.forEach(l=>{const s=l.source.id??l.source,t=l.target.id??l.target;if(!revMap[t])revMap[t]=[];revMap[t].push(s);});
1341
+ while(queue.length){const id=queue.shift();if(result.has(id))continue;result.add(id);(revMap[id]||[]).forEach(x=>queue.push(x));}
1342
+ return result;
1343
+ }
1344
+ // Override click in impact mode
1345
+ const origClick=node.on('click');
1346
+ node.on('click',(e,d)=>{
1347
+ if(!impactMode){e.stopPropagation();pinnedNode=d;highlightNode(d);showDetail(d);return;}
1348
+ e.stopPropagation();
1349
+ const affected=getTransitiveDependents(d.id);
1350
+ node.select('circle').transition().duration(200).attr('opacity',n=>affected.has(n.id)?1:0.06)
1351
+ .attr('stroke',n=>affected.has(n.id)&&n.id!==d.id?'var(--red)':n.deps>=5?'var(--yellow)':nodeColor(n))
1352
+ .attr('stroke-width',n=>affected.has(n.id)?3:1.5);
1353
+ node.select('text').transition().duration(200).attr('opacity',n=>affected.has(n.id)?1:0.04);
1354
+ link.transition().duration(200).attr('opacity',l=>{
1355
+ const s=l.source.id??l.source,t=l.target.id??l.target;
1356
+ return affected.has(s)&&affected.has(t)?0.8:0.03;
1357
+ }).attr('stroke',l=>{
1358
+ const s=l.source.id??l.source,t=l.target.id??l.target;
1359
+ return affected.has(s)&&affected.has(t)?'var(--red)':l.type==='type-only'?'#1f3d5c':'#30363d';
1360
+ });
1361
+ impactBadge.textContent=d.id.split('/').pop()+' \u2192 '+(affected.size-1)+' '+i('impact.transitive');
1362
+ impactBadge.style.display='block';
1363
+ });
1364
+
1365
+ window.addEventListener('resize',()=>{
1366
+ const w=window.innerWidth,h=window.innerHeight-44;
1367
+ svg.attr('width',w).attr('height',h);
1368
+ });
1369
+
1370
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1371
+ // HIERARCHY VIEW
1372
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1373
+ function buildHierarchy(){
1374
+ const hSvg=d3.select('#hier-svg');
1375
+ const hG=hSvg.append('g');
1376
+ const hZoom=d3.zoom().scaleExtent([0.1,4]).on('zoom',e=>hG.attr('transform',e.transform));
1377
+ hSvg.call(hZoom);
1378
+
1379
+ const nodeMap={}; DATA.nodes.forEach(n=>nodeMap[n.id]=n);
1380
+ const importsMap={}; DATA.links.forEach(l=>{const s=l.source.id??l.source,t=l.target.id??l.target;if(!importsMap[s])importsMap[s]=[];importsMap[s].push(t);});
1381
+
1382
+ const entryPoints=DATA.nodes.filter(n=>n.dependents===0).map(n=>n.id);
1383
+ const layers={};const visited=new Set();
1384
+ const queue=entryPoints.map(id=>({id,layer:0}));
1385
+ DATA.nodes.forEach(n=>{if(n.isOrphan)layers[n.id]=0;});
1386
+
1387
+ while(queue.length>0){
1388
+ const{id,layer}=queue.shift();
1389
+ if(visited.has(id)&&(layers[id]??-1)>=layer)continue;
1390
+ layers[id]=Math.max(layers[id]??0,layer);visited.add(id);
1391
+ (importsMap[id]||[]).forEach(t=>queue.push({id:t,layer:layer+1}));
1392
+ }
1393
+ DATA.nodes.forEach(n=>{if(!(n.id in layers))layers[n.id]=0;});
1394
+
1395
+ const maxLayer=Math.max(0,...Object.values(layers));
1396
+ const layerGroups={};
1397
+ for(let i=0;i<=maxLayer;i++)layerGroups[i]=[];
1398
+ Object.entries(layers).forEach(([id,l])=>layerGroups[l].push(id));
1399
+ Object.values(layerGroups).forEach(arr=>arr.sort((a,b)=>(nodeMap[a]?.dir||'').localeCompare(nodeMap[b]?.dir||'')||a.localeCompare(b)));
1400
+
1401
+ const boxW=200,boxH=30,gapX=24,gapY=70,padY=60,padX=40;
1402
+ const positions={};let maxRowWidth=0;
1403
+ for(let layer=0;layer<=maxLayer;layer++){const items=layerGroups[layer];maxRowWidth=Math.max(maxRowWidth,items.length*(boxW+gapX)-gapX);}
1404
+ for(let layer=0;layer<=maxLayer;layer++){
1405
+ const items=layerGroups[layer],rowWidth=items.length*(boxW+gapX)-gapX,startX=padX+(maxRowWidth-rowWidth)/2;
1406
+ items.forEach((id,i)=>{positions[id]={x:startX+i*(boxW+gapX),y:padY+layer*(boxH+gapY)};});
1407
+ }
1408
+
1409
+ const totalW=maxRowWidth+padX*2,totalH=padY*2+(maxLayer+1)*(boxH+gapY);
1410
+ hSvg.attr('width',Math.max(totalW,W)).attr('height',Math.max(totalH,H));
1411
+
1412
+ const linkG=hG.append('g');
1413
+ DATA.links.forEach(l=>{
1414
+ const sId=l.source.id??l.source,tId=l.target.id??l.target;
1415
+ const s=positions[sId],t=positions[tId]; if(!s||!t)return;
1416
+ const x1=s.x+boxW/2,y1=s.y+boxH,x2=t.x+boxW/2,y2=t.y,midY=(y1+y2)/2;
1417
+ linkG.append('path').attr('class','hier-link')
1418
+ .attr('d',\`M\${x1},\${y1} C\${x1},\${midY} \${x2},\${midY} \${x2},\${y2}\`)
1419
+ .attr('stroke',l.type==='type-only'?'#1f3d5c':'var(--border)')
1420
+ .attr('stroke-dasharray',l.type==='type-only'?'4,3':null)
1421
+ .attr('data-source',sId).attr('data-target',tId);
1422
+ });
1423
+
1424
+ hSvg.append('defs').append('marker').attr('id','harrow').attr('viewBox','0 -3 6 6')
1425
+ .attr('refX',6).attr('refY',0).attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
1426
+ .append('path').attr('d','M0,-3L6,0L0,3Z').attr('fill','var(--border)');
1427
+ linkG.selectAll('path').attr('marker-end','url(#harrow)');
1428
+
1429
+ for(let layer=0;layer<=maxLayer;layer++){
1430
+ if(!layerGroups[layer].length)continue;
1431
+ hG.append('text').attr('class','hier-layer-label').attr('font-size',11)
1432
+ .attr('x',12).attr('y',padY+layer*(boxH+gapY)+boxH/2+4).text('L'+layer);
1433
+ }
1434
+
1435
+ const nodeG=hG.append('g');
1436
+ DATA.nodes.forEach(n=>{
1437
+ const pos=positions[n.id]; if(!pos)return;
1438
+ const gn=nodeG.append('g').attr('class','hier-node').attr('transform',\`translate(\${pos.x},\${pos.y})\`);
1439
+ gn.append('rect').attr('width',boxW).attr('height',boxH)
1440
+ .attr('fill','var(--bg-card)').attr('stroke',nodeColor(n))
1441
+ .attr('stroke-width',circularSet.has(n.id)?2:1.5);
1442
+ gn.append('text').attr('x',8).attr('y',boxH/2+4).attr('font-size',11)
1443
+ .text(fileName(n.id).length>24?fileName(n.id).slice(0,22)+'\u2026':fileName(n.id));
1444
+ gn.append('text').attr('x',boxW-8).attr('y',boxH/2+4)
1445
+ .attr('text-anchor','end').attr('font-size',10).attr('fill','var(--text-muted)')
1446
+ .text(n.dependents>0?'\u2191'+n.dependents:'');
1447
+ gn.append('text').attr('x',8).attr('y',-4).attr('font-size',9)
1448
+ .attr('fill',dirColor(n.dir)).attr('opacity',0.7).text(n.dir);
1449
+
1450
+ gn.node().__data_id=n.id;
1451
+ gn.on('mouseover',e=>{
1452
+ showTooltip(e,n);
1453
+ if (!hierPinned) hierHighlight(n.id);
1454
+ })
1455
+ .on('mousemove',e=>positionTooltip(e))
1456
+ .on('mouseout',()=>{
1457
+ scheduleHideTooltip();
1458
+ if (!hierPinned) hierResetHighlight();
1459
+ })
1460
+ .on('click',(e)=>{
1461
+ e.stopPropagation();
1462
+ hierPinned=n.id;
1463
+ hierHighlight(n.id);
1464
+ showHierDetail(n);
1465
+ });
1466
+ });
1467
+
1468
+ // Hierarchy highlight helpers
1469
+ let hierPinned=null;
1470
+ function hierHighlight(nId){
1471
+ linkG.selectAll('path')
1472
+ .attr('stroke',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');if(s===nId)return'#58a6ff';if(t===nId)return'#3fb950';return this.getAttribute('stroke-dasharray')?'#1f3d5c':'var(--border)';})
1473
+ .attr('stroke-width',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');return(s===nId||t===nId)?2.5:1;})
1474
+ .attr('opacity',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');return(s===nId||t===nId)?1:0.15;});
1475
+ nodeG.selectAll('.hier-node').attr('opacity',function(){
1476
+ const id=this.__data_id; if(id===nId)return 1;
1477
+ const connected=DATA.links.some(l=>{const s=l.source.id??l.source,t=l.target.id??l.target;return(s===nId&&t===id)||(t===nId&&s===id);});
1478
+ return connected?1:0.3;
1479
+ });
1480
+ }
1481
+ function hierResetHighlight(){
1482
+ hierPinned=null;
1483
+ linkG.selectAll('path')
1484
+ .attr('stroke',function(){return this.getAttribute('stroke-dasharray')?'#1f3d5c':'var(--border)';})
1485
+ .attr('stroke-width',1).attr('opacity',1);
1486
+ nodeG.selectAll('.hier-node').attr('opacity',1);
1487
+ }
1488
+ function showHierDetail(n){
1489
+ const p=document.getElementById('hier-detail');
1490
+ document.getElementById('hd-name').textContent=n.id;
1491
+ document.getElementById('hd-meta').innerHTML=i('detail.dir')+': '+n.dir+'<br>'+i('detail.dependencies')+': '+n.deps+' \xB7 '+i('detail.dependents')+': '+n.dependents;
1492
+ document.getElementById('hd-dependents').innerHTML=(n.dependentsList||[]).map(x=>'<li>\u2190 '+x+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
1493
+ document.getElementById('hd-deps').innerHTML=(n.dependencies||[]).map(x=>'<li>\u2192 '+x+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
1494
+ p.classList.add('open');
1495
+ }
1496
+ window.closeHierDetail=()=>{document.getElementById('hier-detail').classList.remove('open');hierResetHighlight();tooltip.style.display='none';tooltipLocked=false;};
1497
+
1498
+ // Click on empty space to deselect
1499
+ hSvg.on('click',()=>{closeHierDetail();});
1500
+
1501
+ // Hierarchy dir filters
1502
+ const hFiltersEl=document.getElementById('hier-filters');
1503
+ const hActiveDirs=new Set(DATA.dirs);
1504
+ DATA.dirs.forEach(dir=>{
1505
+ const pill=document.createElement('div');
1506
+ pill.className='filter-pill active';
1507
+ pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+(dir||'.')+' <span class="pill-count">'+(dirCounts[dir]||0)+'</span>';
1508
+ pill.onclick=()=>{
1509
+ if(hActiveDirs.has(dir)){hActiveDirs.delete(dir);pill.classList.remove('active');}
1510
+ else{hActiveDirs.add(dir);pill.classList.add('active');}
1511
+ nodeG.selectAll('.hier-node').attr('opacity',function(){const nId=this.__data_id;return hActiveDirs.has(nodeMap[nId]?.dir)?1:0.1;});
1512
+ };
1513
+ pill.onmouseenter=()=>{
1514
+ nodeG.selectAll('.hier-node').attr('opacity',function(){return this.__data_id&&nodeMap[this.__data_id]?.dir===dir?1:0.1;});
1515
+ };
1516
+ pill.onmouseleave=()=>{
1517
+ nodeG.selectAll('.hier-node').attr('opacity',1);
1518
+ };
1519
+ hFiltersEl.appendChild(pill);
1520
+ });
1521
+
1522
+ hSvg.call(hZoom.transform,d3.zoomIdentity.translate(
1523
+ Math.max(0,(W-totalW)/2),20
1524
+ ).scale(Math.min(1,W/(totalW+40),H/(totalH+40))));
1525
+ }
1526
+
1527
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1528
+ // DIFF VIEW
1529
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1530
+ const DIFF = ${diffData};
1531
+ if (DIFF) {
1532
+ document.getElementById('diff-tab').style.display = '';
1533
+ const addedSet = new Set(DIFF.added||[]);
1534
+ const removedSet = new Set(DIFF.removed||[]);
1535
+ const modifiedSet = new Set(DIFF.modified||[]);
1536
+ const affectedSet = new Set((DIFF.affectedDependents||[]).map(a=>a.file));
1537
+
1538
+ let diffBuilt = false;
1539
+ function buildDiffView() {
1540
+ const dSvg = d3.select('#diff-svg').attr('width', W).attr('height', H);
1541
+ const dG = dSvg.append('g');
1542
+ const dZoom = d3.zoom().scaleExtent([0.05,10]).on('zoom', e=>dG.attr('transform',e.transform));
1543
+ dSvg.call(dZoom);
1544
+
1545
+ function diffColor(d) {
1546
+ if (addedSet.has(d.id)) return 'var(--green)';
1547
+ if (removedSet.has(d.id)) return 'var(--red)';
1548
+ if (modifiedSet.has(d.id)) return 'var(--yellow)';
1549
+ if (affectedSet.has(d.id)) return 'var(--accent)';
1550
+ return '#30363d';
1551
+ }
1552
+
1553
+ const dDefs = dSvg.append('defs');
1554
+ dDefs.append('marker').attr('id','darrow').attr('viewBox','0 -4 8 8')
1555
+ .attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
1556
+ .append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill','#30363d');
1557
+
1558
+ const dLink = dG.append('g').selectAll('line').data(DATA.links).join('line')
1559
+ .attr('stroke','#30363d').attr('stroke-width',1).attr('marker-end','url(#darrow)').attr('opacity',0.3);
1560
+
1561
+ const simNodes = DATA.nodes.map(d=>({...d}));
1562
+ const simLinks = DATA.links.map(d=>({source:d.source.id??d.source,target:d.target.id??d.target,type:d.type}));
1563
+
1564
+ const dNode = dG.append('g').selectAll('g').data(simNodes).join('g').attr('cursor','pointer');
1565
+ dNode.append('circle')
1566
+ .attr('r', d=>nodeRadius(d)*nodeScale)
1567
+ .attr('fill', diffColor)
1568
+ .attr('stroke', diffColor).attr('stroke-width', d=>(addedSet.has(d.id)||removedSet.has(d.id)||modifiedSet.has(d.id)||affectedSet.has(d.id))?3:1)
1569
+ .attr('opacity', d=>(addedSet.has(d.id)||removedSet.has(d.id)||modifiedSet.has(d.id)||affectedSet.has(d.id))?1:0.2);
1570
+ dNode.append('text')
1571
+ .text(d=>fileName(d.id).replace(/\\.tsx?$/,''))
1572
+ .attr('dx', d=>nodeRadius(d)*nodeScale+4).attr('dy',3.5).attr('font-size',11)
1573
+ .attr('fill', d=>(addedSet.has(d.id)||removedSet.has(d.id)||modifiedSet.has(d.id)||affectedSet.has(d.id))?'var(--text)':'var(--text-muted)')
1574
+ .attr('opacity', d=>(addedSet.has(d.id)||removedSet.has(d.id)||modifiedSet.has(d.id)||affectedSet.has(d.id))?1:0.2)
1575
+ .attr('pointer-events','none');
1576
+
1577
+ const dSim = d3.forceSimulation(simNodes)
1578
+ .force('link', d3.forceLink(simLinks).id(d=>d.id).distance(70).strength(0.25))
1579
+ .force('charge', d3.forceManyBody().strength(-150).distanceMax(500))
1580
+ .force('center', d3.forceCenter(0,0))
1581
+ .force('collision', d3.forceCollide().radius(d=>nodeRadius(d)*nodeScale+4))
1582
+ .on('tick', ()=>{
1583
+ dLink.each(function(d){
1584
+ const dx=d.target.x-d.source.x,dy=d.target.y-d.source.y,dist=Math.sqrt(dx*dx+dy*dy)||1;
1585
+ const rT=nodeRadius(d.target)*nodeScale,rS=nodeRadius(d.source)*nodeScale;
1586
+ d3.select(this).attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
1587
+ .attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
1588
+ });
1589
+ dNode.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
1590
+ });
1591
+
1592
+ dNode.on('mouseover',(e,d)=>showTooltip(e,d)).on('mousemove',e=>positionTooltip(e)).on('mouseout',()=>scheduleHideTooltip());
1593
+
1594
+ setTimeout(()=>{
1595
+ const b=dG.node().getBBox();if(!b.width)return;
1596
+ const s=Math.min(W/(b.width+80),H/(b.height+80))*0.9;
1597
+ dSvg.call(dZoom.transform,d3.zoomIdentity.translate(W/2-(b.x+b.width/2)*s,H/2-(b.y+b.height/2)*s).scale(s));
1598
+ },1500);
1599
+ }
1600
+
1601
+ // Hook into tab switching
1602
+ const origTabHandler = document.querySelectorAll('.tab');
1603
+ origTabHandler.forEach(tab=>{
1604
+ tab.addEventListener('click',()=>{
1605
+ if(tab.dataset.view==='diff-view'&&!diffBuilt){buildDiffView();diffBuilt=true;}
1606
+ });
1607
+ });
1608
+ }
1609
+
1610
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1611
+ // INIT
1612
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1613
+ applyI18n();
1614
+ </script>
1615
+ </body>
1616
+ </html>`
1617
+ );
1618
+ }
1619
+
1620
+ // src/web/server.ts
1621
+ function startViewer(graph, options = {}) {
1622
+ const port = options.port ?? 3e3;
1623
+ const locale = options.locale ?? getLocale();
1624
+ const html = buildGraphPage(graph, { locale, diff: options.diff });
1625
+ const graphJson = JSON.stringify(graph);
1626
+ const server = createServer((req, res) => {
1627
+ if (req.url === "/api/graph") {
1628
+ res.writeHead(200, { "Content-Type": "application/json" });
1629
+ res.end(graphJson);
1630
+ return;
1631
+ }
1632
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1633
+ res.end(html);
1634
+ });
1635
+ server.listen(port);
1636
+ return {
1637
+ port,
1638
+ close: () => server.close()
1639
+ };
1640
+ }
1641
+
1642
+ // src/cli/index.ts
1643
+ var program = new Command();
1644
+ program.name("archtracker").description(
1645
+ "Architecture & Dependency Tracker \u2014 Prevent missed architecture changes in AI-driven development"
1646
+ ).version("0.1.0").option("--lang <locale>", "Language (en/ja, auto-detected from LANG env)").hook("preAction", (thisCommand) => {
1647
+ const lang = thisCommand.opts().lang;
1648
+ if (lang === "en" || lang === "ja") {
1649
+ setLocale(lang);
1650
+ }
1651
+ });
1652
+ program.command("init").description("Generate initial snapshot and save to .archtracker/").option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option(
1653
+ "-e, --exclude <patterns...>",
1654
+ "Exclude patterns (regex)"
1655
+ ).action(async (opts) => {
1656
+ try {
1657
+ console.log(t("cli.analyzing"));
1658
+ const graph = await analyzeProject(opts.target, {
1659
+ exclude: opts.exclude
1660
+ });
1661
+ const snapshot = await saveSnapshot(opts.root, graph);
1662
+ console.log(t("cli.snapshotSaved"));
1663
+ console.log(t("cli.timestamp", { ts: snapshot.timestamp }));
1664
+ console.log(t("cli.fileCount", { count: graph.totalFiles }));
1665
+ console.log(t("cli.edgeCount", { count: graph.totalEdges }));
1666
+ if (graph.circularDependencies.length > 0) {
1667
+ console.log(
1668
+ t("cli.circularCount", { count: graph.circularDependencies.length })
1669
+ );
1670
+ }
1671
+ const top = Object.values(graph.files).sort((a, b) => b.dependents.length - a.dependents.length).slice(0, 5);
1672
+ if (top.length > 0 && top[0].dependents.length > 0) {
1673
+ console.log(t("cli.keyComponents"));
1674
+ for (const f of top) {
1675
+ if (f.dependents.length === 0) break;
1676
+ console.log(` ${t("cli.dependedBy", { path: f.path, count: f.dependents.length })}`);
1677
+ }
1678
+ }
1679
+ } catch (error) {
1680
+ handleError(error);
1681
+ }
1682
+ });
1683
+ program.command("analyze").description(
1684
+ "Comprehensive architecture analysis for existing projects"
1685
+ ).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option(
1686
+ "-e, --exclude <patterns...>",
1687
+ "Exclude patterns (regex)"
1688
+ ).option("-n, --top <number>", "Number of top components to show", "10").option("--save", "Also save a snapshot after analysis").action(async (opts) => {
1689
+ try {
1690
+ console.log(t("cli.analyzing"));
1691
+ const graph = await analyzeProject(opts.target, {
1692
+ exclude: opts.exclude
1693
+ });
1694
+ const report = formatAnalysisReport(graph, { topN: parseInt(opts.top, 10) });
1695
+ console.log(report);
1696
+ if (opts.save) {
1697
+ await saveSnapshot(opts.root, graph);
1698
+ console.log(t("analyze.snapshotSaved"));
1699
+ }
1700
+ } catch (error) {
1701
+ handleError(error);
1702
+ }
1703
+ });
1704
+ program.command("check").description(
1705
+ "Compare snapshot with current code and report change impacts"
1706
+ ).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option("--ci", "CI mode: exit code 1 if affected files exist").action(async (opts) => {
1707
+ try {
1708
+ const existingSnapshot = await loadSnapshot(opts.root);
1709
+ if (!existingSnapshot) {
1710
+ console.log(t("cli.noSnapshot"));
1711
+ process.exit(1);
1712
+ }
1713
+ console.log(t("cli.analyzing"));
1714
+ const currentGraph = await analyzeProject(opts.target);
1715
+ const diff = computeDiff(existingSnapshot.graph, currentGraph);
1716
+ const report = formatDiffReport(diff);
1717
+ console.log(report);
1718
+ if (opts.ci && diff.affectedDependents.length > 0) {
1719
+ console.log(t("cli.ciFailed", { count: diff.affectedDependents.length }));
1720
+ process.exit(1);
1721
+ }
1722
+ } catch (error) {
1723
+ handleError(error);
1724
+ }
1725
+ });
1726
+ program.command("context").description(
1727
+ "Display current architecture context (for AI session initialization)"
1728
+ ).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option("--json", "Output in JSON format").action(async (opts) => {
1729
+ try {
1730
+ let snapshot = await loadSnapshot(opts.root);
1731
+ if (!snapshot) {
1732
+ console.log(t("cli.autoGenerating"));
1733
+ const graph2 = await analyzeProject(opts.target);
1734
+ snapshot = await saveSnapshot(opts.root, graph2);
1735
+ }
1736
+ const graph = snapshot.graph;
1737
+ if (opts.json) {
1738
+ const context = {
1739
+ validPaths: Object.keys(graph.files).sort(),
1740
+ snapshotTimestamp: snapshot.timestamp,
1741
+ totalFiles: graph.totalFiles,
1742
+ totalEdges: graph.totalEdges,
1743
+ circularDependencies: graph.circularDependencies.length,
1744
+ keyComponents: Object.values(graph.files).sort((a, b) => b.dependents.length - a.dependents.length).slice(0, 20).map((f) => ({
1745
+ path: f.path,
1746
+ dependentCount: f.dependents.length,
1747
+ dependencyCount: f.dependencies.length
1748
+ }))
1749
+ };
1750
+ console.log(JSON.stringify(context, null, 2));
1751
+ return;
1752
+ }
1753
+ console.log(t("cli.project", { path: graph.rootDir }));
1754
+ console.log(t("cli.fileCount", { count: graph.totalFiles }));
1755
+ console.log(t("cli.edgeCount", { count: graph.totalEdges }));
1756
+ console.log(t("cli.circularCount", { count: graph.circularDependencies.length }));
1757
+ console.log(t("cli.snapshot", { ts: snapshot.timestamp }));
1758
+ console.log(t("cli.validPaths"));
1759
+ for (const f of Object.keys(graph.files).sort()) {
1760
+ console.log(` ${f}`);
1761
+ }
1762
+ } catch (error) {
1763
+ handleError(error);
1764
+ }
1765
+ });
1766
+ program.command("serve").description(
1767
+ "Start interactive architecture graph viewer in browser"
1768
+ ).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option("-p, --port <number>", "Port number", "3000").option(
1769
+ "-e, --exclude <patterns...>",
1770
+ "Exclude patterns (regex)"
1771
+ ).option("-w, --watch", "Watch for file changes and auto-reload").action(async (opts) => {
1772
+ try {
1773
+ console.log(t("web.starting"));
1774
+ console.log(t("cli.analyzing"));
1775
+ let graph;
1776
+ let diff = null;
1777
+ const snapshot = await loadSnapshot(opts.root);
1778
+ if (snapshot) {
1779
+ const currentGraph = await analyzeProject(opts.target, { exclude: opts.exclude });
1780
+ diff = computeDiff(snapshot.graph, currentGraph);
1781
+ graph = currentGraph;
1782
+ } else {
1783
+ graph = await analyzeProject(opts.target, { exclude: opts.exclude });
1784
+ }
1785
+ const port = parseInt(opts.port, 10);
1786
+ const viewer = startViewer(graph, { port, diff });
1787
+ console.log(t("web.listening", { port }));
1788
+ console.log(t("web.stop"));
1789
+ if (opts.watch) {
1790
+ console.log(t("web.watching", { dir: opts.target }));
1791
+ let debounce = null;
1792
+ watch(opts.target, { recursive: true }, () => {
1793
+ if (debounce) clearTimeout(debounce);
1794
+ debounce = setTimeout(async () => {
1795
+ try {
1796
+ console.log(t("web.reloading"));
1797
+ const newGraph = await analyzeProject(opts.target, { exclude: opts.exclude });
1798
+ viewer.close();
1799
+ startViewer(newGraph, { port });
1800
+ console.log(t("web.reloaded"));
1801
+ } catch {
1802
+ }
1803
+ }, 500);
1804
+ });
1805
+ }
1806
+ } catch (error) {
1807
+ handleError(error);
1808
+ }
1809
+ });
1810
+ program.command("ci-setup").description(
1811
+ "Generate GitHub Actions workflow for architecture checks on PRs"
1812
+ ).option("-t, --target <dir>", "Target directory", "src").action(async (opts) => {
1813
+ const workflow = `name: Architecture Check
1814
+
1815
+ on:
1816
+ pull_request:
1817
+ branches: [main, master]
1818
+
1819
+ jobs:
1820
+ arch-check:
1821
+ runs-on: ubuntu-latest
1822
+ steps:
1823
+ - uses: actions/checkout@v4
1824
+ - uses: actions/setup-node@v4
1825
+ with:
1826
+ node-version: '20'
1827
+ - run: npm ci
1828
+ - run: npx archtracker check --target ${opts.target} --ci
1829
+ `;
1830
+ try {
1831
+ const dir = join2(".github", "workflows");
1832
+ await mkdir2(dir, { recursive: true });
1833
+ const path = join2(dir, "arch-check.yml");
1834
+ await writeFile2(path, workflow, "utf-8");
1835
+ console.log(t("ci.generated", { path }));
1836
+ } catch (error) {
1837
+ handleError(error);
1838
+ }
1839
+ });
1840
+ function handleError(error) {
1841
+ if (error instanceof AnalyzerError) {
1842
+ console.error(t("error.cli.analyzer", { message: error.message }));
1843
+ } else if (error instanceof StorageError) {
1844
+ console.error(t("error.cli.storage", { message: error.message }));
1845
+ } else if (error instanceof Error) {
1846
+ console.error(t("error.cli.generic", { message: error.message }));
1847
+ } else {
1848
+ console.error(t("error.cli.unexpected", { message: String(error) }));
1849
+ }
1850
+ process.exit(1);
1851
+ }
1852
+ program.parse();
1853
+ //# sourceMappingURL=index.js.map