archtracker-mcp 0.6.0 → 0.7.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.
package/dist/cli/index.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // src/cli/index.ts
4
4
  import { Command } from "commander";
5
5
  import { watch } from "fs";
6
- import { writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
7
- import { join as join8 } from "path";
6
+ import { writeFile as writeFile4, mkdir as mkdir4 } from "fs/promises";
7
+ import { join as join10 } from "path";
8
8
 
9
9
  // src/analyzer/analyze.ts
10
10
  import { resolve as resolve3 } from "path";
@@ -1569,6 +1569,8 @@ var en = {
1569
1569
  "diff.reasonRemoved": 'Dependency "{file}" was removed',
1570
1570
  "diff.reasonModified": 'Dependency "{file}" had its dependencies changed',
1571
1571
  "diff.reasonAdded": 'New dependency "{file}" was added',
1572
+ "diff.testSummary": " ... and {count} test/fixture file(s)",
1573
+ "diff.testAffectedSummary": " ... and {count} test/fixture-related review(s)",
1572
1574
  // Search
1573
1575
  "search.pathMatch": 'Path matches "{pattern}"',
1574
1576
  "search.affected": 'May be affected by changes to "{file}" (via: {via})',
@@ -1630,6 +1632,12 @@ var en = {
1630
1632
  "web.watching": "Watching {dir}/ for changes...",
1631
1633
  "web.reloading": "File change detected, reloading...",
1632
1634
  "web.reloaded": "Graph reloaded",
1635
+ // History
1636
+ "history.title": "# Snapshot History\n",
1637
+ "history.empty": "No snapshots found. Run `archtracker init` to create one.",
1638
+ "history.entry": " {ts} | {files} files, {edges} edges, {circular} circular{layers}",
1639
+ "history.count": "{count} snapshot(s) recorded",
1640
+ "history.snapshotNotFound": "Snapshot not found for timestamp: {ts}",
1633
1641
  // Errors
1634
1642
  "error.analyzer": "[Analysis Error] {message}",
1635
1643
  "error.storage": "[Storage Error] {message}",
@@ -1662,6 +1670,8 @@ var ja = {
1662
1670
  "diff.reasonRemoved": '\u4F9D\u5B58\u5148 "{file}" \u304C\u524A\u9664\u3055\u308C\u307E\u3057\u305F',
1663
1671
  "diff.reasonModified": '\u4F9D\u5B58\u5148 "{file}" \u306E\u4F9D\u5B58\u95A2\u4FC2\u304C\u5909\u66F4\u3055\u308C\u307E\u3057\u305F',
1664
1672
  "diff.reasonAdded": '\u65B0\u3057\u3044\u4F9D\u5B58\u5148 "{file}" \u304C\u8FFD\u52A0\u3055\u308C\u307E\u3057\u305F',
1673
+ "diff.testSummary": " ... \u4ED6 {count}\u4EF6\u306E\u30C6\u30B9\u30C8/\u30D5\u30A3\u30AF\u30B9\u30C1\u30E3\u30D5\u30A1\u30A4\u30EB",
1674
+ "diff.testAffectedSummary": " ... \u4ED6 {count}\u4EF6\u306E\u30C6\u30B9\u30C8/\u30D5\u30A3\u30AF\u30B9\u30C1\u30E3\u95A2\u9023\u306E\u78BA\u8A8D\u9805\u76EE",
1665
1675
  // Search
1666
1676
  "search.pathMatch": '\u30D1\u30B9\u304C "{pattern}" \u306B\u30DE\u30C3\u30C1',
1667
1677
  "search.affected": '"{file}" \u306E\u5909\u66F4\u306B\u3088\u308A\u5F71\u97FF\u3092\u53D7\u3051\u308B\u53EF\u80FD\u6027\uFF08\u7D4C\u7531: {via}\uFF09',
@@ -1723,6 +1733,12 @@ var ja = {
1723
1733
  "web.watching": "{dir}/ \u3092\u76E3\u8996\u4E2D...",
1724
1734
  "web.reloading": "\u30D5\u30A1\u30A4\u30EB\u5909\u66F4\u3092\u691C\u51FA\u3001\u30EA\u30ED\u30FC\u30C9\u4E2D...",
1725
1735
  "web.reloaded": "\u30B0\u30E9\u30D5\u3092\u66F4\u65B0\u3057\u307E\u3057\u305F",
1736
+ // History
1737
+ "history.title": "# \u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u5C65\u6B74\n",
1738
+ "history.empty": "\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002`archtracker init` \u3067\u4F5C\u6210\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
1739
+ "history.entry": " {ts} | {files}\u30D5\u30A1\u30A4\u30EB, {edges}\u30A8\u30C3\u30B8, \u5FAA\u74B0{circular}\u4EF6{layers}",
1740
+ "history.count": "{count}\u4EF6\u306E\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3092\u8A18\u9332",
1741
+ "history.snapshotNotFound": "\u6307\u5B9A\u306E\u30BF\u30A4\u30E0\u30B9\u30BF\u30F3\u30D7\u306E\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: {ts}",
1726
1742
  // Errors
1727
1743
  "error.analyzer": "[\u89E3\u6790\u30A8\u30E9\u30FC] {message}",
1728
1744
  "error.storage": "[\u30B9\u30C8\u30EC\u30FC\u30B8\u30A8\u30E9\u30FC] {message}",
@@ -1807,14 +1823,14 @@ var LAYER_COLORS = [
1807
1823
  "#ffa657",
1808
1824
  "#7ee787"
1809
1825
  ];
1810
- async function analyzeMultiLayer(projectRoot, layerDefs) {
1826
+ async function analyzeMultiLayer(projectRoot, layerDefs, globalExclude) {
1811
1827
  const layers = {};
1812
1828
  const layerMetadata = [];
1813
1829
  for (let idx = 0; idx < layerDefs.length; idx++) {
1814
1830
  const def = layerDefs[idx];
1815
1831
  const targetDir = resolve4(projectRoot, def.targetDir);
1816
1832
  const graph = await analyzeProject(targetDir, {
1817
- exclude: def.exclude,
1833
+ exclude: def.exclude ?? globalExclude,
1818
1834
  language: def.language
1819
1835
  });
1820
1836
  const language = def.language ?? await detectLanguage(targetDir) ?? "javascript";
@@ -2246,11 +2262,240 @@ function isNodeError(error) {
2246
2262
  return error instanceof Error && "code" in error;
2247
2263
  }
2248
2264
 
2265
+ // src/storage/graph-cache.ts
2266
+ import { readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2, stat as stat4 } from "fs/promises";
2267
+ import { join as join6, resolve as resolve5 } from "path";
2268
+ import { readdir as readdir3 } from "fs/promises";
2269
+ import { createHash } from "crypto";
2270
+ var CACHE_FILE = "graph.json";
2271
+ var ARCHTRACKER_DIR2 = ".archtracker";
2272
+ async function hashFile(filePath) {
2273
+ const content = await readFile3(filePath);
2274
+ return createHash("sha256").update(content).digest("hex");
2275
+ }
2276
+ async function collectFileFingerprints(dir, exclude = []) {
2277
+ const fingerprints = {};
2278
+ const excludeRegexes = exclude.map((p) => new RegExp(p));
2279
+ async function walk(currentDir) {
2280
+ let entries;
2281
+ try {
2282
+ entries = await readdir3(currentDir, { withFileTypes: true });
2283
+ } catch {
2284
+ return;
2285
+ }
2286
+ for (const entry of entries) {
2287
+ const fullPath = join6(currentDir, entry.name);
2288
+ const relativePath = fullPath.slice(dir.length + 1);
2289
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
2290
+ if (excludeRegexes.some((r) => r.test(relativePath))) continue;
2291
+ if (entry.isDirectory()) {
2292
+ await walk(fullPath);
2293
+ } else if (entry.isFile()) {
2294
+ try {
2295
+ const s = await stat4(fullPath);
2296
+ const hash = await hashFile(fullPath);
2297
+ fingerprints[relativePath] = { mtime: s.mtimeMs, hash };
2298
+ } catch {
2299
+ }
2300
+ }
2301
+ }
2302
+ }
2303
+ await walk(dir);
2304
+ return fingerprints;
2305
+ }
2306
+ async function saveGraphCache(projectRoot, graph, options, extra) {
2307
+ const absRoot = resolve5(projectRoot);
2308
+ const dir = join6(absRoot, ARCHTRACKER_DIR2);
2309
+ await mkdir2(dir, { recursive: true });
2310
+ let fingerprints;
2311
+ if (extra?.layerDirs?.length) {
2312
+ fingerprints = {};
2313
+ for (const layerDir of extra.layerDirs) {
2314
+ const layerPath = resolve5(absRoot, layerDir);
2315
+ const layerFp = await collectFileFingerprints(layerPath, options.exclude);
2316
+ for (const [key, value] of Object.entries(layerFp)) {
2317
+ fingerprints[`${layerDir}/${key}`] = value;
2318
+ }
2319
+ }
2320
+ } else {
2321
+ fingerprints = await collectFingerprintsForGraph(absRoot, options);
2322
+ }
2323
+ let layersJsonHash;
2324
+ try {
2325
+ layersJsonHash = await hashFile(join6(dir, "layers.json"));
2326
+ } catch {
2327
+ }
2328
+ const mtimes = {};
2329
+ for (const [k, v] of Object.entries(fingerprints)) {
2330
+ mtimes[k] = v.mtime;
2331
+ }
2332
+ const cache = {
2333
+ version: "1.0",
2334
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2335
+ options: {
2336
+ targetDir: options.targetDir,
2337
+ projectRoot: options.projectRoot,
2338
+ language: options.language,
2339
+ exclude: options.exclude
2340
+ },
2341
+ fileMtimes: mtimes,
2342
+ fileHashes: Object.fromEntries(
2343
+ Object.entries(fingerprints).map(([k, v]) => [k, v.hash])
2344
+ ),
2345
+ graph,
2346
+ multiLayer: extra?.multiLayer,
2347
+ layerMetadata: extra?.layerMetadata,
2348
+ layerDirs: extra?.layerDirs,
2349
+ layersJsonHash
2350
+ };
2351
+ await writeFile2(join6(dir, CACHE_FILE), JSON.stringify(cache), "utf-8");
2352
+ }
2353
+ async function loadGraphCache(projectRoot) {
2354
+ const absRoot = resolve5(projectRoot);
2355
+ const filePath = join6(absRoot, ARCHTRACKER_DIR2, CACHE_FILE);
2356
+ try {
2357
+ const raw = await readFile3(filePath, "utf-8");
2358
+ const data = JSON.parse(raw);
2359
+ if (data?.version !== "1.0" || !data?.graph || !data?.fileMtimes) {
2360
+ return null;
2361
+ }
2362
+ return data;
2363
+ } catch {
2364
+ return null;
2365
+ }
2366
+ }
2367
+ async function isGraphCacheValid(cache, projectRoot, options) {
2368
+ if (cache.options.targetDir !== options.targetDir || cache.options.projectRoot !== options.projectRoot || (cache.options.language ?? "") !== (options.language ?? "") || JSON.stringify(cache.options.exclude ?? []) !== JSON.stringify(options.exclude ?? [])) {
2369
+ return false;
2370
+ }
2371
+ const absRoot = resolve5(projectRoot);
2372
+ const targetPath = resolve5(absRoot, options.targetDir);
2373
+ const isMultiLayer = (cache.layerDirs?.length ?? 0) > 0;
2374
+ let currentLayersHash;
2375
+ try {
2376
+ currentLayersHash = await hashFile(join6(absRoot, ARCHTRACKER_DIR2, "layers.json"));
2377
+ } catch {
2378
+ }
2379
+ if ((cache.layersJsonHash ?? "") !== (currentLayersHash ?? "")) return false;
2380
+ let currentMtimes;
2381
+ if (isMultiLayer) {
2382
+ currentMtimes = {};
2383
+ for (const layerDir of cache.layerDirs) {
2384
+ const layerPath = resolve5(absRoot, layerDir);
2385
+ const dirMtimes = await collectFileMtimes(layerPath, options.exclude);
2386
+ for (const [key, value] of Object.entries(dirMtimes)) {
2387
+ currentMtimes[`${layerDir}/${key}`] = value;
2388
+ }
2389
+ }
2390
+ } else {
2391
+ currentMtimes = await collectFileMtimes(targetPath, options.exclude);
2392
+ }
2393
+ const cachedKeys = new Set(Object.keys(cache.fileMtimes));
2394
+ const currentKeys = new Set(Object.keys(currentMtimes));
2395
+ if (cachedKeys.size !== currentKeys.size) return false;
2396
+ for (const key of cachedKeys) {
2397
+ if (!currentKeys.has(key)) return false;
2398
+ }
2399
+ const mtimeChanged = [];
2400
+ for (const key of cachedKeys) {
2401
+ if (Math.abs(cache.fileMtimes[key] - currentMtimes[key]) > 1) {
2402
+ mtimeChanged.push(key);
2403
+ }
2404
+ }
2405
+ if (mtimeChanged.length === 0) return true;
2406
+ if (!cache.fileHashes) return false;
2407
+ for (const key of mtimeChanged) {
2408
+ const cachedHash = cache.fileHashes[key];
2409
+ if (!cachedHash) return false;
2410
+ try {
2411
+ const fullPath = isMultiLayer ? join6(absRoot, key) : join6(targetPath, key);
2412
+ const currentHash = await hashFile(fullPath);
2413
+ if (currentHash !== cachedHash) return false;
2414
+ } catch {
2415
+ return false;
2416
+ }
2417
+ }
2418
+ return true;
2419
+ }
2420
+ async function collectFileMtimes(dir, exclude) {
2421
+ const mtimes = {};
2422
+ const excludeRegexes = (exclude ?? []).map((p) => new RegExp(p));
2423
+ async function walk(currentDir) {
2424
+ let entries;
2425
+ try {
2426
+ entries = await readdir3(currentDir, { withFileTypes: true });
2427
+ } catch {
2428
+ return;
2429
+ }
2430
+ for (const entry of entries) {
2431
+ const fullPath = join6(currentDir, entry.name);
2432
+ const relativePath = fullPath.slice(dir.length + 1);
2433
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
2434
+ if (excludeRegexes.some((r) => r.test(relativePath))) continue;
2435
+ if (entry.isDirectory()) {
2436
+ await walk(fullPath);
2437
+ } else if (entry.isFile()) {
2438
+ try {
2439
+ const s = await stat4(fullPath);
2440
+ mtimes[relativePath] = s.mtimeMs;
2441
+ } catch {
2442
+ }
2443
+ }
2444
+ }
2445
+ }
2446
+ await walk(dir);
2447
+ return mtimes;
2448
+ }
2449
+ async function collectFingerprintsForGraph(absRoot, options) {
2450
+ const targetPath = resolve5(absRoot, options.targetDir);
2451
+ return collectFileFingerprints(targetPath, options.exclude);
2452
+ }
2453
+
2249
2454
  // src/analyzer/resolve.ts
2250
2455
  async function resolveGraph(opts) {
2456
+ if (!opts.noCache) {
2457
+ const cache = await loadGraphCache(opts.projectRoot);
2458
+ if (cache) {
2459
+ const valid = await isGraphCacheValid(cache, opts.projectRoot, {
2460
+ targetDir: opts.targetDir,
2461
+ projectRoot: opts.projectRoot,
2462
+ language: opts.language,
2463
+ exclude: opts.exclude
2464
+ });
2465
+ if (valid) {
2466
+ const result2 = {
2467
+ graph: cache.graph,
2468
+ multiLayer: cache.multiLayer,
2469
+ layerMetadata: cache.layerMetadata,
2470
+ fromCache: true
2471
+ };
2472
+ if (cache.multiLayer) {
2473
+ const layerConfig2 = await loadLayerConfig(opts.projectRoot);
2474
+ if (layerConfig2) {
2475
+ const autoConnections = detectCrossLayerConnections(
2476
+ cache.multiLayer.layers,
2477
+ layerConfig2.layers
2478
+ );
2479
+ const manualConnections = layerConfig2.connections ?? [];
2480
+ const manualKeys = new Set(manualConnections.map(
2481
+ (c) => `${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`
2482
+ ));
2483
+ result2.crossLayerEdges = [
2484
+ ...manualConnections,
2485
+ ...autoConnections.filter(
2486
+ (c) => !manualKeys.has(`${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`)
2487
+ )
2488
+ ];
2489
+ }
2490
+ }
2491
+ return result2;
2492
+ }
2493
+ }
2494
+ }
2251
2495
  const layerConfig = await loadLayerConfig(opts.projectRoot);
2496
+ let result;
2252
2497
  if (layerConfig) {
2253
- const multi = await analyzeMultiLayer(opts.projectRoot, layerConfig.layers);
2498
+ const multi = await analyzeMultiLayer(opts.projectRoot, layerConfig.layers, opts.exclude);
2254
2499
  const autoConnections = detectCrossLayerConnections(multi.layers, layerConfig.layers);
2255
2500
  const manualConnections = layerConfig.connections ?? [];
2256
2501
  const manualKeys = new Set(manualConnections.map(
@@ -2262,31 +2507,52 @@ async function resolveGraph(opts) {
2262
2507
  (c) => !manualKeys.has(`${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`)
2263
2508
  )
2264
2509
  ];
2265
- return {
2510
+ result = {
2266
2511
  graph: multi.merged,
2267
2512
  multiLayer: multi,
2268
2513
  layerMetadata: multi.layerMetadata,
2269
2514
  crossLayerEdges: merged
2270
2515
  };
2516
+ } else {
2517
+ const graph = await analyzeProject(opts.targetDir, {
2518
+ exclude: opts.exclude,
2519
+ language: opts.language
2520
+ });
2521
+ result = { graph };
2271
2522
  }
2272
- const graph = await analyzeProject(opts.targetDir, {
2273
- exclude: opts.exclude,
2274
- language: opts.language
2275
- });
2276
- return { graph };
2523
+ try {
2524
+ await saveGraphCache(
2525
+ opts.projectRoot,
2526
+ result.graph,
2527
+ {
2528
+ targetDir: opts.targetDir,
2529
+ projectRoot: opts.projectRoot,
2530
+ language: opts.language,
2531
+ exclude: opts.exclude
2532
+ },
2533
+ {
2534
+ multiLayer: result.multiLayer,
2535
+ layerMetadata: result.layerMetadata,
2536
+ layerDirs: layerConfig?.layers.map((l) => l.targetDir)
2537
+ }
2538
+ );
2539
+ } catch {
2540
+ }
2541
+ return result;
2277
2542
  }
2278
2543
 
2279
2544
  // src/storage/snapshot.ts
2280
- import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile3, access } from "fs/promises";
2281
- import { join as join6 } from "path";
2545
+ import { mkdir as mkdir3, writeFile as writeFile3, readFile as readFile4, readdir as readdir4, access } from "fs/promises";
2546
+ import { join as join7 } from "path";
2282
2547
  import { z as z2 } from "zod";
2283
2548
 
2284
2549
  // src/types/schema.ts
2285
2550
  var SCHEMA_VERSION = "1.1";
2286
2551
 
2287
2552
  // src/storage/snapshot.ts
2288
- var ARCHTRACKER_DIR2 = ".archtracker";
2553
+ var ARCHTRACKER_DIR3 = ".archtracker";
2289
2554
  var SNAPSHOT_FILE = "snapshot.json";
2555
+ var HISTORY_DIR = "history";
2290
2556
  var FileNodeSchema = z2.object({
2291
2557
  path: z2.string(),
2292
2558
  exists: z2.boolean(),
@@ -2311,25 +2577,34 @@ var SnapshotSchema = z2.object({
2311
2577
  rootDir: z2.string(),
2312
2578
  graph: DependencyGraphSchema
2313
2579
  });
2314
- async function saveSnapshot(projectRoot, graph, multiLayer) {
2315
- const dirPath = join6(projectRoot, ARCHTRACKER_DIR2);
2316
- const filePath = join6(dirPath, SNAPSHOT_FILE);
2580
+ async function saveSnapshot(projectRoot, graph, multiLayer, analysisOptions) {
2581
+ const dirPath = join7(projectRoot, ARCHTRACKER_DIR3);
2582
+ const filePath = join7(dirPath, SNAPSHOT_FILE);
2317
2583
  const snapshot = {
2318
2584
  version: SCHEMA_VERSION,
2319
2585
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2320
2586
  rootDir: graph.rootDir,
2321
2587
  graph,
2322
- ...multiLayer ? { multiLayer } : {}
2588
+ ...multiLayer ? { multiLayer } : {},
2589
+ ...analysisOptions ? { analysisOptions } : {}
2323
2590
  };
2324
- await mkdir2(dirPath, { recursive: true });
2325
- await writeFile2(filePath, JSON.stringify(snapshot, null, 2), "utf-8");
2591
+ await mkdir3(dirPath, { recursive: true });
2592
+ const json = JSON.stringify(snapshot, null, 2);
2593
+ await writeFile3(filePath, json, "utf-8");
2594
+ try {
2595
+ const historyPath = join7(dirPath, HISTORY_DIR);
2596
+ await mkdir3(historyPath, { recursive: true });
2597
+ const safeTs = snapshot.timestamp.replace(/[:.]/g, "-");
2598
+ await writeFile3(join7(historyPath, `${safeTs}.json`), json, "utf-8");
2599
+ } catch {
2600
+ }
2326
2601
  return snapshot;
2327
2602
  }
2328
2603
  async function loadSnapshot(projectRoot) {
2329
- const filePath = join6(projectRoot, ARCHTRACKER_DIR2, SNAPSHOT_FILE);
2604
+ const filePath = join7(projectRoot, ARCHTRACKER_DIR3, SNAPSHOT_FILE);
2330
2605
  let raw;
2331
2606
  try {
2332
- raw = await readFile3(filePath, "utf-8");
2607
+ raw = await readFile4(filePath, "utf-8");
2333
2608
  } catch (error) {
2334
2609
  if (isNodeError2(error) && error.code === "ENOENT") {
2335
2610
  return null;
@@ -2356,6 +2631,67 @@ async function loadSnapshot(projectRoot) {
2356
2631
  }
2357
2632
  return result.data;
2358
2633
  }
2634
+ async function listSnapshots(projectRoot) {
2635
+ const historyPath = join7(projectRoot, ARCHTRACKER_DIR3, HISTORY_DIR);
2636
+ let entries;
2637
+ try {
2638
+ entries = await readdir4(historyPath);
2639
+ } catch {
2640
+ return [];
2641
+ }
2642
+ const jsonFiles = entries.filter((e) => e.endsWith(".json")).sort().reverse();
2643
+ const summaries = [];
2644
+ for (const file of jsonFiles) {
2645
+ try {
2646
+ const raw = await readFile4(join7(historyPath, file), "utf-8");
2647
+ const parsed = JSON.parse(raw);
2648
+ const result = SnapshotSchema.safeParse(parsed);
2649
+ if (result.success) {
2650
+ const snap = result.data;
2651
+ summaries.push({
2652
+ timestamp: snap.timestamp,
2653
+ totalFiles: snap.graph.totalFiles,
2654
+ totalEdges: snap.graph.totalEdges,
2655
+ circularDeps: snap.graph.circularDependencies.length,
2656
+ hasMultiLayer: "multiLayer" in parsed && parsed.multiLayer != null
2657
+ });
2658
+ }
2659
+ } catch {
2660
+ }
2661
+ }
2662
+ return summaries;
2663
+ }
2664
+ async function loadSnapshotByTimestamp(projectRoot, timestamp) {
2665
+ const historyPath = join7(projectRoot, ARCHTRACKER_DIR3, HISTORY_DIR);
2666
+ let entries;
2667
+ try {
2668
+ entries = await readdir4(historyPath);
2669
+ } catch {
2670
+ return null;
2671
+ }
2672
+ const jsonFiles = entries.filter((e) => e.endsWith(".json")).sort();
2673
+ const safeTs = timestamp.replace(/[:.]/g, "-");
2674
+ const exactMatch = jsonFiles.find((f) => f === `${safeTs}.json`);
2675
+ if (exactMatch) {
2676
+ return loadHistoryFile(join7(historyPath, exactMatch));
2677
+ }
2678
+ const prefixMatch = jsonFiles.find((f) => f.startsWith(safeTs.slice(0, 10)));
2679
+ if (prefixMatch) {
2680
+ return loadHistoryFile(join7(historyPath, prefixMatch));
2681
+ }
2682
+ return null;
2683
+ }
2684
+ async function loadHistoryFile(filePath) {
2685
+ try {
2686
+ const raw = await readFile4(filePath, "utf-8");
2687
+ const parsed = JSON.parse(raw);
2688
+ const result = SnapshotSchema.safeParse(parsed);
2689
+ if (result.success) return result.data;
2690
+ return null;
2691
+ } catch {
2692
+ return null;
2693
+ }
2694
+ }
2359
2695
  var StorageError = class extends Error {
2360
2696
  constructor(message, options) {
2361
2697
  super(message, options);
@@ -2417,6 +2753,17 @@ function computeDiff(oldGraph, newGraph) {
2417
2753
  }
2418
2754
  return { added, removed, modified, affectedDependents };
2419
2755
  }
2756
+ function isTestOrFixture(path) {
2757
+ return /(__fixtures__|__tests__|__mocks__|\.test\.|\.spec\.|\.e2e\.)/.test(path);
2758
+ }
2759
+ function partition(arr, pred) {
2760
+ const yes = [];
2761
+ const no = [];
2762
+ for (const item of arr) {
2763
+ (pred(item) ? yes : no).push(item);
2764
+ }
2765
+ return [yes, no];
2766
+ }
2420
2767
  function formatDiffReport(diff) {
2421
2768
  const lines = [];
2422
2769
  lines.push(t("diff.title"));
@@ -2425,32 +2772,51 @@ function formatDiffReport(diff) {
2425
2772
  return lines.join("\n");
2426
2773
  }
2427
2774
  if (diff.added.length > 0) {
2775
+ const [testFiles, srcFiles] = partition(diff.added, isTestOrFixture);
2428
2776
  lines.push(t("diff.added", { count: diff.added.length }));
2429
- for (const f of diff.added) {
2777
+ for (const f of srcFiles) {
2430
2778
  lines.push(` + ${f}`);
2431
2779
  }
2780
+ if (testFiles.length > 0) {
2781
+ lines.push(t("diff.testSummary", { count: testFiles.length }));
2782
+ }
2432
2783
  lines.push("");
2433
2784
  }
2434
2785
  if (diff.removed.length > 0) {
2786
+ const [testFiles, srcFiles] = partition(diff.removed, isTestOrFixture);
2435
2787
  lines.push(t("diff.removed", { count: diff.removed.length }));
2436
- for (const f of diff.removed) {
2788
+ for (const f of srcFiles) {
2437
2789
  lines.push(` - ${f}`);
2438
2790
  }
2791
+ if (testFiles.length > 0) {
2792
+ lines.push(t("diff.testSummary", { count: testFiles.length }));
2793
+ }
2439
2794
  lines.push("");
2440
2795
  }
2441
2796
  if (diff.modified.length > 0) {
2797
+ const [testFiles, srcFiles] = partition(diff.modified, isTestOrFixture);
2442
2798
  lines.push(t("diff.modified", { count: diff.modified.length }));
2443
- for (const f of diff.modified) {
2799
+ for (const f of srcFiles) {
2444
2800
  lines.push(` ~ ${f}`);
2445
2801
  }
2802
+ if (testFiles.length > 0) {
2803
+ lines.push(t("diff.testSummary", { count: testFiles.length }));
2804
+ }
2446
2805
  lines.push("");
2447
2806
  }
2448
2807
  if (diff.affectedDependents.length > 0) {
2808
+ const [testEntries, srcEntries] = partition(
2809
+ diff.affectedDependents,
2810
+ (a) => isTestOrFixture(a.file) || isTestOrFixture(a.dependsOn)
2811
+ );
2449
2812
  lines.push(t("diff.affected", { count: diff.affectedDependents.length }));
2450
- for (const a of diff.affectedDependents) {
2813
+ for (const a of srcEntries) {
2451
2814
  lines.push(` ! ${a.file}`);
2452
2815
  lines.push(` ${a.reason}`);
2453
2816
  }
2817
+ if (testEntries.length > 0) {
2818
+ lines.push(t("diff.testAffectedSummary", { count: testEntries.length }));
2819
+ }
2454
2820
  lines.push("");
2455
2821
  }
2456
2822
  return lines.join("\n");
@@ -2466,6 +2832,11 @@ function arraysEqual(a, b) {
2466
2832
  // src/web/server.ts
2467
2833
  import { createServer } from "http";
2468
2834
 
2835
+ // src/web/template.ts
2836
+ import { readFileSync as readFileSync3 } from "fs";
2837
+ import { join as join8, dirname as dirname2 } from "path";
2838
+ import { fileURLToPath } from "url";
2839
+
2469
2840
  // src/web/styles.ts
2470
2841
  function buildStyles() {
2471
2842
  return `<style>
@@ -2798,676 +3169,33 @@ function buildViewerHtml() {
2798
3169
  </div>`;
2799
3170
  }
2800
3171
 
2801
- // src/web/js-hierarchy.ts
2802
- function buildHierarchyJs() {
2803
- return `
2804
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2805
- // HIERARCHY VIEW
2806
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2807
- function buildHierarchy(){
2808
- const hSvg=d3.select('#hier-svg');
2809
- const hG=hSvg.append('g');
2810
- const hZoom=d3.zoom().scaleExtent([0.1,4]).on('zoom',e=>hG.attr('transform',e.transform));
2811
- hSvg.call(hZoom);
2812
-
2813
- const nodeMap={}; DATA.nodes.forEach(n=>nodeMap[n.id]=n);
2814
- 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);});
2815
-
2816
- const entryPoints=DATA.nodes.filter(n=>n.dependents===0).map(n=>n.id);
2817
- const layers={};const visited=new Set();
2818
- const queue=entryPoints.map(id=>({id,layer:0}));
2819
- DATA.nodes.forEach(n=>{if(n.isOrphan)layers[n.id]=0;});
2820
-
2821
- while(queue.length>0){
2822
- const{id,layer}=queue.shift();
2823
- if(visited.has(id)&&(layers[id]??-1)>=layer)continue;
2824
- layers[id]=Math.max(layers[id]??0,layer);visited.add(id);
2825
- (importsMap[id]||[]).forEach(t=>queue.push({id:t,layer:layer+1}));
2826
- }
2827
- DATA.nodes.forEach(n=>{if(!(n.id in layers))layers[n.id]=0;});
2828
-
2829
- const maxLayer=Math.max(0,...Object.values(layers));
2830
- const layerGroups={};
2831
- for(let i=0;i<=maxLayer;i++)layerGroups[i]=[];
2832
- Object.entries(layers).forEach(([id,l])=>layerGroups[l].push(id));
2833
- Object.values(layerGroups).forEach(arr=>arr.sort((a,b)=>(nodeMap[a]?.dir||'').localeCompare(nodeMap[b]?.dir||'')||a.localeCompare(b)));
2834
-
2835
- const boxW=200,boxH=30,gapX=24,gapY=70,padY=60,padX=40;
2836
- const positions={};let maxRowWidth=0;
2837
- for(let layer=0;layer<=maxLayer;layer++){const items=layerGroups[layer];maxRowWidth=Math.max(maxRowWidth,items.length*(boxW+gapX)-gapX);}
2838
- for(let layer=0;layer<=maxLayer;layer++){
2839
- const items=layerGroups[layer],rowWidth=items.length*(boxW+gapX)-gapX,startX=padX+(maxRowWidth-rowWidth)/2;
2840
- items.forEach((id,i)=>{positions[id]={x:startX+i*(boxW+gapX),y:padY+layer*(boxH+gapY)};});
2841
- }
2842
-
2843
- const totalW=maxRowWidth+padX*2,totalH=padY*2+(maxLayer+1)*(boxH+gapY);
2844
- hSvg.attr('width',Math.max(totalW,W)).attr('height',Math.max(totalH,H));
2845
-
2846
- const linkG=hG.append('g');
2847
- DATA.links.forEach(l=>{
2848
- const sId=l.source.id??l.source,tId=l.target.id??l.target;
2849
- const s=positions[sId],t=positions[tId]; if(!s||!t)return;
2850
- const x1=s.x+boxW/2,y1=s.y+boxH,x2=t.x+boxW/2,y2=t.y,midY=(y1+y2)/2;
2851
- linkG.append('path').attr('class','hier-link')
2852
- .attr('d',\`M\${x1},\${y1} C\${x1},\${midY} \${x2},\${midY} \${x2},\${y2}\`)
2853
- .attr('stroke',l.type==='type-only'?'#1f3d5c':'var(--border)')
2854
- .attr('stroke-dasharray',l.type==='type-only'?'4,3':null)
2855
- .attr('data-source',sId).attr('data-target',tId);
2856
- });
2857
-
2858
- hSvg.append('defs').append('marker').attr('id','harrow').attr('viewBox','0 -3 6 6')
2859
- .attr('refX',6).attr('refY',0).attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
2860
- .append('path').attr('d','M0,-3L6,0L0,3Z').attr('fill','var(--border)');
2861
- linkG.selectAll('path').attr('marker-end','url(#harrow)');
2862
-
2863
- for(let layer=0;layer<=maxLayer;layer++){
2864
- if(!layerGroups[layer].length)continue;
2865
- hG.append('text').attr('class','hier-layer-label').attr('font-size',11)
2866
- .attr('data-depth-idx',layer)
2867
- .attr('x',12).attr('y',padY+layer*(boxH+gapY)+boxH/2+4).text('L'+layer);
2868
- }
2869
-
2870
- const nodeG=hG.append('g');
2871
- DATA.nodes.forEach(n=>{
2872
- const pos=positions[n.id]; if(!pos)return;
2873
- const gn=nodeG.append('g').attr('class','hier-node').attr('transform',\`translate(\${pos.x},\${pos.y})\`);
2874
- gn.append('rect').attr('width',boxW).attr('height',boxH)
2875
- .attr('fill','var(--bg-card)').attr('stroke',nodeColor(n))
2876
- .attr('stroke-width',circularSet.has(n.id)?2:1.5);
2877
- gn.append('text').attr('x',8).attr('y',boxH/2+4).attr('font-size',11)
2878
- .text(fileName(n.id).length>24?fileName(n.id).slice(0,22)+'\\u2026':fileName(n.id));
2879
- gn.append('text').attr('x',boxW-8).attr('y',boxH/2+4)
2880
- .attr('text-anchor','end').attr('font-size',10).attr('fill','var(--text-muted)')
2881
- .text(n.dependents>0?'\\u2191'+n.dependents:'');
2882
- gn.append('text').attr('x',8).attr('y',-4).attr('font-size',9)
2883
- .attr('fill',dirColor(n.dir)).attr('opacity',0.7).text(n.dir);
2884
-
2885
- gn.node().__data_id=n.id;
2886
- gn.on('mouseover',e=>{
2887
- showTooltip(e,n);
2888
- if (!hierPinned) hierHighlight(n.id);
2889
- })
2890
- .on('mousemove',e=>positionTooltip(e))
2891
- .on('mouseout',()=>{
2892
- scheduleHideTooltip();
2893
- if (!hierPinned) hierResetHighlight();
2894
- })
2895
- .on('click',(e)=>{
2896
- e.stopPropagation();
2897
- hierPinned=n.id;
2898
- hierHighlight(n.id);
2899
- showHierDetail(n);
2900
- });
2901
- });
2902
-
2903
- // Hierarchy highlight helpers
2904
- let hierPinned=null;
2905
- function hierHighlight(nId){
2906
- linkG.selectAll('path')
2907
- .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)';})
2908
- .attr('stroke-width',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');return(s===nId||t===nId)?2.5:1;})
2909
- .attr('opacity',function(){const s=this.getAttribute('data-source'),t=this.getAttribute('data-target');return(s===nId||t===nId)?1:0.15;});
2910
- nodeG.selectAll('.hier-node').attr('opacity',function(){
2911
- const id=this.__data_id; if(id===nId)return 1;
2912
- 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);});
2913
- return connected?1:0.3;
2914
- });
2915
- }
2916
- function hierResetHighlight(){
2917
- hierPinned=null;
2918
- linkG.selectAll('path')
2919
- .attr('stroke',function(){return this.getAttribute('stroke-dasharray')?'#1f3d5c':'var(--border)';})
2920
- .attr('stroke-width',1).attr('opacity',1);
2921
- nodeG.selectAll('.hier-node').attr('opacity',1);
2922
- }
2923
- function showHierDetail(n){
2924
- const p=document.getElementById('hier-detail');
2925
- document.getElementById('hd-name').textContent=n.id;
2926
- document.getElementById('hd-meta').innerHTML=i('detail.dir')+': '+esc(n.dir)+'<br>'+i('detail.dependencies')+': '+n.deps+' \\u00b7 '+i('detail.dependents')+': '+n.dependents;
2927
- document.getElementById('hd-dependents').innerHTML=(n.dependentsList||[]).map(x=>'<li>\\u2190 '+esc(x)+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
2928
- document.getElementById('hd-deps').innerHTML=(n.dependencies||[]).map(x=>'<li>\\u2192 '+esc(x)+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
2929
- p.classList.add('open');
2930
- }
2931
- window.closeHierDetail=()=>{document.getElementById('hier-detail').classList.remove('open');hierResetHighlight();tooltip.style.display='none';tooltipLocked=false;};
2932
-
2933
- // Click on empty space to deselect
2934
- hSvg.on('click',()=>{closeHierDetail();});
2935
-
2936
- // Hierarchy filters \u2014 layer pills or dir pills
2937
- const hFilterRow=document.getElementById('hier-filter-row');
2938
- const hFilterBar=document.getElementById('hier-filter-bar');
2939
- if (hFilterBar) hFilterBar.style.display='';
2940
- const hActiveLayers=new Set(); // empty = show all (same as graph view)
2941
-
2942
- function hierRelayoutInner() {
2943
- function isVisible(nId) {
2944
- var nd = nodeMap[nId];
2945
- if (!nd) return false;
2946
- if (LAYERS && nd.layer && hActiveLayers.size > 0 && !hActiveLayers.has(nd.layer)) return false;
2947
- return true;
2948
- }
2949
-
2950
- // Build visible layer groups and compact Y positions
2951
- var visibleDepths = [];
2952
- var visLayerGroups = {};
2953
- for (var depth = 0; depth <= maxLayer; depth++) {
2954
- var visItems = layerGroups[depth].filter(function(id) { return isVisible(id); });
2955
- if (visItems.length > 0) {
2956
- visLayerGroups[depth] = visItems;
2957
- visibleDepths.push(depth);
2958
- }
2959
- }
2960
-
2961
- // Recalculate positions for visible nodes (compacted)
2962
- var newPositions = {};
2963
- var newMaxRowWidth = 0;
2964
- visibleDepths.forEach(function(depth) {
2965
- newMaxRowWidth = Math.max(newMaxRowWidth, visLayerGroups[depth].length * (boxW + gapX) - gapX);
2966
- });
2967
- visibleDepths.forEach(function(depth, yIdx) {
2968
- var items = visLayerGroups[depth];
2969
- var rowWidth = items.length * (boxW + gapX) - gapX;
2970
- var startX = padX + (newMaxRowWidth - rowWidth) / 2;
2971
- items.forEach(function(id, idx) {
2972
- newPositions[id] = { x: startX + idx * (boxW + gapX), y: padY + yIdx * (boxH + gapY) };
2973
- });
2974
- });
2975
-
2976
- // Update SVG size
2977
- var newTotalW = (newMaxRowWidth || 0) + padX * 2;
2978
- var newTotalH = padY * 2 + Math.max(1, visibleDepths.length) * (boxH + gapY);
2979
- hSvg.attr('width', Math.max(newTotalW, W)).attr('height', Math.max(newTotalH, H));
2980
-
2981
- // Update nodes: hide/show + transition positions
2982
- nodeG.selectAll('.hier-node').each(function() {
2983
- var nId = this.__data_id;
2984
- var el = d3.select(this);
2985
- if (!isVisible(nId) || !newPositions[nId]) {
2986
- el.attr('display', 'none');
2987
- } else {
2988
- el.attr('display', null)
2989
- .transition().duration(300)
2990
- .attr('transform', 'translate(' + newPositions[nId].x + ',' + newPositions[nId].y + ')');
2991
- }
2992
- });
2993
-
2994
- // Update links: show only if both endpoints visible, recalculate bezier
2995
- linkG.selectAll('path').each(function() {
2996
- var sId = this.getAttribute('data-source');
2997
- var tId = this.getAttribute('data-target');
2998
- var el = d3.select(this);
2999
- if (!isVisible(sId) || !isVisible(tId) || !newPositions[sId] || !newPositions[tId]) {
3000
- el.attr('display', 'none');
3001
- } else {
3002
- var s = newPositions[sId], t = newPositions[tId];
3003
- var x1 = s.x + boxW / 2, y1 = s.y + boxH;
3004
- var x2 = t.x + boxW / 2, y2 = t.y;
3005
- var midY = (y1 + y2) / 2;
3006
- el.attr('display', null)
3007
- .transition().duration(300)
3008
- .attr('d', 'M' + x1 + ',' + y1 + ' C' + x1 + ',' + midY + ' ' + x2 + ',' + midY + ' ' + x2 + ',' + y2);
3009
- }
3010
- });
3011
-
3012
- // Update depth labels: hide empty depths, reposition visible ones
3013
- hG.selectAll('.hier-layer-label').each(function() {
3014
- var depthIdx = +this.getAttribute('data-depth-idx');
3015
- var el = d3.select(this);
3016
- var yIdx = visibleDepths.indexOf(depthIdx);
3017
- if (yIdx === -1) {
3018
- el.attr('display', 'none');
3019
- } else {
3020
- el.attr('display', null)
3021
- .transition().duration(300)
3022
- .attr('y', padY + yIdx * (boxH + gapY) + boxH / 2 + 4);
3023
- }
3024
- });
3025
-
3026
- // Close detail panel if pinned node became hidden
3027
- if (hierPinned && !isVisible(hierPinned)) {
3028
- closeHierDetail();
3029
- }
3030
- }
3031
-
3032
- function hierSyncFromTabInner() {
3033
- if (!LAYERS) return;
3034
- hActiveLayers.clear();
3035
- activeLayers.forEach(function(name) { hActiveLayers.add(name); });
3036
- // Sync pill UI
3037
- hFilterRow.querySelectorAll('.layer-pill').forEach(function(p) {
3038
- var ln = p.dataset.layer;
3039
- if (ln === 'all') {
3040
- p.classList.toggle('active', hActiveLayers.size === 0);
3041
- } else {
3042
- p.classList.toggle('active', hActiveLayers.has(ln));
3043
- }
3044
- });
3045
- }
3046
-
3047
- if (LAYERS) {
3048
- // "All" button
3049
- const allPill=document.createElement('div');
3050
- allPill.className='layer-pill active';
3051
- allPill.style.fontWeight='400';
3052
- allPill.textContent='All';
3053
- allPill.dataset.layer='all';
3054
- allPill.onclick=()=>{
3055
- hActiveLayers.clear();
3056
- hFilterRow.querySelectorAll('.layer-pill').forEach(p=>p.classList.remove('active'));
3057
- allPill.classList.add('active');
3058
- hierRelayoutInner();
3059
- };
3060
- hFilterRow.appendChild(allPill);
3061
-
3062
- LAYERS.forEach(layer => {
3063
- const pill=document.createElement('div');
3064
- pill.className='layer-pill';
3065
- pill.dataset.layer=layer.name;
3066
- const count=DATA.nodes.filter(n=>n.layer===layer.name).length;
3067
- pill.innerHTML='<div class="lp-dot" style="background:'+esc(layer.color)+'"></div>'+esc(layer.name)+' <span class="lp-count">'+count+'</span>';
3068
- pill.onclick=(e)=>{
3069
- if (e.shiftKey) {
3070
- hActiveLayers.clear();
3071
- hActiveLayers.add(layer.name);
3072
- } else {
3073
- if (hActiveLayers.has(layer.name)) hActiveLayers.delete(layer.name);
3074
- else hActiveLayers.add(layer.name);
3075
- }
3076
- // Sync pill UI
3077
- hFilterRow.querySelectorAll('.layer-pill').forEach(function(p) {
3078
- var ln = p.dataset.layer;
3079
- if (ln === 'all') p.classList.toggle('active', hActiveLayers.size === 0);
3080
- else p.classList.toggle('active', hActiveLayers.has(ln));
3081
- });
3082
- hierRelayoutInner();
3083
- };
3084
- hFilterRow.appendChild(pill);
3085
- });
3086
- } else {
3087
- const hActiveDirs=new Set(DATA.dirs);
3088
- DATA.dirs.forEach(dir=>{
3089
- const pill=document.createElement('div');
3090
- pill.className='filter-pill active';
3091
- pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+esc(dir||'.')+' <span class="pill-count">'+(dirCounts[dir]||0)+'</span>';
3092
- pill.onclick=()=>{
3093
- if(hActiveDirs.has(dir)){hActiveDirs.delete(dir);pill.classList.remove('active');}
3094
- else{hActiveDirs.add(dir);pill.classList.add('active');}
3095
- nodeG.selectAll('.hier-node').attr('opacity',function(){const nId=this.__data_id;return hActiveDirs.has(nodeMap[nId]?.dir)?1:0.1;});
3096
- };
3097
- hFilterRow.appendChild(pill);
3098
- });
3099
- }
3100
-
3101
- // Assign function pointers for cross-view sync
3102
- hierRelayout = hierRelayoutInner;
3103
- hierSyncFromTab = hierSyncFromTabInner;
3104
-
3105
- hSvg.call(hZoom.transform,d3.zoomIdentity.translate(
3106
- Math.max(0,(W-totalW)/2),20
3107
- ).scale(Math.min(1,W/(totalW+40),H/(totalH+40))));
3108
-
3109
- // If layers were already filtered in graph view, sync hierarchy on first build
3110
- if (activeLayers.size > 0) {
3111
- hierSyncFromTabInner();
3112
- hierRelayoutInner();
3113
- }
3114
- }
3115
- `;
3172
+ // src/utils/html-escape.ts
3173
+ function escapeHtml(s) {
3174
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3116
3175
  }
3117
3176
 
3118
- // src/web/js-diff.ts
3119
- function buildDiffJs(diffData) {
3120
- return `
3121
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3122
- // DIFF VIEW
3123
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3124
- const DIFF = ${diffData};
3125
- if (DIFF) {
3126
- document.getElementById('diff-tab').style.display = '';
3127
- const addedSet = new Set(DIFF.added||[]);
3128
- const removedSet = new Set(DIFF.removed||[]);
3129
- const modifiedSet = new Set(DIFF.modified||[]);
3130
- const affectedSet = new Set((DIFF.affectedDependents||[]).map(a=>a.file));
3131
-
3132
- // Populate summary counts
3133
- document.getElementById('diff-added-count').textContent = addedSet.size;
3134
- document.getElementById('diff-removed-count').textContent = removedSet.size;
3135
- document.getElementById('diff-modified-count').textContent = modifiedSet.size;
3136
- document.getElementById('diff-affected-count').textContent = affectedSet.size;
3137
-
3138
- function isDiffNode(id) {
3139
- return addedSet.has(id) || removedSet.has(id) || modifiedSet.has(id) || affectedSet.has(id);
3140
- }
3141
-
3142
- function diffStatus(id) {
3143
- if (addedSet.has(id)) return 'Added';
3144
- if (removedSet.has(id)) return 'Removed';
3145
- if (modifiedSet.has(id)) return 'Modified';
3146
- if (affectedSet.has(id)) return 'Affected';
3147
- return 'Unchanged';
3148
- }
3149
-
3150
- function diffStatusColor(id) {
3151
- if (addedSet.has(id)) return 'var(--green)';
3152
- if (removedSet.has(id)) return 'var(--red)';
3153
- if (modifiedSet.has(id)) return 'var(--yellow)';
3154
- if (affectedSet.has(id)) return 'var(--accent)';
3155
- return 'var(--text-muted)';
3156
- }
3157
-
3158
- // Build reverse dependency map for impact chain
3159
- var diffRevMap = {};
3160
- DATA.links.forEach(function(l) {
3161
- var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
3162
- if (!diffRevMap[t]) diffRevMap[t] = [];
3163
- diffRevMap[t].push(s);
3164
- });
3165
-
3166
- function getImpactChain(startId) {
3167
- var result = new Set();
3168
- var queue = [startId];
3169
- while (queue.length) {
3170
- var id = queue.shift();
3171
- if (result.has(id)) continue;
3172
- result.add(id);
3173
- (diffRevMap[id] || []).forEach(function(x) { queue.push(x); });
3174
- }
3175
- return result;
3176
- }
3177
-
3178
- let diffFocusMode = false;
3179
- var dNode, dLink, dSim, simNodes, simLinks;
3180
-
3181
- window.toggleDiffFocus = function() {
3182
- diffFocusMode = !diffFocusMode;
3183
- var btn = document.getElementById('diff-focus-btn');
3184
- btn.classList.toggle('active', diffFocusMode);
3185
- btn.textContent = diffFocusMode ? i('diff.showAll') : i('diff.focusChanges');
3186
- if (!diffBuilt) return;
3187
- applyDiffFilter();
3188
- };
3189
-
3190
- window.closeDiffDetail = function() {
3191
- document.getElementById('diff-detail').style.display = 'none';
3192
- if (diffBuilt) resetDiffHighlight();
3193
- };
3194
-
3195
- function applyDiffFilter() {
3196
- dNode.attr('display', function(d) {
3197
- if (!diffFocusMode) return null;
3198
- return isDiffNode(d.id) ? null : 'none';
3199
- });
3200
- dNode.select('circle')
3201
- .attr('opacity', function(d) {
3202
- if (diffFocusMode) return isDiffNode(d.id) ? 1 : 0;
3203
- return isDiffNode(d.id) ? 1 : 0.12;
3204
- });
3205
- dNode.select('text')
3206
- .attr('opacity', function(d) {
3207
- if (diffFocusMode) return isDiffNode(d.id) ? 1 : 0;
3208
- return isDiffNode(d.id) ? 1 : 0.08;
3209
- });
3210
- dLink.attr('display', function(l) {
3211
- if (!diffFocusMode) return null;
3212
- var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
3213
- return (isDiffNode(s) && isDiffNode(t)) ? null : 'none';
3214
- });
3215
- dLink.attr('opacity', function(l) {
3216
- var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
3217
- if (isDiffNode(s) && isDiffNode(t)) return 0.6;
3218
- if (isDiffNode(s) || isDiffNode(t)) return 0.15;
3219
- return diffFocusMode ? 0 : 0.05;
3220
- });
3221
- dLink.attr('stroke', function(l) {
3222
- var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
3223
- if (isDiffNode(s) && isDiffNode(t)) return diffStatusColor(s);
3224
- return '#30363d';
3225
- });
3226
- dNode.select('circle')
3227
- .attr('stroke-width', function(d) { return isDiffNode(d.id) ? 3 : 1; });
3228
- }
3229
-
3230
- function resetDiffHighlight() {
3231
- applyDiffFilter();
3232
- }
3233
-
3234
- function highlightDiffImpact(d) {
3235
- var chain = getImpactChain(d.id);
3236
- dNode.select('circle').transition().duration(200)
3237
- .attr('opacity', function(n) { return chain.has(n.id) ? 1 : 0.04; })
3238
- .attr('stroke-width', function(n) { return chain.has(n.id) && n.id !== d.id ? 3 : isDiffNode(n.id) ? 3 : 1; })
3239
- .attr('stroke', function(n) { return chain.has(n.id) && n.id !== d.id ? 'var(--red)' : diffStatusColor(n.id); });
3240
- dNode.select('text').transition().duration(200)
3241
- .attr('opacity', function(n) { return chain.has(n.id) ? 1 : 0.03; });
3242
- dLink.transition().duration(200)
3243
- .attr('opacity', function(l) {
3244
- var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
3245
- return (chain.has(s) && chain.has(t)) ? 0.8 : 0.03;
3246
- })
3247
- .attr('stroke', function(l) {
3248
- var s = l.source.id ?? l.source, t = l.target.id ?? l.target;
3249
- return (chain.has(s) && chain.has(t)) ? 'var(--red)' : '#30363d';
3250
- });
3251
- return chain;
3252
- }
3253
-
3254
- function showDiffDetail(d) {
3255
- var panel = document.getElementById('diff-detail');
3256
- document.getElementById('dd-name').textContent = d.id;
3257
- var statusEl = document.getElementById('dd-status');
3258
- statusEl.textContent = diffStatus(d.id);
3259
- statusEl.style.color = diffStatusColor(d.id);
3260
- document.getElementById('dd-meta').innerHTML = i('detail.dir') + ': ' + esc(d.dir) + '<br>' + i('detail.dependencies') + ': ' + d.deps + ' \\u00b7 ' + i('detail.dependents') + ': ' + d.dependents;
3261
-
3262
- // Show impact chain
3263
- var chain = getImpactChain(d.id);
3264
- chain.delete(d.id);
3265
- var affectedList = document.getElementById('dd-affected');
3266
- if (chain.size > 0) {
3267
- affectedList.innerHTML = Array.from(chain).map(function(id) {
3268
- return '<li style="color:' + diffStatusColor(id) + '">\\u2190 ' + esc(id) + ' <span style="font-size:10px;color:var(--text-muted)">(' + diffStatus(id) + ')</span></li>';
3269
- }).join('');
3270
- } else {
3271
- affectedList.innerHTML = '<li style="color:var(--text-muted)">' + i('diff.noImpact') + '</li>';
3272
- }
3273
-
3274
- // Show imports
3275
- var depsList = document.getElementById('dd-deps');
3276
- depsList.innerHTML = (d.dependencies || []).map(function(x) {
3277
- return '<li style="color:' + diffStatusColor(x) + '">\\u2192 ' + esc(x) + '</li>';
3278
- }).join('') || '<li style="color:var(--text-muted)">' + i('detail.none') + '</li>';
3279
-
3280
- panel.style.display = 'block';
3281
- }
3282
-
3283
- function buildDiffView() {
3284
- const dSvg = d3.select('#diff-svg').attr('width', W).attr('height', H);
3285
- const dG = dSvg.append('g');
3286
- const dZoom = d3.zoom().scaleExtent([0.05,10]).on('zoom', e=>dG.attr('transform',e.transform));
3287
- dSvg.call(dZoom);
3288
-
3289
- function diffColor(d) {
3290
- if (addedSet.has(d.id)) return 'var(--green)';
3291
- if (removedSet.has(d.id)) return 'var(--red)';
3292
- if (modifiedSet.has(d.id)) return 'var(--yellow)';
3293
- if (affectedSet.has(d.id)) return 'var(--accent)';
3294
- return '#30363d';
3295
- }
3296
-
3297
- const dDefs = dSvg.append('defs');
3298
- dDefs.append('marker').attr('id','darrow').attr('viewBox','0 -4 8 8')
3299
- .attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
3300
- .append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill','#30363d');
3301
- // Colored arrow markers for diff edges
3302
- [['var(--green)','darrow-g'],['var(--red)','darrow-r'],['var(--yellow)','darrow-y'],['var(--accent)','darrow-a']].forEach(function(pair) {
3303
- dDefs.append('marker').attr('id',pair[1]).attr('viewBox','0 -4 8 8')
3304
- .attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
3305
- .append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill',pair[0]);
3306
- });
3307
-
3308
- simNodes = DATA.nodes.map(d=>({...d, x:undefined, y:undefined, vx:undefined, vy:undefined}));
3309
- simLinks = DATA.links.map(d=>({source:d.source.id??d.source,target:d.target.id??d.target,type:d.type}));
3310
-
3311
- dLink = dG.append('g').selectAll('line').data(simLinks).join('line')
3312
- .attr('stroke','#30363d').attr('stroke-width',1).attr('marker-end','url(#darrow)').attr('opacity',0.05);
3313
-
3314
- dNode = dG.append('g').selectAll('g').data(simNodes).join('g').attr('cursor','pointer');
3315
- dNode.append('circle')
3316
- .attr('r', d=>nodeRadius(d)*nodeScale)
3317
- .attr('fill', diffColor)
3318
- .attr('stroke', diffColor).attr('stroke-width', d=>isDiffNode(d.id)?3:1)
3319
- .attr('opacity', d=>isDiffNode(d.id)?1:0.12);
3320
- dNode.append('text')
3321
- .text(d=>fileName(d.id).replace(/\\.tsx?$/,''))
3322
- .attr('dx', d=>nodeRadius(d)*nodeScale+4).attr('dy',3.5).attr('font-size',11)
3323
- .attr('fill', d=>isDiffNode(d.id)?'var(--text)':'var(--text-muted)')
3324
- .attr('opacity', d=>isDiffNode(d.id)?1:0.08)
3325
- .attr('pointer-events','none');
3326
-
3327
- dSim = d3.forceSimulation(simNodes)
3328
- .force('link', d3.forceLink(simLinks).id(d=>d.id).distance(70).strength(0.25))
3329
- .force('charge', d3.forceManyBody().strength(-150).distanceMax(500))
3330
- .force('center', d3.forceCenter(0,0))
3331
- .force('collision', d3.forceCollide().radius(d=>nodeRadius(d)*nodeScale+4));
3332
-
3333
- // Layer-aware physics for diff view (same pattern as graph view)
3334
- var dHullGroup = null;
3335
- if (LAYERS && LAYERS.length > 0) {
3336
- var dLayerCenters = {};
3337
- var dLayerCount = LAYERS.length;
3338
- var dBaseRadius = Math.max(60, Math.min(W, H) * 0.04 * Math.sqrt(dLayerCount));
3339
- LAYERS.forEach(function(l, idx) {
3340
- var angle = (2 * Math.PI * idx) / dLayerCount - Math.PI / 2;
3341
- dLayerCenters[l.name] = { x: Math.cos(angle) * dBaseRadius, y: Math.sin(angle) * dBaseRadius };
3342
- });
3343
- dSim.force('center', null);
3344
- dSim.force('layerX', d3.forceX(function(d) { return dLayerCenters[d.layer]?.x || 0; }).strength(function(d) { return d.layer ? 0.12 : 0.03; }));
3345
- dSim.force('layerY', d3.forceY(function(d) { return dLayerCenters[d.layer]?.y || 0; }).strength(function(d) { return d.layer ? 0.12 : 0.03; }));
3346
- dSim.force('link').strength(function(l) {
3347
- var sL = l.source.layer ?? l.source, tL = l.target.layer ?? l.target;
3348
- return sL === tL ? 0.4 : 0.1;
3349
- });
3350
- // Cluster force for diff view
3351
- dSim.force('cluster', (function() {
3352
- var ns;
3353
- function f(alpha) {
3354
- var centroids = {}, counts = {};
3355
- ns.forEach(function(n) {
3356
- if (!n.layer) return;
3357
- if (!centroids[n.layer]) { centroids[n.layer] = {x:0,y:0}; counts[n.layer] = 0; }
3358
- centroids[n.layer].x += n.x; centroids[n.layer].y += n.y; counts[n.layer]++;
3359
- });
3360
- Object.keys(centroids).forEach(function(k) { centroids[k].x /= counts[k]; centroids[k].y /= counts[k]; });
3361
- ns.forEach(function(n) {
3362
- if (!n.layer || !centroids[n.layer]) return;
3363
- n.vx += (centroids[n.layer].x - n.x) * alpha * 0.2;
3364
- n.vy += (centroids[n.layer].y - n.y) * alpha * 0.2;
3365
- });
3366
- }
3367
- f.initialize = function(n) { ns = n; };
3368
- return f;
3369
- })());
3370
-
3371
- dHullGroup = dG.insert('g', ':first-child');
3372
- }
3373
-
3374
- function updateDiffHulls() {
3375
- if (!dHullGroup) return;
3376
- dHullGroup.selectAll('*').remove();
3377
- LAYERS.forEach(function(layer) {
3378
- var layerNodes = simNodes.filter(function(n) { return n.layer === layer.name; });
3379
- if (layerNodes.length === 0) return;
3380
- if (diffFocusMode && !layerNodes.some(function(n) { return isDiffNode(n.id); })) return;
3381
- var hasDiff = layerNodes.some(function(n) { return isDiffNode(n.id); });
3382
-
3383
- var points = [];
3384
- layerNodes.forEach(function(n) {
3385
- if (n.x == null || n.y == null) return;
3386
- if (diffFocusMode && !isDiffNode(n.id)) return;
3387
- var r = nodeRadius(n) * nodeScale + 30;
3388
- for (var a = 0; a < Math.PI * 2; a += Math.PI / 4) {
3389
- points.push([n.x + Math.cos(a) * r, n.y + Math.sin(a) * r]);
3390
- }
3391
- });
3392
-
3393
- var fillOp = hasDiff ? 0.15 : 0.06;
3394
- var strokeOp = hasDiff ? 0.6 : 0.2;
3395
- var sw = hasDiff ? 2.5 : 1;
3396
- if (points.length < 6) {
3397
- var cx = layerNodes.reduce(function(s, n) { return s + (n.x||0); }, 0) / layerNodes.length;
3398
- var cy = layerNodes.reduce(function(s, n) { return s + (n.y||0); }, 0) / layerNodes.length;
3399
- dHullGroup.append('circle').attr('cx', cx).attr('cy', cy).attr('r', 50)
3400
- .attr('fill', layer.color).attr('fill-opacity', fillOp)
3401
- .attr('stroke', layer.color).attr('stroke-opacity', strokeOp).attr('stroke-width', sw);
3402
- } else {
3403
- var hull = d3.polygonHull(points);
3404
- if (hull) {
3405
- dHullGroup.append('path')
3406
- .attr('d', 'M' + hull.map(function(p) { return p.join(','); }).join('L') + 'Z')
3407
- .attr('fill', layer.color).attr('fill-opacity', fillOp)
3408
- .attr('stroke', layer.color).attr('stroke-opacity', strokeOp).attr('stroke-width', sw)
3409
- .attr('stroke-dasharray', hasDiff ? null : '6,3');
3410
- }
3411
- }
3412
- // Layer name label
3413
- var visNodes = diffFocusMode ? layerNodes.filter(function(n) { return isDiffNode(n.id); }) : layerNodes;
3414
- if (visNodes.length === 0) return;
3415
- var lx = visNodes.reduce(function(s, n) { return s + (n.x||0); }, 0) / visNodes.length;
3416
- var ly = Math.min.apply(null, visNodes.map(function(n) { return n.y||0; })) - 25;
3417
- dHullGroup.append('text')
3418
- .attr('x', lx).attr('y', ly).attr('text-anchor', 'middle')
3419
- .attr('fill', layer.color).attr('fill-opacity', hasDiff ? 0.9 : 0.4)
3420
- .attr('font-size', 12).attr('font-weight', 600).text(layer.name);
3421
- });
3177
+ // src/web/template.ts
3178
+ var _viewerJs = null;
3179
+ function getViewerJs() {
3180
+ if (!_viewerJs) {
3181
+ const thisDir = dirname2(fileURLToPath(import.meta.url));
3182
+ const candidates = [
3183
+ join8(thisDir, "..", "web", "viewer.js"),
3184
+ // dist/mcp/ or dist/cli/ → dist/web/
3185
+ join8(thisDir, "..", "..", "dist", "web", "viewer.js")
3186
+ // src/web/ dist/web/
3187
+ ];
3188
+ for (const p of candidates) {
3189
+ try {
3190
+ _viewerJs = readFileSync3(p, "utf-8");
3191
+ break;
3192
+ } catch {
3193
+ }
3422
3194
  }
3423
-
3424
- var dTickCount = 0;
3425
- dSim.on('tick', function() {
3426
- dLink.each(function(d) {
3427
- var dx=d.target.x-d.source.x, dy=d.target.y-d.source.y, dist=Math.sqrt(dx*dx+dy*dy)||1;
3428
- var rT=nodeRadius(d.target)*nodeScale, rS=nodeRadius(d.source)*nodeScale;
3429
- d3.select(this).attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
3430
- .attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
3431
- });
3432
- dNode.attr('transform', function(d) { return 'translate('+d.x+','+d.y+')'; });
3433
- if (++dTickCount % 5 === 0) updateDiffHulls();
3434
- });
3435
-
3436
- // Click: show impact chain + detail panel
3437
- dNode.on('click', function(e, d) {
3438
- e.stopPropagation();
3439
- highlightDiffImpact(d);
3440
- showDiffDetail(d);
3441
- });
3442
-
3443
- // Click on empty space to deselect
3444
- dSvg.on('click', function() {
3445
- closeDiffDetail();
3446
- });
3447
-
3448
- dNode.on('mouseover',function(e,d) { showTooltip(e,d); }).on('mousemove',function(e) { positionTooltip(e); }).on('mouseout',function() { scheduleHideTooltip(); });
3449
-
3450
- // Apply initial filter (in case focus was toggled before build)
3451
- applyDiffFilter();
3452
-
3453
- var dAutoFitDone = false;
3454
- dSim.on('end', function() {
3455
- if (dAutoFitDone) return;
3456
- dAutoFitDone = true;
3457
- var b=dG.node().getBBox(); if(!b.width) return;
3458
- var s=Math.min(W/(b.width+80),H/(b.height+80))*0.9;
3459
- 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));
3460
- });
3195
+ if (!_viewerJs) throw new Error("viewer.js not found. Run 'npm run build:client' first.");
3461
3196
  }
3462
-
3197
+ return _viewerJs;
3463
3198
  }
3464
- `;
3465
- }
3466
-
3467
- // src/utils/html-escape.ts
3468
- var ESC_FUNCTION_JS = `function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }`;
3469
-
3470
- // src/web/template.ts
3471
3199
  function buildGraphPage(graph, options = {}) {
3472
3200
  const locale = options.locale ?? "en";
3473
3201
  const diff = options.diff ?? null;
@@ -3499,6 +3227,7 @@ function buildGraphPage(graph, options = {}) {
3499
3227
  const layersData = layers ? JSON.stringify(layers) : "null";
3500
3228
  const crossEdgesData = crossEdges ? JSON.stringify(crossEdges) : "null";
3501
3229
  const graphData = JSON.stringify({ nodes, links, circularFiles: [...circularFiles], dirs, projectName });
3230
+ const viewerJs = getViewerJs();
3502
3231
  return (
3503
3232
  /* html */
3504
3233
  `<!DOCTYPE html>
@@ -3506,986 +3235,22 @@ function buildGraphPage(graph, options = {}) {
3506
3235
  <head>
3507
3236
  <meta charset="utf-8">
3508
3237
  <meta name="viewport" content="width=device-width, initial-scale=1">
3509
- <title>${projectName} \u2014 Architecture Viewer</title>
3238
+ <title>${escapeHtml(projectName)} \u2014 Architecture Viewer</title>
3510
3239
  ${buildStyles()}
3511
3240
  </head>
3512
3241
  <body>
3513
3242
  ${buildViewerHtml()}
3514
3243
  <script src="https://d3js.org/d3.v7.min.js"></script>
3515
3244
  <script>
3516
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3517
- // i18n
3518
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3519
- const I18N = {
3520
- en: {
3521
- 'tab.graph': 'Graph', 'tab.hierarchy': 'Hierarchy',
3522
- 'stats.files': 'Files', 'stats.edges': 'Edges', 'stats.circular': 'Circular',
3523
- 'settings.title': 'Settings', 'settings.theme': 'Theme', 'settings.fontSize': 'Font Size',
3524
- 'settings.nodeSize': 'Node Size', 'settings.linkOpacity': 'Link Opacity', 'settings.gravity': 'Gravity', 'settings.language': 'Language', 'settings.export': 'Export',
3525
- 'impact.title': 'Impact Simulation', 'impact.btn': 'Impact', 'impact.transitive': 'files affected',
3526
- 'search.placeholder': 'Search files...',
3527
- 'legend.circular': 'Circular dep', 'legend.orphan': 'Orphan', 'legend.highCoupling': 'High coupling',
3528
- 'legend.imports': 'imports', 'legend.importedBy': 'imported by',
3529
- 'detail.importedBy': 'Imported by', 'detail.imports': 'Imports',
3530
- 'detail.none': 'none', 'detail.dir': 'Dir', 'detail.dependencies': 'Dependencies', 'detail.dependents': 'Dependents',
3531
- 'tooltip.imports': 'imports', 'tooltip.importedBy': 'imported by',
3532
- 'help.graph': 'Scroll: zoom \xB7 Drag: pan \xB7 Click: select \xB7 / search',
3533
- 'help.hierarchy': 'Scroll to navigate \xB7 Click to highlight',
3534
- 'help.diff': 'Green=added \xB7 Red=removed \xB7 Yellow=modified \xB7 Blue=affected',
3535
- 'tab.diff': 'Diff',
3536
- 'diff.addedLabel': 'Added', 'diff.removedLabel': 'Removed', 'diff.modifiedLabel': 'Modified', 'diff.affectedLabel': 'Affected',
3537
- 'diff.showAll': 'Show all', 'diff.focusChanges': 'Focus changes', 'diff.noImpact': 'No downstream impact',
3538
- 'diff.affectedByChange': 'Affected by this change',
3539
- },
3540
- ja: {
3541
- 'tab.graph': '\u30B0\u30E9\u30D5', 'tab.hierarchy': '\u968E\u5C64\u56F3',
3542
- 'stats.files': '\u30D5\u30A1\u30A4\u30EB', 'stats.edges': '\u30A8\u30C3\u30B8', 'stats.circular': '\u5FAA\u74B0\u53C2\u7167',
3543
- 'settings.title': '\u8A2D\u5B9A', 'settings.theme': '\u30C6\u30FC\u30DE', 'settings.fontSize': '\u30D5\u30A9\u30F3\u30C8\u30B5\u30A4\u30BA',
3544
- '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',
3545
- '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',
3546
- 'search.placeholder': '\u30D5\u30A1\u30A4\u30EB\u691C\u7D22...',
3547
- 'legend.circular': '\u5FAA\u74B0\u53C2\u7167', 'legend.orphan': '\u5B64\u7ACB', 'legend.highCoupling': '\u9AD8\u7D50\u5408',
3548
- 'legend.imports': 'import\u5148', 'legend.importedBy': 'import\u5143',
3549
- 'detail.importedBy': 'import\u5143', 'detail.imports': 'import\u5148',
3550
- 'detail.none': '\u306A\u3057', 'detail.dir': '\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA', 'detail.dependencies': '\u4F9D\u5B58\u5148', 'detail.dependents': '\u88AB\u4F9D\u5B58',
3551
- 'tooltip.imports': 'import\u5148', 'tooltip.importedBy': 'import\u5143',
3552
- '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',
3553
- 'help.hierarchy': '\u30B9\u30AF\u30ED\u30FC\u30EB\u3067\u79FB\u52D5 \xB7 \u30AF\u30EA\u30C3\u30AF\u3067\u30CF\u30A4\u30E9\u30A4\u30C8',
3554
- 'help.diff': '\u7DD1=\u8FFD\u52A0 \xB7 \u8D64=\u524A\u9664 \xB7 \u9EC4=\u5909\u66F4 \xB7 \u9752=\u5F71\u97FF',
3555
- 'tab.diff': '\u5DEE\u5206',
3556
- 'diff.addedLabel': '\u8FFD\u52A0', 'diff.removedLabel': '\u524A\u9664', 'diff.modifiedLabel': '\u5909\u66F4', 'diff.affectedLabel': '\u5F71\u97FF',
3557
- 'diff.showAll': '\u5168\u8868\u793A', 'diff.focusChanges': '\u5909\u66F4\u306E\u307F\u8868\u793A', 'diff.noImpact': '\u4E0B\u6D41\u3078\u306E\u5F71\u97FF\u306A\u3057',
3558
- 'diff.affectedByChange': '\u3053\u306E\u5909\u66F4\u306E\u5F71\u97FF\u7BC4\u56F2',
3559
- }
3560
- };
3561
- let currentLang = '${locale}';
3562
- function applyI18n() {
3563
- const msgs = I18N[currentLang] || I18N.en;
3564
- document.querySelectorAll('[data-i18n]').forEach(el => {
3565
- const key = el.getAttribute('data-i18n');
3566
- if (msgs[key]) el.textContent = msgs[key];
3567
- });
3568
- document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
3569
- const key = el.getAttribute('data-i18n-placeholder');
3570
- if (msgs[key]) el.placeholder = msgs[key];
3571
- });
3572
- document.querySelectorAll('.lang-btn').forEach(b => b.classList.toggle('active', b.dataset.lang === currentLang));
3573
- }
3574
- window.setLang = (lang) => { currentLang = lang; applyI18n(); saveSettings(); };
3575
- function i(key) { return (I18N[currentLang] || I18N.en)[key] || key; }
3576
- ${ESC_FUNCTION_JS}
3577
-
3578
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3579
- // SETTINGS (persisted to localStorage)
3580
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3581
- const STORAGE_KEY = 'archtracker-settings';
3582
- function saveSettings() {
3583
- 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, layerGravity: document.getElementById('layer-gravity-val').textContent, lang: currentLang, projectTitle: document.getElementById('project-title').textContent };
3584
- try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch(e) {}
3585
- }
3586
- function loadSettings() {
3587
- try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || null; } catch(e) { return null; }
3588
- }
3589
-
3590
- let nodeScale = 1, baseLinkOpacity = 0.4;
3591
- window.toggleSettings = () => document.getElementById('settings-panel').classList.toggle('open');
3592
- window.setTheme = (theme) => {
3593
- document.body.setAttribute('data-theme', theme === 'light' ? 'light' : '');
3594
- document.querySelectorAll('.theme-btn[data-theme-val]').forEach(b => b.classList.toggle('active', b.dataset.themeVal === theme));
3595
- saveSettings();
3596
- };
3597
- window.setFontSize = (v) => {
3598
- document.getElementById('font-size-val').textContent = v;
3599
- const scale = v / 13;
3600
- if (typeof node !== 'undefined') {
3601
- node.select('text').attr('font-size', d => (d.dependents>=3?12:10) * scale);
3602
- }
3603
- saveSettings();
3604
- };
3605
- window.setNodeScale = (v) => {
3606
- nodeScale = v / 100;
3607
- document.getElementById('node-size-val').textContent = v;
3608
- if (typeof node !== 'undefined') {
3609
- node.select('circle').attr('r', d => nodeRadius(d) * nodeScale);
3610
- node.select('text').attr('dx', d => nodeRadius(d) * nodeScale + 4);
3611
- simulation.force('collision', d3.forceCollide().radius(d => nodeRadius(d) * nodeScale + 4));
3612
- simulation.alpha(0.3).restart();
3613
- }
3614
- saveSettings();
3615
- };
3616
- window.setLinkOpacity = (v) => {
3617
- baseLinkOpacity = v / 100;
3618
- document.getElementById('link-opacity-val').textContent = v;
3619
- if (typeof link !== 'undefined') link.attr('opacity', baseLinkOpacity);
3620
- saveSettings();
3621
- };
3622
- let gravityStrength = 150;
3623
- window.setGravity = (v) => {
3624
- gravityStrength = +v;
3625
- document.getElementById('gravity-val').textContent = v;
3626
- if (typeof simulation !== 'undefined') {
3627
- if (typeof updateLayerPhysics === 'function') {
3628
- updateLayerPhysics();
3629
- } else {
3630
- simulation.force('charge', d3.forceManyBody().strength(-gravityStrength).distanceMax(500));
3631
- }
3632
- simulation.alpha(0.5).restart();
3633
- }
3634
- saveSettings();
3635
- };
3636
- let layerGravity = 12;
3637
- window.setLayerGravity = (v) => {
3638
- layerGravity = +v;
3639
- document.getElementById('layer-gravity-val').textContent = v;
3640
- if (typeof simulation !== 'undefined' && typeof updateLayerPhysics === 'function') {
3641
- updateLayerPhysics();
3642
- simulation.alpha(0.5).restart();
3643
- }
3644
- saveSettings();
3645
- };
3646
-
3647
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3648
- // EXPORT
3649
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3650
- window.exportSVG = () => {
3651
- const activeView = document.querySelector('.view.active svg');
3652
- if (!activeView) return;
3653
- const clone = activeView.cloneNode(true);
3654
- clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
3655
- const blob = new Blob([clone.outerHTML], {type: 'image/svg+xml'});
3656
- const a = document.createElement('a');
3657
- a.href = URL.createObjectURL(blob);
3658
- a.download = (document.getElementById('project-title').textContent || 'graph') + '.svg';
3659
- a.click(); URL.revokeObjectURL(a.href);
3660
- };
3661
- window.exportPNG = () => {
3662
- const activeView = document.querySelector('.view.active svg');
3663
- if (!activeView) return;
3664
- const clone = activeView.cloneNode(true);
3665
- clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
3666
- const svgStr = new XMLSerializer().serializeToString(clone);
3667
- const canvas = document.createElement('canvas');
3668
- const bbox = activeView.getBoundingClientRect();
3669
- canvas.width = bbox.width * 2; canvas.height = bbox.height * 2;
3670
- const ctx = canvas.getContext('2d');
3671
- ctx.scale(2, 2);
3672
- const img = new Image();
3673
- 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(); };
3674
- img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgStr)));
3245
+ window.__ARCH = {
3246
+ data: ${graphData},
3247
+ layers: ${layersData},
3248
+ crossEdges: ${crossEdgesData},
3249
+ diff: ${diffData},
3250
+ locale: '${locale}'
3675
3251
  };
3676
-
3677
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3678
- // DATA
3679
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3680
- const DATA = ${graphData};
3681
- const LAYERS = ${layersData};
3682
- const CROSS_EDGES = ${crossEdgesData};
3683
- const W = window.innerWidth, H = window.innerHeight - 44;
3684
- const circularSet = new Set(DATA.circularFiles);
3685
-
3686
- // Project title (editable)
3687
- const titleEl = document.getElementById('project-title');
3688
- titleEl.textContent = DATA.projectName;
3689
- titleEl.addEventListener('blur', () => { if (!titleEl.textContent.trim()) titleEl.textContent = DATA.projectName; document.title = titleEl.textContent + ' \u2014 Architecture Viewer'; saveSettings(); });
3690
- titleEl.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); titleEl.blur(); } });
3691
-
3692
- // Restore saved settings \u2014 phase 1: non-graph settings (before graph init)
3693
- const _savedSettings = loadSettings();
3694
- if (_savedSettings) {
3695
- if (_savedSettings.theme) setTheme(_savedSettings.theme);
3696
- if (_savedSettings.lang) { currentLang = _savedSettings.lang; applyI18n(); }
3697
- if (_savedSettings.projectTitle) { titleEl.textContent = _savedSettings.projectTitle; document.title = _savedSettings.projectTitle + ' \u2014 Architecture Viewer'; }
3698
- // Set slider positions (visual only \u2014 graph not built yet)
3699
- if (_savedSettings.fontSize) { document.getElementById('font-size-slider').value = _savedSettings.fontSize; document.getElementById('font-size-val').textContent = _savedSettings.fontSize; }
3700
- if (_savedSettings.nodeSize) { document.getElementById('node-size-slider').value = _savedSettings.nodeSize; document.getElementById('node-size-val').textContent = _savedSettings.nodeSize; nodeScale = _savedSettings.nodeSize / 100; }
3701
- if (_savedSettings.linkOpacity) { document.getElementById('link-opacity-slider').value = _savedSettings.linkOpacity; document.getElementById('link-opacity-val').textContent = _savedSettings.linkOpacity; baseLinkOpacity = _savedSettings.linkOpacity / 100; }
3702
- if (_savedSettings.gravity) { document.getElementById('gravity-slider').value = _savedSettings.gravity; document.getElementById('gravity-val').textContent = _savedSettings.gravity; gravityStrength = +_savedSettings.gravity; }
3703
- if (_savedSettings.layerGravity) { document.getElementById('layer-gravity-slider').value = _savedSettings.layerGravity; document.getElementById('layer-gravity-val').textContent = _savedSettings.layerGravity; layerGravity = +_savedSettings.layerGravity; }
3704
- }
3705
-
3706
- document.getElementById('s-files').textContent = DATA.nodes.length;
3707
- document.getElementById('s-edges').textContent = DATA.links.length;
3708
- document.getElementById('s-circular').textContent = DATA.circularFiles.length;
3709
-
3710
- const dirColor = d3.scaleOrdinal()
3711
- .domain(DATA.dirs)
3712
- .range(['#58a6ff','#3fb950','#d2a8ff','#f0883e','#79c0ff','#56d4dd','#db61a2','#f778ba','#ffa657','#7ee787']);
3713
-
3714
- // Layer color map (from LAYERS metadata)
3715
- const layerColorMap = {};
3716
- let activeLayerFilter = null; // DEPRECATED \u2014 kept for backward compat, always null with multi-select tabs
3717
- const activeLayers = new Set(); // empty = no filter (show all); non-empty = show only selected
3718
- if (LAYERS) {
3719
- LAYERS.forEach(l => { layerColorMap[l.name] = l.color; });
3720
- document.getElementById('layer-gravity-setting').style.display = '';
3721
- }
3722
-
3723
- function nodeColor(d) {
3724
- if (circularSet.has(d.id)) return '#f97583';
3725
- if (d.isOrphan) return '#484f58';
3726
- // Layer coloring: all-visible or multi-select \u2192 layer colors; single-select \u2192 dir colors
3727
- if (LAYERS && d.layer && layerColorMap[d.layer] && activeLayers.size !== 1) return layerColorMap[d.layer];
3728
- return dirColor(d.dir);
3729
- }
3730
- function nodeRadius(d) { return Math.max(5, Math.min(22, 4 + d.dependents * 1.8)); }
3731
- function fileName(id) { return id.split('/').pop(); }
3732
-
3733
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3734
- // TAB SWITCHING
3735
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3736
- let hierBuilt = false;
3737
- let diffBuilt = false;
3738
- let hierRelayout = null;
3739
- let hierSyncFromTab = null;
3740
- document.querySelectorAll('.tab').forEach(tab => {
3741
- tab.addEventListener('click', () => {
3742
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
3743
- document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
3744
- tab.classList.add('active');
3745
- document.getElementById(tab.dataset.view).classList.add('active');
3746
- if (tab.dataset.view === 'hier-view') {
3747
- if (!hierBuilt) { buildHierarchy(); hierBuilt = true; }
3748
- if (hierSyncFromTab) { hierSyncFromTab(); hierRelayout(); }
3749
- }
3750
- if (tab.dataset.view === 'diff-view') {
3751
- if (!diffBuilt) { buildDiffView(); diffBuilt = true; }
3752
- }
3753
- });
3754
- });
3755
-
3756
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3757
- // TOOLTIP \u2014 delayed hide + interactive
3758
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3759
- const tooltip = document.getElementById('tooltip');
3760
- let tooltipHideTimer = null;
3761
- let tooltipLocked = false;
3762
-
3763
- function showTooltip(e, d) {
3764
- clearTimeout(tooltipHideTimer);
3765
- document.getElementById('tt-name').textContent = d.id;
3766
- document.getElementById('tt-dep-count').textContent = d.deps;
3767
- document.getElementById('tt-dpt-count').textContent = d.dependents;
3768
- const out = (d.dependencies||[]).map(x => '<div class="tt-out">\u2192 '+esc(x)+'</div>');
3769
- const inc = (d.dependentsList||[]).map(x => '<div class="tt-in">\u2190 '+esc(x)+'</div>');
3770
- document.getElementById('tt-details').innerHTML = [...out, ...inc].join('');
3771
- tooltip.style.display = 'block';
3772
- positionTooltip(e);
3773
- }
3774
- function positionTooltip(e) {
3775
- const gap = 24;
3776
- const tw = 420, th = tooltip.offsetHeight || 200;
3777
- // Prefer placing to the right and above the cursor so it doesn't cover nodes below
3778
- let x = e.clientX + gap;
3779
- let y = e.clientY - th - 12;
3780
- // If no room on the right, flip left
3781
- if (x + tw > window.innerWidth) x = e.clientX - tw - gap;
3782
- // If no room above, place below the cursor with gap
3783
- if (y < 50) y = e.clientY + gap;
3784
- // Final clamp
3785
- if (y + th > window.innerHeight) y = window.innerHeight - th - 8;
3786
- if (x < 8) x = 8;
3787
- tooltip.style.left = x + 'px';
3788
- tooltip.style.top = y + 'px';
3789
- }
3790
- function scheduleHideTooltip() {
3791
- clearTimeout(tooltipHideTimer);
3792
- tooltipHideTimer = setTimeout(() => {
3793
- if (!tooltipLocked) {
3794
- tooltip.style.display = 'none';
3795
- if (!pinnedNode) resetGraphHighlight();
3796
- }
3797
- }, 250);
3798
- }
3799
-
3800
- // Keep tooltip visible when mouse enters it
3801
- tooltip.addEventListener('mouseenter', () => {
3802
- clearTimeout(tooltipHideTimer);
3803
- tooltipLocked = true;
3804
- });
3805
- tooltip.addEventListener('mouseleave', () => {
3806
- tooltipLocked = false;
3807
- scheduleHideTooltip();
3808
- });
3809
-
3810
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3811
- // GRAPH VIEW
3812
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3813
- const svg = d3.select('#graph-svg').attr('width', W).attr('height', H);
3814
- const g = svg.append('g');
3815
- const zoom = d3.zoom().scaleExtent([0.05, 10]).on('zoom', e => g.attr('transform', e.transform));
3816
- svg.call(zoom);
3817
- svg.call(zoom.transform, d3.zoomIdentity.translate(W/2, H/2).scale(0.7));
3818
-
3819
- window.zoomIn = () => svg.transition().duration(300).call(zoom.scaleBy, 1.4);
3820
- window.zoomOut = () => svg.transition().duration(300).call(zoom.scaleBy, 0.7);
3821
- window.zoomFit = () => {
3822
- const b = g.node().getBBox(); if (!b.width) return;
3823
- const s = Math.min(W/(b.width+80), H/(b.height+80))*0.9;
3824
- 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));
3825
- };
3826
-
3827
- // Defs
3828
- const defs = svg.append('defs');
3829
- ['#30363d','#58a6ff','#3fb950'].forEach((c,i) => {
3830
- defs.append('marker').attr('id','arrow-'+i).attr('viewBox','0 -4 8 8')
3831
- .attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
3832
- .append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill',c);
3833
- });
3834
-
3835
- // Links
3836
- const link = g.append('g').selectAll('line').data(DATA.links).join('line')
3837
- .attr('stroke', d => d.type==='type-only'?'#1f3d5c':'#30363d')
3838
- .attr('stroke-width',1)
3839
- .attr('stroke-dasharray', d => d.type==='type-only'?'4,3':d.type==='dynamic'?'6,3':null)
3840
- .attr('marker-end','url(#arrow-0)')
3841
- .attr('opacity', baseLinkOpacity);
3842
-
3843
- // Cross-layer links (from layers.json connections)
3844
- defs.append('marker').attr('id','arrow-cross').attr('viewBox','0 -4 8 8')
3845
- .attr('refX',8).attr('refY',0).attr('markerWidth',7).attr('markerHeight',7).attr('orient','auto')
3846
- .append('path').attr('d','M0,-3.5L8,0L0,3.5Z').attr('fill','#f0883e');
3847
-
3848
- const crossLinkData = (CROSS_EDGES || []).map(e => ({
3849
- source: e.fromLayer + '/' + e.fromFile,
3850
- target: e.toLayer + '/' + e.toFile,
3851
- sourceLayer: e.fromLayer,
3852
- targetLayer: e.toLayer,
3853
- type: e.type || 'api-call',
3854
- label: e.label || e.type || '',
3855
- })).filter(e => DATA.nodes.some(n => n.id === e.source) && DATA.nodes.some(n => n.id === e.target));
3856
-
3857
- const crossLinkG = g.append('g');
3858
- const crossLink = crossLinkG.selectAll('line').data(crossLinkData).join('line')
3859
- .attr('stroke', '#f0883e')
3860
- .attr('stroke-width', 2)
3861
- .attr('stroke-dasharray', '8,4')
3862
- .attr('marker-end', 'url(#arrow-cross)')
3863
- .attr('opacity', 0.7);
3864
- const crossLabel = crossLinkG.selectAll('text').data(crossLinkData).join('text')
3865
- .text(d => d.label)
3866
- .attr('font-size', 9)
3867
- .attr('fill', '#f0883e')
3868
- .attr('text-anchor', 'middle')
3869
- .attr('opacity', 0.8)
3870
- .attr('pointer-events', 'none');
3871
-
3872
- // Nodes
3873
- const node = g.append('g').selectAll('g').data(DATA.nodes).join('g')
3874
- .attr('cursor','pointer')
3875
- .call(d3.drag().on('start',dragStart).on('drag',dragging).on('end',dragEnd));
3876
-
3877
- node.append('circle')
3878
- .attr('r', d => nodeRadius(d) * nodeScale)
3879
- .attr('fill', nodeColor)
3880
- .attr('stroke', d => d.deps>=5?'var(--yellow)':nodeColor(d))
3881
- .attr('stroke-width', d => d.deps>=5?2.5:1.5)
3882
- .attr('stroke-opacity', d => d.deps>=5?0.8:0.3);
3883
-
3884
- node.append('text')
3885
- .text(d => fileName(d.id).replace(/\\.tsx?$/,''))
3886
- .attr('dx', d => nodeRadius(d)*nodeScale+4)
3887
- .attr('dy',3.5)
3888
- .attr('font-size', d => d.dependents>=3?12:10)
3889
- .attr('font-weight', d => d.dependents>=3?600:400)
3890
- .attr('fill', d => d.dependents>=3?'var(--text)':'var(--text-dim)')
3891
- .attr('opacity', d => d.dependents>=1||d.deps>=3?1:0.5)
3892
- .attr('pointer-events','none');
3893
-
3894
- // Simulation
3895
- const simulation = d3.forceSimulation(DATA.nodes)
3896
- .force('link', d3.forceLink(DATA.links).id(d=>d.id).distance(70).strength(0.25))
3897
- .force('charge', d3.forceManyBody().strength(-gravityStrength).distanceMax(500))
3898
- .force('center', d3.forceCenter(0,0))
3899
- .force('collision', d3.forceCollide().radius(d=>nodeRadius(d)*nodeScale+4))
3900
- .force('x', d3.forceX(0).strength(0.03))
3901
- .force('y', d3.forceY(0).strength(0.03))
3902
- .on('tick', () => {
3903
- link.each(function(d) {
3904
- const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y;
3905
- const dist=Math.sqrt(dx*dx+dy*dy)||1;
3906
- const rT=nodeRadius(d.target)*nodeScale, rS=nodeRadius(d.source)*nodeScale;
3907
- d3.select(this)
3908
- .attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
3909
- .attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
3910
- });
3911
- node.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
3912
- });
3913
-
3914
- // \u2500\u2500\u2500 Layer convex hulls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3915
- let hullGroup = null;
3916
- const activeDirs = new Set(DATA.dirs);
3917
- const dirCounts = {};
3918
- DATA.nodes.forEach(n => dirCounts[n.dir] = (dirCounts[n.dir] || 0) + 1);
3919
- var applyLayerFilter = null; // hoisted for dir-filter integration
3920
- var updateLayerPhysics = null; // hoisted \u2014 updates charge/layer forces without visibility changes
3921
-
3922
- if (LAYERS && LAYERS.length > 0) {
3923
- // \u2500\u2500\u2500 Water droplet physics: intra-layer cohesion + inter-layer separation \u2500\u2500\u2500
3924
- const allLayerCount = LAYERS.length;
3925
- const allBaseRadius = Math.max(60, Math.min(W, H) * 0.04 * Math.sqrt(allLayerCount));
3926
- // Pre-compute full-circle positions for all layers (used when no filter)
3927
- const allLayerCenters = {};
3928
- LAYERS.forEach((l, idx) => {
3929
- const angle = (2 * Math.PI * idx) / allLayerCount - Math.PI / 2;
3930
- allLayerCenters[l.name] = { x: Math.cos(angle) * allBaseRadius, y: Math.sin(angle) * allBaseRadius };
3931
- });
3932
-
3933
- // Dynamic center calculation: compact when multi-selecting, full spread when all
3934
- function getLayerCenters() {
3935
- if (activeLayers.size <= 1) return allLayerCenters; // 0 = all, 1 = single (centered)
3936
- // Multi-select: arrange only selected layers compactly on a smaller circle
3937
- const selected = LAYERS.filter(l => activeLayers.has(l.name));
3938
- const count = selected.length;
3939
- const compactRadius = Math.max(40, Math.min(W, H) * 0.03 * Math.sqrt(count));
3940
- const centers = {};
3941
- selected.forEach((l, idx) => {
3942
- const angle = (2 * Math.PI * idx) / count - Math.PI / 2;
3943
- centers[l.name] = { x: Math.cos(angle) * compactRadius, y: Math.sin(angle) * compactRadius };
3944
- });
3945
- return centers;
3946
- }
3947
-
3948
- // Replace default centering forces with per-layer positioning
3949
- const layerStrength = layerGravity / 100;
3950
- simulation.force('x', null).force('y', null).force('center', null);
3951
- simulation.force('layerX', d3.forceX(d => allLayerCenters[d.layer]?.x || 0).strength(d => d.layer ? layerStrength : 0.03));
3952
- simulation.force('layerY', d3.forceY(d => allLayerCenters[d.layer]?.y || 0).strength(d => d.layer ? layerStrength : 0.03));
3953
-
3954
- // Custom clustering force \u2014 surface tension pulling nodes toward their layer centroid
3955
- function clusterForce() {
3956
- let nodes;
3957
- function force(alpha) {
3958
- const centroids = {};
3959
- const counts = {};
3960
- nodes.forEach(n => {
3961
- if (!n.layer) return;
3962
- if (!centroids[n.layer]) { centroids[n.layer] = {x: 0, y: 0}; counts[n.layer] = 0; }
3963
- centroids[n.layer].x += n.x;
3964
- centroids[n.layer].y += n.y;
3965
- counts[n.layer]++;
3966
- });
3967
- Object.keys(centroids).forEach(k => {
3968
- centroids[k].x /= counts[k];
3969
- centroids[k].y /= counts[k];
3970
- });
3971
- // Pull each node toward its layer centroid (surface tension)
3972
- const strength = 0.2;
3973
- nodes.forEach(n => {
3974
- if (!n.layer || !centroids[n.layer]) return;
3975
- n.vx += (centroids[n.layer].x - n.x) * alpha * strength;
3976
- n.vy += (centroids[n.layer].y - n.y) * alpha * strength;
3977
- });
3978
- }
3979
- force.initialize = (n) => { nodes = n; };
3980
- return force;
3981
- }
3982
- simulation.force('cluster', clusterForce());
3983
-
3984
- // Boost link strength for intra-layer edges (tighter connections within a layer)
3985
- simulation.force('link').strength(l => {
3986
- const sLayer = (l.source.layer ?? l.source);
3987
- const tLayer = (l.target.layer ?? l.target);
3988
- return sLayer === tLayer ? 0.4 : 0.1;
3989
- });
3990
-
3991
- hullGroup = g.insert('g', ':first-child');
3992
-
3993
- function updateHulls() {
3994
- if (!hullGroup) return;
3995
- hullGroup.selectAll('*').remove();
3996
- // Show hulls always (filter to selected layers when focused)
3997
-
3998
- LAYERS.forEach(layer => {
3999
- if (activeLayers.size > 0 && !activeLayers.has(layer.name)) return;
4000
- const layerNodes = DATA.nodes.filter(n => n.layer === layer.name);
4001
- if (layerNodes.length === 0) return;
4002
-
4003
- const points = [];
4004
- layerNodes.forEach(n => {
4005
- if (n.x == null || n.y == null) return;
4006
- const r = nodeRadius(n) * nodeScale + 30;
4007
- // Add expanded points for a nicer hull shape
4008
- for (let a = 0; a < Math.PI * 2; a += Math.PI / 4) {
4009
- points.push([n.x + Math.cos(a) * r, n.y + Math.sin(a) * r]);
4010
- }
4011
- });
4012
-
4013
- if (points.length < 3) {
4014
- // Fallback: circle for 1-2 nodes
4015
- const cx = layerNodes.reduce((s, n) => s + (n.x || 0), 0) / layerNodes.length;
4016
- const cy = layerNodes.reduce((s, n) => s + (n.y || 0), 0) / layerNodes.length;
4017
- const maxR = Math.max(60, ...layerNodes.map(n => {
4018
- const dx = (n.x || 0) - cx, dy = (n.y || 0) - cy;
4019
- return Math.sqrt(dx*dx + dy*dy) + nodeRadius(n) * nodeScale + 30;
4020
- }));
4021
- hullGroup.append('circle')
4022
- .attr('cx', cx).attr('cy', cy).attr('r', maxR)
4023
- .attr('class', 'layer-hull')
4024
- .attr('fill', layer.color).attr('stroke', layer.color);
4025
- hullGroup.append('text')
4026
- .attr('class', 'layer-hull-label')
4027
- .attr('x', cx).attr('y', cy - maxR - 8)
4028
- .attr('text-anchor', 'middle')
4029
- .attr('fill', layer.color)
4030
- .text(layer.name);
4031
- return;
4032
- }
4033
-
4034
- const hull = d3.polygonHull(points);
4035
- if (!hull) return;
4036
-
4037
- // Smooth the hull with a cardinal closed curve
4038
- hullGroup.append('path')
4039
- .attr('class', 'layer-hull')
4040
- .attr('d', d3.line().curve(d3.curveCatmullRomClosed.alpha(0.5))(hull))
4041
- .attr('fill', layer.color).attr('stroke', layer.color);
4042
-
4043
- // Label at the top of the hull
4044
- const topPt = hull.reduce((best, p) => p[1] < best[1] ? p : best, hull[0]);
4045
- hullGroup.append('text')
4046
- .attr('class', 'layer-hull-label')
4047
- .attr('x', topPt[0]).attr('y', topPt[1] - 10)
4048
- .attr('text-anchor', 'middle')
4049
- .attr('fill', layer.color)
4050
- .text(layer.name);
4051
- });
4052
- }
4053
-
4054
- // Update hulls + cross-layer links on each tick
4055
- simulation.on('tick', () => {
4056
- // Regular links
4057
- link.each(function(d) {
4058
- const dx=d.target.x-d.source.x, dy=d.target.y-d.source.y;
4059
- const dist=Math.sqrt(dx*dx+dy*dy)||1;
4060
- const rT=nodeRadius(d.target)*nodeScale, rS=nodeRadius(d.source)*nodeScale;
4061
- d3.select(this)
4062
- .attr('x1',d.source.x+(dx/dist)*rS).attr('y1',d.source.y+(dy/dist)*rS)
4063
- .attr('x2',d.target.x-(dx/dist)*rT).attr('y2',d.target.y-(dy/dist)*rT);
4064
- });
4065
- node.attr('transform', d=>\`translate(\${d.x},\${d.y})\`);
4066
- // Cross-layer links \u2014 resolve node positions by ID
4067
- if (crossLinkData.length > 0) {
4068
- const nodeById = {};
4069
- DATA.nodes.forEach(n => { nodeById[n.id] = n; });
4070
- crossLink.each(function(d) {
4071
- const sN = nodeById[d.source], tN = nodeById[d.target];
4072
- if (!sN || !tN) return;
4073
- const dx = tN.x - sN.x, dy = tN.y - sN.y;
4074
- const dist = Math.sqrt(dx*dx + dy*dy) || 1;
4075
- const rS = nodeRadius(sN) * nodeScale, rT = nodeRadius(tN) * nodeScale;
4076
- d3.select(this)
4077
- .attr('x1', sN.x + (dx/dist)*rS).attr('y1', sN.y + (dy/dist)*rS)
4078
- .attr('x2', tN.x - (dx/dist)*rT).attr('y2', tN.y - (dy/dist)*rT);
4079
- });
4080
- crossLabel.each(function(d) {
4081
- const sN = nodeById[d.source], tN = nodeById[d.target];
4082
- if (!sN || !tN) return;
4083
- d3.select(this).attr('x', (sN.x + tN.x) / 2).attr('y', (sN.y + tN.y) / 2 - 6);
4084
- });
4085
- }
4086
- updateHulls();
4087
- });
4088
-
4089
- // \u2500\u2500\u2500 Layer legend \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
4090
- const layerLegend = document.getElementById('layer-legend');
4091
- LAYERS.forEach(layer => {
4092
- const item = document.createElement('div');
4093
- item.className = 'legend-item';
4094
- item.innerHTML = '<div class="legend-dot" style="background:' + esc(layer.color) + '"></div> ' + esc(layer.name);
4095
- layerLegend.appendChild(item);
4096
- });
4097
- // Cross-layer edge legend
4098
- if (CROSS_EDGES && CROSS_EDGES.length > 0) {
4099
- const crossItem = document.createElement('div');
4100
- crossItem.className = 'legend-item';
4101
- crossItem.innerHTML = '<span style="color:#f0883e;font-size:11px">- - \u2192</span> Cross-layer link';
4102
- layerLegend.appendChild(crossItem);
4103
- }
4104
- // Add separator
4105
- const sep = document.createElement('hr');
4106
- sep.style.cssText = 'border:none;border-top:1px solid var(--border);margin:6px 0;';
4107
- layerLegend.appendChild(sep);
4108
-
4109
- // \u2500\u2500\u2500 Layer tabs (multi-select toggles in tab bar) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4110
- const layerTabsEl = document.getElementById('layer-tabs');
4111
- const allTab = document.createElement('div');
4112
- allTab.className = 'layer-tab active';
4113
- allTab.textContent = 'All';
4114
- allTab.onclick = () => {
4115
- activeLayers.clear();
4116
- syncLayerTabUI();
4117
- applyLayerFilter();
4118
- if (hierBuilt && hierSyncFromTab) { hierSyncFromTab(); hierRelayout(); }
4119
- };
4120
- layerTabsEl.appendChild(allTab);
4121
-
4122
- LAYERS.forEach(layer => {
4123
- const tab = document.createElement('div');
4124
- tab.className = 'layer-tab';
4125
- tab.dataset.layer = layer.name;
4126
- tab.innerHTML = '<div class="lt-dot" style="background:' + esc(layer.color) + '"></div>' + esc(layer.name);
4127
- tab.onclick = (e) => {
4128
- if (e.shiftKey) {
4129
- // Shift+click: solo this layer
4130
- activeLayers.clear();
4131
- activeLayers.add(layer.name);
4132
- } else {
4133
- // Toggle
4134
- if (activeLayers.has(layer.name)) activeLayers.delete(layer.name);
4135
- else activeLayers.add(layer.name);
4136
- }
4137
- syncLayerTabUI();
4138
- applyLayerFilter();
4139
- if (hierBuilt && hierSyncFromTab) { hierSyncFromTab(); hierRelayout(); }
4140
- };
4141
- layerTabsEl.appendChild(tab);
4142
- });
4143
-
4144
- function syncLayerTabUI() {
4145
- allTab.classList.toggle('active', activeLayers.size === 0);
4146
- layerTabsEl.querySelectorAll('.layer-tab[data-layer]').forEach(t => {
4147
- t.classList.toggle('active', activeLayers.has(t.dataset.layer));
4148
- });
4149
- // Also sync the filter bar layer pills
4150
- layerRowEl.querySelectorAll('.layer-pill[data-layer]').forEach(p => {
4151
- p.classList.toggle('active', activeLayers.has(p.dataset.layer));
4152
- });
4153
- }
4154
-
4155
- applyLayerFilter = function() {
4156
- const isSingleLayer = activeLayers.size === 1;
4157
- const hasLayerFilter = activeLayers.size > 0;
4158
- node.attr('display', d => {
4159
- if (!activeDirs.has(d.dir)) return 'none';
4160
- if (hasLayerFilter && !activeLayers.has(d.layer)) return 'none';
4161
- return null;
4162
- });
4163
- link.attr('display', l => {
4164
- const s = l.source.id ?? l.source, t = l.target.id ?? l.target;
4165
- const sN = DATA.nodes.find(n => n.id === s), tN = DATA.nodes.find(n => n.id === t);
4166
- if (!sN || !tN) return 'none';
4167
- if (!activeDirs.has(sN.dir) || !activeDirs.has(tN.dir)) return 'none';
4168
- if (hasLayerFilter && (!activeLayers.has(sN.layer) || !activeLayers.has(tN.layer))) return 'none';
4169
- return null;
4170
- });
4171
- // Refresh node colors: single-layer = dir-based, multi-layer = layer-based
4172
- node.select('circle')
4173
- .attr('fill', nodeColor)
4174
- .attr('stroke', d => d.deps >= 5 ? 'var(--yellow)' : nodeColor(d));
4175
- // Cross-layer links: respect user toggle + layer filter
4176
- if (typeof crossLink !== 'undefined') {
4177
- if (!crossLinksUserEnabled || isSingleLayer) {
4178
- crossLink.attr('display', 'none');
4179
- crossLabel.attr('display', 'none');
4180
- } else if (hasLayerFilter) {
4181
- crossLink.attr('display', d => (activeLayers.has(d.sourceLayer) && activeLayers.has(d.targetLayer)) ? null : 'none');
4182
- crossLabel.attr('display', d => (activeLayers.has(d.sourceLayer) && activeLayers.has(d.targetLayer)) ? null : 'none');
4183
- } else {
4184
- crossLink.attr('display', null);
4185
- crossLabel.attr('display', null);
4186
- }
4187
- }
4188
- // Update stats
4189
- const visibleNodes = DATA.nodes.filter(d => {
4190
- if (!activeDirs.has(d.dir)) return false;
4191
- if (hasLayerFilter && !activeLayers.has(d.layer)) return false;
4192
- return true;
4193
- });
4194
- const visibleIds = new Set(visibleNodes.map(n => n.id));
4195
- const visibleEdges = DATA.links.filter(l => {
4196
- const s = l.source.id ?? l.source, t = l.target.id ?? l.target;
4197
- return visibleIds.has(s) && visibleIds.has(t);
4198
- });
4199
- document.getElementById('s-files').textContent = visibleNodes.length;
4200
- document.getElementById('s-edges').textContent = visibleEdges.length;
4201
- const visCirc = DATA.circularFiles.filter(f => visibleIds.has(f));
4202
- document.getElementById('s-circular').textContent = visCirc.length;
4203
- updateHulls();
4204
- // Delegate physics update and zoom to fit
4205
- updateLayerPhysics();
4206
- simulation.alpha(0.6).restart();
4207
- setTimeout(() => zoomFit(), 600);
4208
- }
4209
-
4210
- // Separated physics update: handles charge/layer forces based on filter state.
4211
- // Called by applyLayerFilter (with zoomFit), setGravity, setLayerGravity (without zoomFit).
4212
- updateLayerPhysics = function() {
4213
- const isSingleLayer = activeLayers.size === 1;
4214
- const lStrength = layerGravity / 100;
4215
- if (isSingleLayer) {
4216
- simulation.force('charge', d3.forceManyBody().strength(-gravityStrength * 3).distanceMax(800));
4217
- simulation.force('layerX', d3.forceX(0).strength(0.03));
4218
- simulation.force('layerY', d3.forceY(0).strength(0.03));
4219
- } else {
4220
- const centers = getLayerCenters();
4221
- simulation.force('charge', d3.forceManyBody().strength(-gravityStrength).distanceMax(500));
4222
- simulation.force('layerX', d3.forceX(d => centers[d.layer]?.x || 0).strength(d => d.layer ? lStrength : 0.03));
4223
- simulation.force('layerY', d3.forceY(d => centers[d.layer]?.y || 0).strength(d => d.layer ? lStrength : 0.03));
4224
- }
4225
- }
4226
-
4227
- // \u2500\u2500\u2500 Layer filter pills (new grouped bar) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4228
- const layerRowEl = document.getElementById('filter-layer-row');
4229
- const dirPanelEl = document.getElementById('filter-dir-panel');
4230
-
4231
- // Dir toggle button
4232
- const dirToggle = document.createElement('div');
4233
- dirToggle.id = 'filter-dir-toggle';
4234
- dirToggle.textContent = '\u25B8 Dirs';
4235
- dirToggle.onclick = () => {
4236
- dirToggle.classList.toggle('open');
4237
- dirPanelEl.classList.toggle('open');
4238
- dirToggle.textContent = dirPanelEl.classList.contains('open') ? '\u25BE Dirs' : '\u25B8 Dirs';
4239
- };
4240
- layerRowEl.appendChild(dirToggle);
4241
-
4242
- // Cross-layer link toggle (in settings sidebar)
4243
- let crossLinksUserEnabled = true;
4244
- if (crossLinkData.length > 0) {
4245
- document.getElementById('cross-layer-setting').style.display = '';
4246
- window.toggleCrossLinks = () => {
4247
- crossLinksUserEnabled = !crossLinksUserEnabled;
4248
- const btn = document.getElementById('cross-link-toggle');
4249
- btn.textContent = crossLinksUserEnabled ? 'ON' : 'OFF';
4250
- btn.classList.toggle('active', crossLinksUserEnabled);
4251
- applyLayerFilter();
4252
- };
4253
- }
4254
-
4255
- LAYERS.forEach(layer => {
4256
- const layerNodes = DATA.nodes.filter(n => n.layer === layer.name);
4257
- const pill = document.createElement('div');
4258
- pill.className = 'layer-pill';
4259
- pill.dataset.layer = layer.name;
4260
- pill.innerHTML = '<div class="lp-dot" style="background:' + esc(layer.color) + '"></div>' + esc(layer.name) + ' <span class="lp-count">' + layerNodes.length + '</span>';
4261
- pill.onclick = () => {
4262
- if (activeLayers.has(layer.name)) activeLayers.delete(layer.name);
4263
- else activeLayers.add(layer.name);
4264
- syncLayerTabUI();
4265
- applyLayerFilter();
4266
- };
4267
- pill.onmouseenter = () => {
4268
- if (pinnedNode) return;
4269
- node.select('circle').transition().duration(120).attr('opacity', d => d.layer === layer.name ? 1 : 0.1);
4270
- node.select('text').transition().duration(120).attr('opacity', d => d.layer === layer.name ? 1 : 0.05);
4271
- };
4272
- pill.onmouseleave = () => {
4273
- if (pinnedNode) return;
4274
- node.select('circle').transition().duration(150).attr('opacity', 1);
4275
- node.select('text').transition().duration(150).attr('opacity', d => d.dependents >= 1 || d.deps >= 3 ? 1 : 0.5);
4276
- };
4277
- layerRowEl.appendChild(pill);
4278
-
4279
- // Build dir group in panel for this layer
4280
- const layerDirs = [...new Set(layerNodes.map(n => n.dir))].sort();
4281
- if (layerDirs.length > 0) {
4282
- const group = document.createElement('div');
4283
- group.className = 'dir-group';
4284
- const label = document.createElement('div');
4285
- label.className = 'dir-group-label';
4286
- label.innerHTML = '<div class="dg-dot" style="background:' + esc(layer.color) + '"></div>' + esc(layer.name);
4287
- group.appendChild(label);
4288
- const pillsWrap = document.createElement('div');
4289
- pillsWrap.className = 'dir-group-pills';
4290
- layerDirs.forEach(dir => {
4291
- const dp = document.createElement('div');
4292
- dp.className = 'filter-pill active';
4293
- const shortDir = dir.includes('/') ? dir.substring(dir.indexOf('/') + 1) : dir;
4294
- dp.innerHTML = '<div class="pill-dot" style="background:' + dirColor(dir) + '"></div>' + esc(shortDir || '.') + ' <span class="pill-count">' + (dirCounts[dir] || 0) + '</span>';
4295
- dp.onclick = () => {
4296
- if (activeDirs.has(dir)) { activeDirs.delete(dir); dp.classList.remove('active'); }
4297
- else { activeDirs.add(dir); dp.classList.add('active'); }
4298
- applyLayerFilter();
4299
- };
4300
- pillsWrap.appendChild(dp);
4301
- });
4302
- group.appendChild(pillsWrap);
4303
- dirPanelEl.appendChild(group);
4304
- }
4305
- });
4306
-
4307
- // Override applyFilter to respect layers
4308
- window._origApplyFilter = applyFilter;
4309
- }
4310
-
4311
- setTimeout(()=>zoomFit(), 1500);
4312
-
4313
- // Restore saved settings \u2014 phase 2: apply to graph elements now that they exist
4314
- if (_savedSettings) {
4315
- if (_savedSettings.fontSize) setFontSize(_savedSettings.fontSize);
4316
- }
4317
-
4318
- // \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
4319
- let pinnedNode = null;
4320
-
4321
- function highlightNode(d) {
4322
- const conn = new Set([d.id]);
4323
- 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); });
4324
- node.select('circle').transition().duration(150).attr('opacity',n=>conn.has(n.id)?1:0.1);
4325
- node.select('text').transition().duration(150).attr('opacity',n=>conn.has(n.id)?1:0.05);
4326
- link.transition().duration(150)
4327
- .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;})
4328
- .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';})
4329
- .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;})
4330
- .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)';});
4331
- }
4332
-
4333
- function resetGraphHighlight() {
4334
- pinnedNode = null;
4335
- node.select('circle').transition().duration(200).attr('opacity',1);
4336
- node.select('text').transition().duration(200).attr('opacity',d=>d.dependents>=1||d.deps>=3?1:0.5);
4337
- link.transition().duration(200)
4338
- .attr('opacity',baseLinkOpacity)
4339
- .attr('stroke',d=>d.type==='type-only'?'#1f3d5c':'#30363d')
4340
- .attr('stroke-width',1).attr('marker-end','url(#arrow-0)');
4341
- }
4342
-
4343
- // \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
4344
- node.on('mouseover', (e,d) => {
4345
- showTooltip(e,d);
4346
- if (!pinnedNode) highlightNode(d);
4347
- })
4348
- .on('mousemove', e=>positionTooltip(e))
4349
- .on('mouseout', () => { scheduleHideTooltip(); if (!pinnedNode) { /* highlight resets via scheduleHideTooltip */ } });
4350
-
4351
- // \u2500\u2500\u2500 Click: pin highlight + detail panel \u2500\u2500\u2500\u2500\u2500
4352
- node.on('click', (e,d) => {
4353
- e.stopPropagation();
4354
- pinnedNode = d;
4355
- highlightNode(d);
4356
- showDetail(d);
4357
- });
4358
- svg.on('click', () => {
4359
- resetGraphHighlight();
4360
- tooltip.style.display = 'none';
4361
- tooltipLocked = false;
4362
- closeDetail();
4363
- });
4364
-
4365
- function showDetail(d) {
4366
- const p=document.getElementById('detail');
4367
- document.getElementById('d-name').textContent=d.id;
4368
- document.getElementById('d-meta').innerHTML=i('detail.dir')+': '+esc(d.dir)+'<br>'+i('detail.dependencies')+': '+d.deps+' \\u00b7 '+i('detail.dependents')+': '+d.dependents;
4369
- const deptL=document.getElementById('d-dependents'), depsL=document.getElementById('d-deps');
4370
- deptL.innerHTML=(d.dependentsList||[]).map(x=>'<li data-focus="'+esc(x)+'">\\u2190 '+esc(x)+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
4371
- depsL.innerHTML=(d.dependencies||[]).map(x=>'<li data-focus="'+esc(x)+'">\\u2192 '+esc(x)+'</li>').join('')||'<li style="color:var(--text-muted)">'+i('detail.none')+'</li>';
4372
- p.classList.add('open');
4373
- }
4374
- // Event delegation for detail panel list items (avoids inline onclick)
4375
- document.getElementById('d-dependents').addEventListener('click', function(e) { var li=e.target.closest('li[data-focus]'); if(li) focusNode(li.dataset.focus); });
4376
- document.getElementById('d-deps').addEventListener('click', function(e) { var li=e.target.closest('li[data-focus]'); if(li) focusNode(li.dataset.focus); });
4377
- window.closeDetail=()=>document.getElementById('detail').classList.remove('open');
4378
- window.focusNode=(id)=>{
4379
- const n=DATA.nodes.find(x=>x.id===id); if(!n)return; showDetail(n);
4380
- 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));
4381
- };
4382
-
4383
- // Drag
4384
- function dragStart(e,d){if(!e.active)simulation.alphaTarget(0.3).restart();d.fx=d.x;d.fy=d.y;}
4385
- function dragging(e,d){d.fx=e.x;d.fy=e.y;}
4386
- function dragEnd(e,d){if(!e.active)simulation.alphaTarget(0);}
4387
-
4388
- // \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
4389
- const searchInput=document.getElementById('search');
4390
- document.addEventListener('keydown',e=>{
4391
- if(e.key==='/'&&document.activeElement!==searchInput){e.preventDefault();searchInput.focus();}
4392
- if(e.key==='Escape'){searchInput.value='';searchInput.blur();resetGraphHighlight();}
4393
- });
4394
- searchInput.addEventListener('input',e=>{
4395
- const q=e.target.value.toLowerCase();
4396
- if(!q){resetGraphHighlight();return;}
4397
- node.select('circle').attr('opacity',d=>d.id.toLowerCase().includes(q)?1:0.06);
4398
- node.select('text').attr('opacity',d=>d.id.toLowerCase().includes(q)?1:0.04);
4399
- link.attr('opacity',0.03);
4400
- });
4401
-
4402
- // \u2500\u2500\u2500 Filters (click=toggle, hover=highlight nodes) \u2500\u2500
4403
- if (!LAYERS) {
4404
- // Non-layer mode: flat pills in filter-layer-row
4405
- const filterRowEl=document.getElementById('filter-layer-row');
4406
- DATA.dirs.forEach(dir=>{
4407
- const pill=document.createElement('div');
4408
- pill.className='filter-pill active';
4409
- pill.innerHTML='<div class="pill-dot" style="background:'+dirColor(dir)+'"></div>'+esc(dir||'.')+' <span class="pill-count">'+dirCounts[dir]+'</span>';
4410
- pill.onclick=()=>{
4411
- if(activeDirs.has(dir)){activeDirs.delete(dir);pill.classList.remove('active');}
4412
- else{activeDirs.add(dir);pill.classList.add('active');}
4413
- applyFilter();
4414
- };
4415
- pill.onmouseenter=()=>{
4416
- if(pinnedNode)return;
4417
- node.select('circle').transition().duration(120).attr('opacity',d=>d.dir===dir?1:0.1);
4418
- node.select('text').transition().duration(120).attr('opacity',d=>d.dir===dir?1:0.05);
4419
- };
4420
- pill.onmouseleave=()=>{
4421
- if(pinnedNode)return;
4422
- node.select('circle').transition().duration(150).attr('opacity',1);
4423
- node.select('text').transition().duration(150).attr('opacity',d=>d.dependents>=1||d.deps>=3?1:0.5);
4424
- };
4425
- filterRowEl.appendChild(pill);
4426
- });
4427
- }
4428
- function applyFilter(){
4429
- if (LAYERS) {
4430
- // Delegate to layer-aware filter
4431
- if (typeof applyLayerFilter === 'function') { applyLayerFilter(); return; }
4432
- }
4433
- node.attr('display',d=>activeDirs.has(d.dir)?null:'none');
4434
- link.attr('display',l=>{
4435
- const s=l.source.id??l.source,t=l.target.id??l.target;
4436
- const sD=DATA.nodes.find(n=>n.id===s)?.dir,tD=DATA.nodes.find(n=>n.id===t)?.dir;
4437
- return activeDirs.has(sD)&&activeDirs.has(tD)?null:'none';
4438
- });
4439
- }
4440
-
4441
- // \u2500\u2500\u2500 Impact simulation mode \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4442
- let impactMode=false;
4443
- const impactBadge=document.getElementById('impact-badge');
4444
- window.toggleImpactMode=()=>{
4445
- impactMode=!impactMode;
4446
- document.getElementById('impact-btn').classList.toggle('active',impactMode);
4447
- if(!impactMode){impactBadge.style.display='none';resetGraphHighlight();}
4448
- };
4449
- function getTransitiveDependents(startId){
4450
- const result=new Set();const queue=[startId];
4451
- const revMap={};
4452
- 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);});
4453
- while(queue.length){const id=queue.shift();if(result.has(id))continue;result.add(id);(revMap[id]||[]).forEach(x=>queue.push(x));}
4454
- return result;
4455
- }
4456
- // Override click in impact mode
4457
- const origClick=node.on('click');
4458
- node.on('click',(e,d)=>{
4459
- if(!impactMode){e.stopPropagation();pinnedNode=d;highlightNode(d);showDetail(d);return;}
4460
- e.stopPropagation();
4461
- const affected=getTransitiveDependents(d.id);
4462
- node.select('circle').transition().duration(200).attr('opacity',n=>affected.has(n.id)?1:0.06)
4463
- .attr('stroke',n=>affected.has(n.id)&&n.id!==d.id?'var(--red)':n.deps>=5?'var(--yellow)':nodeColor(n))
4464
- .attr('stroke-width',n=>affected.has(n.id)?3:1.5);
4465
- node.select('text').transition().duration(200).attr('opacity',n=>affected.has(n.id)?1:0.04);
4466
- link.transition().duration(200).attr('opacity',l=>{
4467
- const s=l.source.id??l.source,t=l.target.id??l.target;
4468
- return affected.has(s)&&affected.has(t)?0.8:0.03;
4469
- }).attr('stroke',l=>{
4470
- const s=l.source.id??l.source,t=l.target.id??l.target;
4471
- return affected.has(s)&&affected.has(t)?'var(--red)':l.type==='type-only'?'#1f3d5c':'#30363d';
4472
- });
4473
- impactBadge.textContent=d.id.split('/').pop()+' \u2192 '+(affected.size-1)+' '+i('impact.transitive');
4474
- impactBadge.style.display='block';
4475
- });
4476
-
4477
- window.addEventListener('resize',()=>{
4478
- const w=window.innerWidth,h=window.innerHeight-44;
4479
- svg.attr('width',w).attr('height',h);
4480
- });
4481
-
4482
- ${buildHierarchyJs()}
4483
- ${buildDiffJs(diffData)}
4484
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
4485
- // INIT
4486
- // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
4487
- applyI18n();
4488
3252
  </script>
3253
+ <script>${viewerJs}</script>
4489
3254
  </body>
4490
3255
  </html>`
4491
3256
  );
@@ -4519,17 +3284,17 @@ function startViewer(graph, options = {}) {
4519
3284
  }
4520
3285
 
4521
3286
  // src/utils/version.ts
4522
- import { readFileSync as readFileSync3 } from "fs";
4523
- import { join as join7, dirname as dirname2 } from "path";
4524
- import { fileURLToPath } from "url";
3287
+ import { readFileSync as readFileSync4 } from "fs";
3288
+ import { join as join9, dirname as dirname3 } from "path";
3289
+ import { fileURLToPath as fileURLToPath2 } from "url";
4525
3290
  function loadVersion() {
4526
- let dir = dirname2(fileURLToPath(import.meta.url));
3291
+ let dir = dirname3(fileURLToPath2(import.meta.url));
4527
3292
  for (let i = 0; i < 5; i++) {
4528
3293
  try {
4529
- const pkg = JSON.parse(readFileSync3(join7(dir, "package.json"), "utf-8"));
3294
+ const pkg = JSON.parse(readFileSync4(join9(dir, "package.json"), "utf-8"));
4530
3295
  return pkg.version;
4531
3296
  } catch {
4532
- dir = dirname2(dir);
3297
+ dir = dirname3(dir);
4533
3298
  }
4534
3299
  }
4535
3300
  return "0.0.0";
@@ -4543,7 +3308,8 @@ async function resolveGraphCli(opts) {
4543
3308
  targetDir: opts.target,
4544
3309
  projectRoot: opts.root,
4545
3310
  exclude: opts.exclude,
4546
- language: opts.language
3311
+ language: opts.language,
3312
+ noCache: opts.noCache
4547
3313
  });
4548
3314
  }
4549
3315
  var program = new Command();
@@ -4568,7 +3334,11 @@ program.command("init").description("Generate initial snapshot and save to .arch
4568
3334
  exclude: opts.exclude,
4569
3335
  language
4570
3336
  });
4571
- const snapshot = await saveSnapshot(opts.root, graph, multiLayer);
3337
+ const snapshot = await saveSnapshot(opts.root, graph, multiLayer, {
3338
+ targetDir: opts.target,
3339
+ language,
3340
+ exclude: opts.exclude
3341
+ });
4572
3342
  console.log(t("cli.snapshotSaved"));
4573
3343
  console.log(t("cli.timestamp", { ts: snapshot.timestamp }));
4574
3344
  console.log(t("cli.fileCount", { count: graph.totalFiles }));
@@ -4595,7 +3365,7 @@ program.command("analyze").description(
4595
3365
  ).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option(
4596
3366
  "-e, --exclude <patterns...>",
4597
3367
  "Exclude patterns (regex)"
4598
- ).option("-n, --top <number>", "Number of top components to show", "10").option("--save", "Also save a snapshot after analysis").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
3368
+ ).option("-n, --top <number>", "Number of top components to show", "10").option("--save", "Also save a snapshot after analysis").option("--no-cache", "Force fresh analysis (ignore graph cache)").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
4599
3369
  try {
4600
3370
  const language = validateLanguage(opts.language);
4601
3371
  console.log(t("cli.analyzing"));
@@ -4603,12 +3373,17 @@ program.command("analyze").description(
4603
3373
  target: opts.target,
4604
3374
  root: opts.root,
4605
3375
  exclude: opts.exclude,
4606
- language
3376
+ language,
3377
+ noCache: opts.noCache
4607
3378
  });
4608
3379
  const report = formatAnalysisReport(graph, { topN: parseInt(opts.top, 10) });
4609
3380
  console.log(report);
4610
3381
  if (opts.save) {
4611
- await saveSnapshot(opts.root, graph, multiLayer);
3382
+ await saveSnapshot(opts.root, graph, multiLayer, {
3383
+ targetDir: opts.target,
3384
+ language,
3385
+ exclude: opts.exclude
3386
+ });
4612
3387
  console.log(t("analyze.snapshotSaved"));
4613
3388
  }
4614
3389
  } catch (error) {
@@ -4617,19 +3392,29 @@ program.command("analyze").description(
4617
3392
  });
4618
3393
  program.command("check").description(
4619
3394
  "Compare snapshot with current code and report change impacts"
4620
- ).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option("--ci", "CI mode: exit code 1 if affected files exist").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
3395
+ ).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option("--ci", "CI mode: exit code 1 if affected files exist").option("--from <timestamp>", "Compare from a historical snapshot (use 'archtracker history' to see timestamps)").option("--no-cache", "Force fresh analysis (ignore graph cache)").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
4621
3396
  try {
4622
3397
  const language = validateLanguage(opts.language);
4623
- const existingSnapshot = await loadSnapshot(opts.root);
4624
- if (!existingSnapshot) {
4625
- console.log(t("cli.noSnapshot"));
4626
- process.exit(1);
3398
+ let existingSnapshot;
3399
+ if (opts.from) {
3400
+ existingSnapshot = await loadSnapshotByTimestamp(opts.root, opts.from);
3401
+ if (!existingSnapshot) {
3402
+ console.log(t("history.snapshotNotFound", { ts: opts.from }));
3403
+ process.exit(1);
3404
+ }
3405
+ } else {
3406
+ existingSnapshot = await loadSnapshot(opts.root);
3407
+ if (!existingSnapshot) {
3408
+ console.log(t("cli.noSnapshot"));
3409
+ process.exit(1);
3410
+ }
4627
3411
  }
4628
3412
  console.log(t("cli.analyzing"));
4629
3413
  const { graph: currentGraph } = await resolveGraphCli({
4630
3414
  target: opts.target,
4631
3415
  root: opts.root,
4632
- language
3416
+ language,
3417
+ noCache: opts.noCache
4633
3418
  });
4634
3419
  const diff = computeDiff(existingSnapshot.graph, currentGraph);
4635
3420
  const report = formatDiffReport(diff);
@@ -4644,7 +3429,7 @@ program.command("check").description(
4644
3429
  });
4645
3430
  program.command("context").description(
4646
3431
  "Display current architecture context (for AI session initialization)"
4647
- ).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option("--json", "Output in JSON format").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
3432
+ ).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option("--json", "Output in JSON format").option("--no-cache", "Force fresh analysis (ignore graph cache)").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
4648
3433
  try {
4649
3434
  const language = validateLanguage(opts.language);
4650
3435
  let snapshot = await loadSnapshot(opts.root);
@@ -4653,9 +3438,13 @@ program.command("context").description(
4653
3438
  const result = await resolveGraphCli({
4654
3439
  target: opts.target,
4655
3440
  root: opts.root,
3441
+ language,
3442
+ noCache: opts.noCache
3443
+ });
3444
+ snapshot = await saveSnapshot(opts.root, result.graph, result.multiLayer, {
3445
+ targetDir: opts.target,
4656
3446
  language
4657
3447
  });
4658
- snapshot = await saveSnapshot(opts.root, result.graph, result.multiLayer);
4659
3448
  }
4660
3449
  const graph = snapshot.graph;
4661
3450
  if (opts.json) {
@@ -4692,7 +3481,7 @@ program.command("serve").description(
4692
3481
  ).option("-t, --target <dir>", "Target directory", "src").option("-r, --root <dir>", "Project root", ".").option("-p, --port <number>", "Port number", "3000").option(
4693
3482
  "-e, --exclude <patterns...>",
4694
3483
  "Exclude patterns (regex)"
4695
- ).option("-w, --watch", "Watch for file changes and auto-reload").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
3484
+ ).option("-w, --watch", "Watch for file changes and auto-reload").option("--no-cache", "Force fresh analysis (ignore graph cache)").option("-l, --language <lang>", `Target language (${LANGUAGE_IDS.join(", ")})`).action(async (opts) => {
4696
3485
  try {
4697
3486
  const language = validateLanguage(opts.language);
4698
3487
  console.log(t("web.starting"));
@@ -4702,7 +3491,8 @@ program.command("serve").description(
4702
3491
  target: opts.target,
4703
3492
  root: opts.root,
4704
3493
  exclude: opts.exclude,
4705
- language
3494
+ language,
3495
+ noCache: opts.noCache
4706
3496
  });
4707
3497
  const snapshot = await loadSnapshot(opts.root);
4708
3498
  if (snapshot) {
@@ -4729,7 +3519,9 @@ program.command("serve").description(
4729
3519
  target: opts.target,
4730
3520
  root: opts.root,
4731
3521
  exclude: opts.exclude,
4732
- language
3522
+ language,
3523
+ noCache: true
3524
+ // Watch mode always needs fresh analysis
4733
3525
  });
4734
3526
  viewer.close();
4735
3527
  startViewer(newResult.graph, {
@@ -4768,15 +3560,41 @@ jobs:
4768
3560
  - run: npx archtracker check --target ${opts.target} --ci
4769
3561
  `;
4770
3562
  try {
4771
- const dir = join8(".github", "workflows");
4772
- await mkdir3(dir, { recursive: true });
4773
- const path = join8(dir, "arch-check.yml");
4774
- await writeFile3(path, workflow, "utf-8");
3563
+ const dir = join10(".github", "workflows");
3564
+ await mkdir4(dir, { recursive: true });
3565
+ const path = join10(dir, "arch-check.yml");
3566
+ await writeFile4(path, workflow, "utf-8");
4775
3567
  console.log(t("ci.generated", { path }));
4776
3568
  } catch (error) {
4777
3569
  handleError(error);
4778
3570
  }
4779
3571
  });
3572
+ program.command("history").description("List all saved architecture snapshots").option("-r, --root <dir>", "Project root", ".").option("-n, --limit <number>", "Max entries to show", "20").action(async (opts) => {
3573
+ try {
3574
+ const snapshots = await listSnapshots(opts.root);
3575
+ if (snapshots.length === 0) {
3576
+ console.log(t("history.empty"));
3577
+ return;
3578
+ }
3579
+ const limit = parseInt(opts.limit, 10);
3580
+ const shown = snapshots.slice(0, limit);
3581
+ console.log(t("history.title"));
3582
+ console.log(t("history.count", { count: snapshots.length }));
3583
+ console.log("");
3584
+ for (const s of shown) {
3585
+ const layers = s.hasMultiLayer ? " [multi-layer]" : "";
3586
+ console.log(t("history.entry", {
3587
+ ts: s.timestamp,
3588
+ files: s.totalFiles,
3589
+ edges: s.totalEdges,
3590
+ circular: s.circularDeps,
3591
+ layers
3592
+ }));
3593
+ }
3594
+ } catch (error) {
3595
+ handleError(error);
3596
+ }
3597
+ });
4780
3598
  var layersCmd = program.command("layers").description("Manage multi-layer architecture configuration");
4781
3599
  layersCmd.command("init").description("Create a template .archtracker/layers.json").option("-r, --root <dir>", "Project root", ".").action(async (opts) => {
4782
3600
  try {