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/mcp/index.js CHANGED
@@ -1562,6 +1562,8 @@ var en = {
1562
1562
  "diff.reasonRemoved": 'Dependency "{file}" was removed',
1563
1563
  "diff.reasonModified": 'Dependency "{file}" had its dependencies changed',
1564
1564
  "diff.reasonAdded": 'New dependency "{file}" was added',
1565
+ "diff.testSummary": " ... and {count} test/fixture file(s)",
1566
+ "diff.testAffectedSummary": " ... and {count} test/fixture-related review(s)",
1565
1567
  // Search
1566
1568
  "search.pathMatch": 'Path matches "{pattern}"',
1567
1569
  "search.affected": 'May be affected by changes to "{file}" (via: {via})',
@@ -1623,6 +1625,12 @@ var en = {
1623
1625
  "web.watching": "Watching {dir}/ for changes...",
1624
1626
  "web.reloading": "File change detected, reloading...",
1625
1627
  "web.reloaded": "Graph reloaded",
1628
+ // History
1629
+ "history.title": "# Snapshot History\n",
1630
+ "history.empty": "No snapshots found. Run `archtracker init` to create one.",
1631
+ "history.entry": " {ts} | {files} files, {edges} edges, {circular} circular{layers}",
1632
+ "history.count": "{count} snapshot(s) recorded",
1633
+ "history.snapshotNotFound": "Snapshot not found for timestamp: {ts}",
1626
1634
  // Errors
1627
1635
  "error.analyzer": "[Analysis Error] {message}",
1628
1636
  "error.storage": "[Storage Error] {message}",
@@ -1655,6 +1663,8 @@ var ja = {
1655
1663
  "diff.reasonRemoved": '\u4F9D\u5B58\u5148 "{file}" \u304C\u524A\u9664\u3055\u308C\u307E\u3057\u305F',
1656
1664
  "diff.reasonModified": '\u4F9D\u5B58\u5148 "{file}" \u306E\u4F9D\u5B58\u95A2\u4FC2\u304C\u5909\u66F4\u3055\u308C\u307E\u3057\u305F',
1657
1665
  "diff.reasonAdded": '\u65B0\u3057\u3044\u4F9D\u5B58\u5148 "{file}" \u304C\u8FFD\u52A0\u3055\u308C\u307E\u3057\u305F',
1666
+ "diff.testSummary": " ... \u4ED6 {count}\u4EF6\u306E\u30C6\u30B9\u30C8/\u30D5\u30A3\u30AF\u30B9\u30C1\u30E3\u30D5\u30A1\u30A4\u30EB",
1667
+ "diff.testAffectedSummary": " ... \u4ED6 {count}\u4EF6\u306E\u30C6\u30B9\u30C8/\u30D5\u30A3\u30AF\u30B9\u30C1\u30E3\u95A2\u9023\u306E\u78BA\u8A8D\u9805\u76EE",
1658
1668
  // Search
1659
1669
  "search.pathMatch": '\u30D1\u30B9\u304C "{pattern}" \u306B\u30DE\u30C3\u30C1',
1660
1670
  "search.affected": '"{file}" \u306E\u5909\u66F4\u306B\u3088\u308A\u5F71\u97FF\u3092\u53D7\u3051\u308B\u53EF\u80FD\u6027\uFF08\u7D4C\u7531: {via}\uFF09',
@@ -1716,6 +1726,12 @@ var ja = {
1716
1726
  "web.watching": "{dir}/ \u3092\u76E3\u8996\u4E2D...",
1717
1727
  "web.reloading": "\u30D5\u30A1\u30A4\u30EB\u5909\u66F4\u3092\u691C\u51FA\u3001\u30EA\u30ED\u30FC\u30C9\u4E2D...",
1718
1728
  "web.reloaded": "\u30B0\u30E9\u30D5\u3092\u66F4\u65B0\u3057\u307E\u3057\u305F",
1729
+ // History
1730
+ "history.title": "# \u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u5C65\u6B74\n",
1731
+ "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",
1732
+ "history.entry": " {ts} | {files}\u30D5\u30A1\u30A4\u30EB, {edges}\u30A8\u30C3\u30B8, \u5FAA\u74B0{circular}\u4EF6{layers}",
1733
+ "history.count": "{count}\u4EF6\u306E\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8\u3092\u8A18\u9332",
1734
+ "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}",
1719
1735
  // Errors
1720
1736
  "error.analyzer": "[\u89E3\u6790\u30A8\u30E9\u30FC] {message}",
1721
1737
  "error.storage": "[\u30B9\u30C8\u30EC\u30FC\u30B8\u30A8\u30E9\u30FC] {message}",
@@ -1858,14 +1874,14 @@ var LAYER_COLORS = [
1858
1874
  "#ffa657",
1859
1875
  "#7ee787"
1860
1876
  ];
1861
- async function analyzeMultiLayer(projectRoot, layerDefs) {
1877
+ async function analyzeMultiLayer(projectRoot, layerDefs, globalExclude) {
1862
1878
  const layers = {};
1863
1879
  const layerMetadata = [];
1864
1880
  for (let idx = 0; idx < layerDefs.length; idx++) {
1865
1881
  const def = layerDefs[idx];
1866
1882
  const targetDir = resolve4(projectRoot, def.targetDir);
1867
1883
  const graph = await analyzeProject(targetDir, {
1868
- exclude: def.exclude,
1884
+ exclude: def.exclude ?? globalExclude,
1869
1885
  language: def.language
1870
1886
  });
1871
1887
  const language = def.language ?? await detectLanguage(targetDir) ?? "javascript";
@@ -2291,11 +2307,240 @@ function isNodeError(error) {
2291
2307
  return error instanceof Error && "code" in error;
2292
2308
  }
2293
2309
 
2310
+ // src/storage/graph-cache.ts
2311
+ import { readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2, stat as stat4 } from "fs/promises";
2312
+ import { join as join6, resolve as resolve5 } from "path";
2313
+ import { readdir as readdir3 } from "fs/promises";
2314
+ import { createHash } from "crypto";
2315
+ var CACHE_FILE = "graph.json";
2316
+ var ARCHTRACKER_DIR2 = ".archtracker";
2317
+ async function hashFile(filePath) {
2318
+ const content = await readFile3(filePath);
2319
+ return createHash("sha256").update(content).digest("hex");
2320
+ }
2321
+ async function collectFileFingerprints(dir, exclude = []) {
2322
+ const fingerprints = {};
2323
+ const excludeRegexes = exclude.map((p) => new RegExp(p));
2324
+ async function walk(currentDir) {
2325
+ let entries;
2326
+ try {
2327
+ entries = await readdir3(currentDir, { withFileTypes: true });
2328
+ } catch {
2329
+ return;
2330
+ }
2331
+ for (const entry of entries) {
2332
+ const fullPath = join6(currentDir, entry.name);
2333
+ const relativePath = fullPath.slice(dir.length + 1);
2334
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
2335
+ if (excludeRegexes.some((r) => r.test(relativePath))) continue;
2336
+ if (entry.isDirectory()) {
2337
+ await walk(fullPath);
2338
+ } else if (entry.isFile()) {
2339
+ try {
2340
+ const s = await stat4(fullPath);
2341
+ const hash = await hashFile(fullPath);
2342
+ fingerprints[relativePath] = { mtime: s.mtimeMs, hash };
2343
+ } catch {
2344
+ }
2345
+ }
2346
+ }
2347
+ }
2348
+ await walk(dir);
2349
+ return fingerprints;
2350
+ }
2351
+ async function saveGraphCache(projectRoot, graph, options, extra) {
2352
+ const absRoot = resolve5(projectRoot);
2353
+ const dir = join6(absRoot, ARCHTRACKER_DIR2);
2354
+ await mkdir2(dir, { recursive: true });
2355
+ let fingerprints;
2356
+ if (extra?.layerDirs?.length) {
2357
+ fingerprints = {};
2358
+ for (const layerDir of extra.layerDirs) {
2359
+ const layerPath = resolve5(absRoot, layerDir);
2360
+ const layerFp = await collectFileFingerprints(layerPath, options.exclude);
2361
+ for (const [key, value] of Object.entries(layerFp)) {
2362
+ fingerprints[`${layerDir}/${key}`] = value;
2363
+ }
2364
+ }
2365
+ } else {
2366
+ fingerprints = await collectFingerprintsForGraph(absRoot, options);
2367
+ }
2368
+ let layersJsonHash;
2369
+ try {
2370
+ layersJsonHash = await hashFile(join6(dir, "layers.json"));
2371
+ } catch {
2372
+ }
2373
+ const mtimes = {};
2374
+ for (const [k, v] of Object.entries(fingerprints)) {
2375
+ mtimes[k] = v.mtime;
2376
+ }
2377
+ const cache = {
2378
+ version: "1.0",
2379
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2380
+ options: {
2381
+ targetDir: options.targetDir,
2382
+ projectRoot: options.projectRoot,
2383
+ language: options.language,
2384
+ exclude: options.exclude
2385
+ },
2386
+ fileMtimes: mtimes,
2387
+ fileHashes: Object.fromEntries(
2388
+ Object.entries(fingerprints).map(([k, v]) => [k, v.hash])
2389
+ ),
2390
+ graph,
2391
+ multiLayer: extra?.multiLayer,
2392
+ layerMetadata: extra?.layerMetadata,
2393
+ layerDirs: extra?.layerDirs,
2394
+ layersJsonHash
2395
+ };
2396
+ await writeFile2(join6(dir, CACHE_FILE), JSON.stringify(cache), "utf-8");
2397
+ }
2398
+ async function loadGraphCache(projectRoot) {
2399
+ const absRoot = resolve5(projectRoot);
2400
+ const filePath = join6(absRoot, ARCHTRACKER_DIR2, CACHE_FILE);
2401
+ try {
2402
+ const raw = await readFile3(filePath, "utf-8");
2403
+ const data = JSON.parse(raw);
2404
+ if (data?.version !== "1.0" || !data?.graph || !data?.fileMtimes) {
2405
+ return null;
2406
+ }
2407
+ return data;
2408
+ } catch {
2409
+ return null;
2410
+ }
2411
+ }
2412
+ async function isGraphCacheValid(cache, projectRoot, options) {
2413
+ 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 ?? [])) {
2414
+ return false;
2415
+ }
2416
+ const absRoot = resolve5(projectRoot);
2417
+ const targetPath = resolve5(absRoot, options.targetDir);
2418
+ const isMultiLayer = (cache.layerDirs?.length ?? 0) > 0;
2419
+ let currentLayersHash;
2420
+ try {
2421
+ currentLayersHash = await hashFile(join6(absRoot, ARCHTRACKER_DIR2, "layers.json"));
2422
+ } catch {
2423
+ }
2424
+ if ((cache.layersJsonHash ?? "") !== (currentLayersHash ?? "")) return false;
2425
+ let currentMtimes;
2426
+ if (isMultiLayer) {
2427
+ currentMtimes = {};
2428
+ for (const layerDir of cache.layerDirs) {
2429
+ const layerPath = resolve5(absRoot, layerDir);
2430
+ const dirMtimes = await collectFileMtimes(layerPath, options.exclude);
2431
+ for (const [key, value] of Object.entries(dirMtimes)) {
2432
+ currentMtimes[`${layerDir}/${key}`] = value;
2433
+ }
2434
+ }
2435
+ } else {
2436
+ currentMtimes = await collectFileMtimes(targetPath, options.exclude);
2437
+ }
2438
+ const cachedKeys = new Set(Object.keys(cache.fileMtimes));
2439
+ const currentKeys = new Set(Object.keys(currentMtimes));
2440
+ if (cachedKeys.size !== currentKeys.size) return false;
2441
+ for (const key of cachedKeys) {
2442
+ if (!currentKeys.has(key)) return false;
2443
+ }
2444
+ const mtimeChanged = [];
2445
+ for (const key of cachedKeys) {
2446
+ if (Math.abs(cache.fileMtimes[key] - currentMtimes[key]) > 1) {
2447
+ mtimeChanged.push(key);
2448
+ }
2449
+ }
2450
+ if (mtimeChanged.length === 0) return true;
2451
+ if (!cache.fileHashes) return false;
2452
+ for (const key of mtimeChanged) {
2453
+ const cachedHash = cache.fileHashes[key];
2454
+ if (!cachedHash) return false;
2455
+ try {
2456
+ const fullPath = isMultiLayer ? join6(absRoot, key) : join6(targetPath, key);
2457
+ const currentHash = await hashFile(fullPath);
2458
+ if (currentHash !== cachedHash) return false;
2459
+ } catch {
2460
+ return false;
2461
+ }
2462
+ }
2463
+ return true;
2464
+ }
2465
+ async function collectFileMtimes(dir, exclude) {
2466
+ const mtimes = {};
2467
+ const excludeRegexes = (exclude ?? []).map((p) => new RegExp(p));
2468
+ async function walk(currentDir) {
2469
+ let entries;
2470
+ try {
2471
+ entries = await readdir3(currentDir, { withFileTypes: true });
2472
+ } catch {
2473
+ return;
2474
+ }
2475
+ for (const entry of entries) {
2476
+ const fullPath = join6(currentDir, entry.name);
2477
+ const relativePath = fullPath.slice(dir.length + 1);
2478
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
2479
+ if (excludeRegexes.some((r) => r.test(relativePath))) continue;
2480
+ if (entry.isDirectory()) {
2481
+ await walk(fullPath);
2482
+ } else if (entry.isFile()) {
2483
+ try {
2484
+ const s = await stat4(fullPath);
2485
+ mtimes[relativePath] = s.mtimeMs;
2486
+ } catch {
2487
+ }
2488
+ }
2489
+ }
2490
+ }
2491
+ await walk(dir);
2492
+ return mtimes;
2493
+ }
2494
+ async function collectFingerprintsForGraph(absRoot, options) {
2495
+ const targetPath = resolve5(absRoot, options.targetDir);
2496
+ return collectFileFingerprints(targetPath, options.exclude);
2497
+ }
2498
+
2294
2499
  // src/analyzer/resolve.ts
2295
2500
  async function resolveGraph(opts) {
2501
+ if (!opts.noCache) {
2502
+ const cache = await loadGraphCache(opts.projectRoot);
2503
+ if (cache) {
2504
+ const valid = await isGraphCacheValid(cache, opts.projectRoot, {
2505
+ targetDir: opts.targetDir,
2506
+ projectRoot: opts.projectRoot,
2507
+ language: opts.language,
2508
+ exclude: opts.exclude
2509
+ });
2510
+ if (valid) {
2511
+ const result2 = {
2512
+ graph: cache.graph,
2513
+ multiLayer: cache.multiLayer,
2514
+ layerMetadata: cache.layerMetadata,
2515
+ fromCache: true
2516
+ };
2517
+ if (cache.multiLayer) {
2518
+ const layerConfig2 = await loadLayerConfig(opts.projectRoot);
2519
+ if (layerConfig2) {
2520
+ const autoConnections = detectCrossLayerConnections(
2521
+ cache.multiLayer.layers,
2522
+ layerConfig2.layers
2523
+ );
2524
+ const manualConnections = layerConfig2.connections ?? [];
2525
+ const manualKeys = new Set(manualConnections.map(
2526
+ (c) => `${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`
2527
+ ));
2528
+ result2.crossLayerEdges = [
2529
+ ...manualConnections,
2530
+ ...autoConnections.filter(
2531
+ (c) => !manualKeys.has(`${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`)
2532
+ )
2533
+ ];
2534
+ }
2535
+ }
2536
+ return result2;
2537
+ }
2538
+ }
2539
+ }
2296
2540
  const layerConfig = await loadLayerConfig(opts.projectRoot);
2541
+ let result;
2297
2542
  if (layerConfig) {
2298
- const multi = await analyzeMultiLayer(opts.projectRoot, layerConfig.layers);
2543
+ const multi = await analyzeMultiLayer(opts.projectRoot, layerConfig.layers, opts.exclude);
2299
2544
  const autoConnections = detectCrossLayerConnections(multi.layers, layerConfig.layers);
2300
2545
  const manualConnections = layerConfig.connections ?? [];
2301
2546
  const manualKeys = new Set(manualConnections.map(
@@ -2307,31 +2552,52 @@ async function resolveGraph(opts) {
2307
2552
  (c) => !manualKeys.has(`${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`)
2308
2553
  )
2309
2554
  ];
2310
- return {
2555
+ result = {
2311
2556
  graph: multi.merged,
2312
2557
  multiLayer: multi,
2313
2558
  layerMetadata: multi.layerMetadata,
2314
2559
  crossLayerEdges: merged
2315
2560
  };
2561
+ } else {
2562
+ const graph = await analyzeProject(opts.targetDir, {
2563
+ exclude: opts.exclude,
2564
+ language: opts.language
2565
+ });
2566
+ result = { graph };
2316
2567
  }
2317
- const graph = await analyzeProject(opts.targetDir, {
2318
- exclude: opts.exclude,
2319
- language: opts.language
2320
- });
2321
- return { graph };
2568
+ try {
2569
+ await saveGraphCache(
2570
+ opts.projectRoot,
2571
+ result.graph,
2572
+ {
2573
+ targetDir: opts.targetDir,
2574
+ projectRoot: opts.projectRoot,
2575
+ language: opts.language,
2576
+ exclude: opts.exclude
2577
+ },
2578
+ {
2579
+ multiLayer: result.multiLayer,
2580
+ layerMetadata: result.layerMetadata,
2581
+ layerDirs: layerConfig?.layers.map((l) => l.targetDir)
2582
+ }
2583
+ );
2584
+ } catch {
2585
+ }
2586
+ return result;
2322
2587
  }
2323
2588
 
2324
2589
  // src/storage/snapshot.ts
2325
- import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile3, access } from "fs/promises";
2326
- import { join as join6 } from "path";
2590
+ import { mkdir as mkdir3, writeFile as writeFile3, readFile as readFile4, readdir as readdir4, access } from "fs/promises";
2591
+ import { join as join7 } from "path";
2327
2592
  import { z as z2 } from "zod";
2328
2593
 
2329
2594
  // src/types/schema.ts
2330
2595
  var SCHEMA_VERSION = "1.1";
2331
2596
 
2332
2597
  // src/storage/snapshot.ts
2333
- var ARCHTRACKER_DIR2 = ".archtracker";
2598
+ var ARCHTRACKER_DIR3 = ".archtracker";
2334
2599
  var SNAPSHOT_FILE = "snapshot.json";
2600
+ var HISTORY_DIR = "history";
2335
2601
  var FileNodeSchema = z2.object({
2336
2602
  path: z2.string(),
2337
2603
  exists: z2.boolean(),
@@ -2356,25 +2622,34 @@ var SnapshotSchema = z2.object({
2356
2622
  rootDir: z2.string(),
2357
2623
  graph: DependencyGraphSchema
2358
2624
  });
2359
- async function saveSnapshot(projectRoot, graph, multiLayer) {
2360
- const dirPath = join6(projectRoot, ARCHTRACKER_DIR2);
2361
- const filePath = join6(dirPath, SNAPSHOT_FILE);
2625
+ async function saveSnapshot(projectRoot, graph, multiLayer, analysisOptions) {
2626
+ const dirPath = join7(projectRoot, ARCHTRACKER_DIR3);
2627
+ const filePath = join7(dirPath, SNAPSHOT_FILE);
2362
2628
  const snapshot = {
2363
2629
  version: SCHEMA_VERSION,
2364
2630
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2365
2631
  rootDir: graph.rootDir,
2366
2632
  graph,
2367
- ...multiLayer ? { multiLayer } : {}
2633
+ ...multiLayer ? { multiLayer } : {},
2634
+ ...analysisOptions ? { analysisOptions } : {}
2368
2635
  };
2369
- await mkdir2(dirPath, { recursive: true });
2370
- await writeFile2(filePath, JSON.stringify(snapshot, null, 2), "utf-8");
2636
+ await mkdir3(dirPath, { recursive: true });
2637
+ const json = JSON.stringify(snapshot, null, 2);
2638
+ await writeFile3(filePath, json, "utf-8");
2639
+ try {
2640
+ const historyPath = join7(dirPath, HISTORY_DIR);
2641
+ await mkdir3(historyPath, { recursive: true });
2642
+ const safeTs = snapshot.timestamp.replace(/[:.]/g, "-");
2643
+ await writeFile3(join7(historyPath, `${safeTs}.json`), json, "utf-8");
2644
+ } catch {
2645
+ }
2371
2646
  return snapshot;
2372
2647
  }
2373
2648
  async function loadSnapshot(projectRoot) {
2374
- const filePath = join6(projectRoot, ARCHTRACKER_DIR2, SNAPSHOT_FILE);
2649
+ const filePath = join7(projectRoot, ARCHTRACKER_DIR3, SNAPSHOT_FILE);
2375
2650
  let raw;
2376
2651
  try {
2377
- raw = await readFile3(filePath, "utf-8");
2652
+ raw = await readFile4(filePath, "utf-8");
2378
2653
  } catch (error) {
2379
2654
  if (isNodeError2(error) && error.code === "ENOENT") {
2380
2655
  return null;
@@ -2401,6 +2676,67 @@ async function loadSnapshot(projectRoot) {
2401
2676
  }
2402
2677
  return result.data;
2403
2678
  }
2679
+ async function listSnapshots(projectRoot) {
2680
+ const historyPath = join7(projectRoot, ARCHTRACKER_DIR3, HISTORY_DIR);
2681
+ let entries;
2682
+ try {
2683
+ entries = await readdir4(historyPath);
2684
+ } catch {
2685
+ return [];
2686
+ }
2687
+ const jsonFiles = entries.filter((e) => e.endsWith(".json")).sort().reverse();
2688
+ const summaries = [];
2689
+ for (const file of jsonFiles) {
2690
+ try {
2691
+ const raw = await readFile4(join7(historyPath, file), "utf-8");
2692
+ const parsed = JSON.parse(raw);
2693
+ const result = SnapshotSchema.safeParse(parsed);
2694
+ if (result.success) {
2695
+ const snap = result.data;
2696
+ summaries.push({
2697
+ timestamp: snap.timestamp,
2698
+ totalFiles: snap.graph.totalFiles,
2699
+ totalEdges: snap.graph.totalEdges,
2700
+ circularDeps: snap.graph.circularDependencies.length,
2701
+ hasMultiLayer: "multiLayer" in parsed && parsed.multiLayer != null
2702
+ });
2703
+ }
2704
+ } catch {
2705
+ }
2706
+ }
2707
+ return summaries;
2708
+ }
2709
+ async function loadSnapshotByTimestamp(projectRoot, timestamp) {
2710
+ const historyPath = join7(projectRoot, ARCHTRACKER_DIR3, HISTORY_DIR);
2711
+ let entries;
2712
+ try {
2713
+ entries = await readdir4(historyPath);
2714
+ } catch {
2715
+ return null;
2716
+ }
2717
+ const jsonFiles = entries.filter((e) => e.endsWith(".json")).sort();
2718
+ const safeTs = timestamp.replace(/[:.]/g, "-");
2719
+ const exactMatch = jsonFiles.find((f) => f === `${safeTs}.json`);
2720
+ if (exactMatch) {
2721
+ return loadHistoryFile(join7(historyPath, exactMatch));
2722
+ }
2723
+ const prefixMatch = jsonFiles.find((f) => f.startsWith(safeTs.slice(0, 10)));
2724
+ if (prefixMatch) {
2725
+ return loadHistoryFile(join7(historyPath, prefixMatch));
2726
+ }
2727
+ return null;
2728
+ }
2729
+ async function loadHistoryFile(filePath) {
2730
+ try {
2731
+ const raw = await readFile4(filePath, "utf-8");
2732
+ const parsed = JSON.parse(raw);
2733
+ const result = SnapshotSchema.safeParse(parsed);
2734
+ if (result.success) return result.data;
2735
+ return null;
2736
+ } catch {
2737
+ return null;
2738
+ }
2739
+ }
2404
2740
  var StorageError = class extends Error {
2405
2741
  constructor(message, options) {
2406
2742
  super(message, options);
@@ -2462,6 +2798,17 @@ function computeDiff(oldGraph, newGraph) {
2462
2798
  }
2463
2799
  return { added, removed, modified, affectedDependents };
2464
2800
  }
2801
+ function isTestOrFixture(path) {
2802
+ return /(__fixtures__|__tests__|__mocks__|\.test\.|\.spec\.|\.e2e\.)/.test(path);
2803
+ }
2804
+ function partition(arr, pred) {
2805
+ const yes = [];
2806
+ const no = [];
2807
+ for (const item of arr) {
2808
+ (pred(item) ? yes : no).push(item);
2809
+ }
2810
+ return [yes, no];
2811
+ }
2465
2812
  function formatDiffReport(diff) {
2466
2813
  const lines = [];
2467
2814
  lines.push(t("diff.title"));
@@ -2470,32 +2817,51 @@ function formatDiffReport(diff) {
2470
2817
  return lines.join("\n");
2471
2818
  }
2472
2819
  if (diff.added.length > 0) {
2820
+ const [testFiles, srcFiles] = partition(diff.added, isTestOrFixture);
2473
2821
  lines.push(t("diff.added", { count: diff.added.length }));
2474
- for (const f of diff.added) {
2822
+ for (const f of srcFiles) {
2475
2823
  lines.push(` + ${f}`);
2476
2824
  }
2825
+ if (testFiles.length > 0) {
2826
+ lines.push(t("diff.testSummary", { count: testFiles.length }));
2827
+ }
2477
2828
  lines.push("");
2478
2829
  }
2479
2830
  if (diff.removed.length > 0) {
2831
+ const [testFiles, srcFiles] = partition(diff.removed, isTestOrFixture);
2480
2832
  lines.push(t("diff.removed", { count: diff.removed.length }));
2481
- for (const f of diff.removed) {
2833
+ for (const f of srcFiles) {
2482
2834
  lines.push(` - ${f}`);
2483
2835
  }
2836
+ if (testFiles.length > 0) {
2837
+ lines.push(t("diff.testSummary", { count: testFiles.length }));
2838
+ }
2484
2839
  lines.push("");
2485
2840
  }
2486
2841
  if (diff.modified.length > 0) {
2842
+ const [testFiles, srcFiles] = partition(diff.modified, isTestOrFixture);
2487
2843
  lines.push(t("diff.modified", { count: diff.modified.length }));
2488
- for (const f of diff.modified) {
2844
+ for (const f of srcFiles) {
2489
2845
  lines.push(` ~ ${f}`);
2490
2846
  }
2847
+ if (testFiles.length > 0) {
2848
+ lines.push(t("diff.testSummary", { count: testFiles.length }));
2849
+ }
2491
2850
  lines.push("");
2492
2851
  }
2493
2852
  if (diff.affectedDependents.length > 0) {
2853
+ const [testEntries, srcEntries] = partition(
2854
+ diff.affectedDependents,
2855
+ (a) => isTestOrFixture(a.file) || isTestOrFixture(a.dependsOn)
2856
+ );
2494
2857
  lines.push(t("diff.affected", { count: diff.affectedDependents.length }));
2495
- for (const a of diff.affectedDependents) {
2858
+ for (const a of srcEntries) {
2496
2859
  lines.push(` ! ${a.file}`);
2497
2860
  lines.push(` ${a.reason}`);
2498
2861
  }
2862
+ if (testEntries.length > 0) {
2863
+ lines.push(t("diff.testAffectedSummary", { count: testEntries.length }));
2864
+ }
2499
2865
  lines.push("");
2500
2866
  }
2501
2867
  return lines.join("\n");
@@ -2509,10 +2875,10 @@ function arraysEqual(a, b) {
2509
2875
  }
2510
2876
 
2511
2877
  // src/utils/path-guard.ts
2512
- import { resolve as resolve5 } from "path";
2878
+ import { resolve as resolve6 } from "path";
2513
2879
  function validatePath(inputPath, boundary) {
2514
- const resolved = resolve5(inputPath);
2515
- const root = boundary ? resolve5(boundary) : process.cwd();
2880
+ const root = boundary ? resolve6(boundary) : process.cwd();
2881
+ const resolved = boundary ? resolve6(root, inputPath) : resolve6(inputPath);
2516
2882
  if (!resolved.startsWith(root)) {
2517
2883
  throw new PathTraversalError(
2518
2884
  t("pathGuard.traversal", { input: inputPath, resolved, boundary: root })
@@ -2520,6 +2886,9 @@ function validatePath(inputPath, boundary) {
2520
2886
  }
2521
2887
  return resolved;
2522
2888
  }
2889
+ function resolveProjectRoot(inputPath) {
2890
+ return resolve6(inputPath);
2891
+ }
2523
2892
  var PathTraversalError = class extends Error {
2524
2893
  constructor(message) {
2525
2894
  super(message);
@@ -2529,13 +2898,13 @@ var PathTraversalError = class extends Error {
2529
2898
 
2530
2899
  // src/utils/version.ts
2531
2900
  import { readFileSync as readFileSync3 } from "fs";
2532
- import { join as join7, dirname as dirname2 } from "path";
2901
+ import { join as join8, dirname as dirname2 } from "path";
2533
2902
  import { fileURLToPath } from "url";
2534
2903
  function loadVersion() {
2535
2904
  let dir = dirname2(fileURLToPath(import.meta.url));
2536
2905
  for (let i = 0; i < 5; i++) {
2537
2906
  try {
2538
- const pkg = JSON.parse(readFileSync3(join7(dir, "package.json"), "utf-8"));
2907
+ const pkg = JSON.parse(readFileSync3(join8(dir, "package.json"), "utf-8"));
2539
2908
  return pkg.version;
2540
2909
  } catch {
2541
2910
  dir = dirname2(dir);
@@ -2557,13 +2926,45 @@ var LANG_DISPLAY = {
2557
2926
  "c-sharp": "C#"
2558
2927
  };
2559
2928
  var languageList = LANGUAGE_IDS.map((id) => LANG_DISPLAY[id] ?? id.charAt(0).toUpperCase() + id.slice(1)).join(", ");
2929
+ var MCP_DEFAULT_EXCLUDES = [
2930
+ "__fixtures__",
2931
+ "__tests__",
2932
+ "__mocks__",
2933
+ "\\.test\\.",
2934
+ "\\.spec\\.",
2935
+ "\\.e2e\\."
2936
+ ];
2937
+ var memoryCache = null;
2938
+ function cacheKey(opts) {
2939
+ return JSON.stringify([opts.targetDir, opts.projectRoot, opts.language ?? "", opts.exclude ?? []]);
2940
+ }
2560
2941
  async function resolveGraphMcp(opts) {
2561
- return resolveGraph({
2562
- targetDir: opts.targetDir,
2563
- projectRoot: opts.projectRoot,
2564
- exclude: opts.exclude,
2565
- language: opts.language
2942
+ const effectiveOpts = {
2943
+ ...opts,
2944
+ exclude: opts.exclude ?? MCP_DEFAULT_EXCLUDES
2945
+ };
2946
+ const key = cacheKey(effectiveOpts);
2947
+ if (memoryCache && memoryCache.key === key) {
2948
+ const result2 = await resolveGraph({
2949
+ targetDir: effectiveOpts.targetDir,
2950
+ projectRoot: effectiveOpts.projectRoot,
2951
+ exclude: effectiveOpts.exclude,
2952
+ language: effectiveOpts.language
2953
+ });
2954
+ if (result2.fromCache) {
2955
+ return memoryCache.result;
2956
+ }
2957
+ memoryCache = { key, result: result2, timestamp: Date.now() };
2958
+ return result2;
2959
+ }
2960
+ const result = await resolveGraph({
2961
+ targetDir: effectiveOpts.targetDir,
2962
+ projectRoot: effectiveOpts.projectRoot,
2963
+ exclude: effectiveOpts.exclude,
2964
+ language: effectiveOpts.language
2566
2965
  });
2966
+ memoryCache = { key, result, timestamp: Date.now() };
2967
+ return result;
2567
2968
  }
2568
2969
  function formatLayerSummary(metadata) {
2569
2970
  return metadata.map(
@@ -2581,11 +2982,11 @@ server.tool(
2581
2982
  },
2582
2983
  async ({ targetDir, projectRoot, exclude, language }) => {
2583
2984
  try {
2584
- validatePath(targetDir);
2585
- validatePath(projectRoot);
2985
+ const root = resolveProjectRoot(projectRoot);
2986
+ validatePath(targetDir, root);
2586
2987
  const { graph, layerMetadata, crossLayerEdges } = await resolveGraphMcp({
2587
2988
  targetDir,
2588
- projectRoot,
2989
+ projectRoot: root,
2589
2990
  exclude,
2590
2991
  language
2591
2992
  });
@@ -2622,11 +3023,11 @@ server.tool(
2622
3023
  },
2623
3024
  async ({ targetDir, exclude, topN, saveSnapshot: doSave, projectRoot, language }) => {
2624
3025
  try {
2625
- validatePath(targetDir);
2626
- validatePath(projectRoot);
3026
+ const root = resolveProjectRoot(projectRoot);
3027
+ validatePath(targetDir, root);
2627
3028
  const { graph, multiLayer, layerMetadata, crossLayerEdges } = await resolveGraphMcp({
2628
3029
  targetDir,
2629
- projectRoot,
3030
+ projectRoot: root,
2630
3031
  exclude,
2631
3032
  language
2632
3033
  });
@@ -2646,7 +3047,7 @@ Cross-layer connections (${crossLayerEdges.length}):
2646
3047
  ${crossSummary}` });
2647
3048
  }
2648
3049
  if (doSave) {
2649
- await saveSnapshot(projectRoot, graph, multiLayer);
3050
+ await saveSnapshot(root, graph, multiLayer, { targetDir, language, exclude });
2650
3051
  content.push({ type: "text", text: t("analyze.snapshotSaved") });
2651
3052
  }
2652
3053
  return { content };
@@ -2665,14 +3066,14 @@ server.tool(
2665
3066
  },
2666
3067
  async ({ targetDir, projectRoot, language }) => {
2667
3068
  try {
2668
- validatePath(targetDir);
2669
- validatePath(projectRoot);
3069
+ const root = resolveProjectRoot(projectRoot);
3070
+ validatePath(targetDir, root);
2670
3071
  const { graph, multiLayer, layerMetadata } = await resolveGraphMcp({
2671
3072
  targetDir,
2672
- projectRoot,
3073
+ projectRoot: root,
2673
3074
  language
2674
3075
  });
2675
- const snapshot = await saveSnapshot(projectRoot, graph, multiLayer);
3076
+ const snapshot = await saveSnapshot(root, graph, multiLayer, { targetDir, language });
2676
3077
  const keyComponents = Object.values(graph.files).sort((a, b) => b.dependents.length - a.dependents.length).slice(0, 5).map((f) => ` ${t("cli.dependedBy", { path: f.path, count: f.dependents.length })}`);
2677
3078
  const report = [
2678
3079
  t("mcp.snapshotSaved"),
@@ -2692,24 +3093,58 @@ server.tool(
2692
3093
  );
2693
3094
  server.tool(
2694
3095
  "check_architecture_diff",
2695
- "Compare saved snapshot with current code dependencies and warn about files that may need updates",
3096
+ "Compare saved snapshot with current code dependencies and warn about files that may need updates. Use fromTimestamp to diff against a historical snapshot, or listHistory=true to list all snapshots.",
2696
3097
  {
2697
3098
  targetDir: z3.string().default("src").describe("Target directory path"),
2698
3099
  projectRoot: z3.string().default(".").describe("Project root (where .archtracker is placed)"),
2699
- language: languageEnum.optional().describe("Target language (auto-detected if omitted)")
3100
+ language: languageEnum.optional().describe("Target language (auto-detected if omitted)"),
3101
+ fromTimestamp: z3.string().optional().describe("Compare from a specific historical snapshot timestamp"),
3102
+ listHistory: z3.boolean().optional().describe("If true, list all available snapshots instead of diffing")
2700
3103
  },
2701
- async ({ targetDir, projectRoot, language }) => {
3104
+ async ({ targetDir, projectRoot, language, fromTimestamp, listHistory }) => {
2702
3105
  try {
2703
- validatePath(targetDir);
2704
- validatePath(projectRoot);
2705
- const existingSnapshot = await loadSnapshot(projectRoot);
3106
+ const root = resolveProjectRoot(projectRoot);
3107
+ validatePath(targetDir, root);
3108
+ if (listHistory) {
3109
+ const snapshots = await listSnapshots(root);
3110
+ if (snapshots.length === 0) {
3111
+ return { content: [{ type: "text", text: t("history.empty") }] };
3112
+ }
3113
+ const lines = [
3114
+ t("history.count", { count: snapshots.length }),
3115
+ "",
3116
+ ...snapshots.map((s) => {
3117
+ const layers = s.hasMultiLayer ? " [multi-layer]" : "";
3118
+ return t("history.entry", {
3119
+ ts: s.timestamp,
3120
+ files: s.totalFiles,
3121
+ edges: s.totalEdges,
3122
+ circular: s.circularDeps,
3123
+ layers
3124
+ });
3125
+ })
3126
+ ];
3127
+ return { content: [{ type: "text", text: lines.join("\n") }] };
3128
+ }
3129
+ let existingSnapshot;
3130
+ if (fromTimestamp) {
3131
+ existingSnapshot = await loadSnapshotByTimestamp(root, fromTimestamp);
3132
+ if (!existingSnapshot) {
3133
+ return {
3134
+ content: [{ type: "text", text: t("history.snapshotNotFound", { ts: fromTimestamp }) }],
3135
+ isError: true
3136
+ };
3137
+ }
3138
+ } else {
3139
+ existingSnapshot = await loadSnapshot(root);
3140
+ }
2706
3141
  if (!existingSnapshot) {
2707
3142
  const { graph, multiLayer } = await resolveGraphMcp({
2708
3143
  targetDir,
2709
- projectRoot,
3144
+ projectRoot: root,
2710
3145
  language
2711
3146
  });
2712
- await saveSnapshot(projectRoot, graph, multiLayer);
3147
+ await saveSnapshot(root, graph, multiLayer, { targetDir, language });
2713
3148
  return {
2714
3149
  content: [
2715
3150
  {
@@ -2725,7 +3160,7 @@ server.tool(
2725
3160
  }
2726
3161
  const { graph: currentGraph } = await resolveGraphMcp({
2727
3162
  targetDir,
2728
- projectRoot,
3163
+ projectRoot: root,
2729
3164
  language
2730
3165
  });
2731
3166
  const diff = computeDiff(existingSnapshot.graph, currentGraph);
@@ -2746,18 +3181,14 @@ server.tool(
2746
3181
  },
2747
3182
  async ({ targetDir, projectRoot, language }) => {
2748
3183
  try {
2749
- validatePath(targetDir);
2750
- validatePath(projectRoot);
2751
- let snapshot = await loadSnapshot(projectRoot);
2752
- if (!snapshot) {
2753
- const { graph: graph2, multiLayer } = await resolveGraphMcp({
2754
- targetDir,
2755
- projectRoot,
2756
- language
2757
- });
2758
- snapshot = await saveSnapshot(projectRoot, graph2, multiLayer);
2759
- }
2760
- const graph = snapshot.graph;
3184
+ const root = resolveProjectRoot(projectRoot);
3185
+ validatePath(targetDir, root);
3186
+ const { graph } = await resolveGraphMcp({
3187
+ targetDir,
3188
+ projectRoot: root,
3189
+ language
3190
+ });
3191
+ const existingSnapshot = await loadSnapshot(root);
2761
3192
  const keyComponents = Object.values(graph.files).filter((f) => f.dependents.length > 0 || f.dependencies.length > 0).sort((a, b) => b.dependents.length - a.dependents.length).slice(0, 20).map((f) => ({
2762
3193
  path: f.path,
2763
3194
  dependentCount: f.dependents.length,
@@ -2769,7 +3200,7 @@ server.tool(
2769
3200
  t("cli.fileCount", { count: graph.totalFiles }),
2770
3201
  t("cli.edgeCount", { count: graph.totalEdges }),
2771
3202
  t("cli.circularCount", { count: graph.circularDependencies.length }),
2772
- t("cli.snapshot", { ts: snapshot.timestamp }),
3203
+ ...existingSnapshot ? [t("cli.snapshot", { ts: existingSnapshot.timestamp })] : [],
2773
3204
  "",
2774
3205
  t("cli.keyComponents"),
2775
3206
  ...keyComponents.map(
@@ -2779,8 +3210,8 @@ server.tool(
2779
3210
  const context = {
2780
3211
  validPaths,
2781
3212
  summary,
2782
- snapshotExists: true,
2783
- snapshotTimestamp: snapshot.timestamp,
3213
+ snapshotExists: existingSnapshot !== null,
3214
+ snapshotTimestamp: existingSnapshot?.timestamp,
2784
3215
  keyComponents
2785
3216
  };
2786
3217
  return {
@@ -2812,18 +3243,13 @@ server.tool(
2812
3243
  },
2813
3244
  async ({ query, mode, targetDir, projectRoot, limit, language }) => {
2814
3245
  try {
2815
- validatePath(targetDir);
2816
- validatePath(projectRoot);
2817
- let snapshot = await loadSnapshot(projectRoot);
2818
- if (!snapshot) {
2819
- const { graph: graph2, multiLayer } = await resolveGraphMcp({
2820
- targetDir,
2821
- projectRoot,
2822
- language
2823
- });
2824
- snapshot = await saveSnapshot(projectRoot, graph2, multiLayer);
2825
- }
2826
- const graph = snapshot.graph;
3246
+ const root = resolveProjectRoot(projectRoot);
3247
+ validatePath(targetDir, root);
3248
+ const { graph } = await resolveGraphMcp({
3249
+ targetDir,
3250
+ projectRoot: root,
3251
+ language
3252
+ });
2827
3253
  const maxResults = limit ?? 10;
2828
3254
  let results;
2829
3255
  if ((mode === "path" || mode === "affected") && !query) {