archtracker-mcp 0.5.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";
@@ -2226,62 +2242,416 @@ function mergeLayerGraphs(projectRoot, layers) {
2226
2242
  };
2227
2243
  }
2228
2244
 
2229
- // src/storage/snapshot.ts
2230
- import { mkdir, writeFile, readFile as readFile2, access } from "fs/promises";
2245
+ // src/storage/layers.ts
2246
+ import { readFile as readFile2, writeFile, mkdir } from "fs/promises";
2231
2247
  import { join as join5 } from "path";
2232
2248
  import { z } from "zod";
2249
+ var ARCHTRACKER_DIR = ".archtracker";
2250
+ var LAYERS_FILE = "layers.json";
2251
+ var LayerDefinitionSchema = z.object({
2252
+ name: z.string().min(1).regex(
2253
+ /^[a-zA-Z0-9_-]+$/,
2254
+ "Layer name must be alphanumeric (hyphens/underscores allowed)"
2255
+ ),
2256
+ targetDir: z.string().min(1),
2257
+ language: z.enum(LANGUAGE_IDS).optional(),
2258
+ exclude: z.array(z.string()).optional(),
2259
+ color: z.string().optional(),
2260
+ description: z.string().optional()
2261
+ });
2262
+ var CrossLayerConnectionSchema = z.object({
2263
+ fromLayer: z.string(),
2264
+ fromFile: z.string(),
2265
+ toLayer: z.string(),
2266
+ toFile: z.string(),
2267
+ type: z.enum(["api-call", "event", "data-flow", "manual"]),
2268
+ label: z.string().optional()
2269
+ });
2270
+ var LayerConfigSchema = z.object({
2271
+ version: z.literal("1.0"),
2272
+ layers: z.array(LayerDefinitionSchema).min(1).refine(
2273
+ (layers) => {
2274
+ const names = layers.map((l) => l.name);
2275
+ return new Set(names).size === names.length;
2276
+ },
2277
+ { message: "Layer names must be unique" }
2278
+ ),
2279
+ connections: z.array(CrossLayerConnectionSchema).optional()
2280
+ });
2281
+ async function loadLayerConfig(projectRoot) {
2282
+ const filePath = join5(projectRoot, ARCHTRACKER_DIR, LAYERS_FILE);
2283
+ let raw;
2284
+ try {
2285
+ raw = await readFile2(filePath, "utf-8");
2286
+ } catch (error) {
2287
+ if (isNodeError(error) && error.code === "ENOENT") {
2288
+ return null;
2289
+ }
2290
+ throw new Error(`Failed to read ${filePath}`);
2291
+ }
2292
+ let parsed;
2293
+ try {
2294
+ parsed = JSON.parse(raw);
2295
+ } catch {
2296
+ throw new Error(`Invalid JSON in ${filePath}`);
2297
+ }
2298
+ const result = LayerConfigSchema.safeParse(parsed);
2299
+ if (!result.success) {
2300
+ const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).slice(0, 5).join("\n");
2301
+ throw new Error(`layers.json validation failed:
2302
+ ${issues}`);
2303
+ }
2304
+ return result.data;
2305
+ }
2306
+ function isNodeError(error) {
2307
+ return error instanceof Error && "code" in error;
2308
+ }
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
+
2499
+ // src/analyzer/resolve.ts
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
+ }
2540
+ const layerConfig = await loadLayerConfig(opts.projectRoot);
2541
+ let result;
2542
+ if (layerConfig) {
2543
+ const multi = await analyzeMultiLayer(opts.projectRoot, layerConfig.layers, opts.exclude);
2544
+ const autoConnections = detectCrossLayerConnections(multi.layers, layerConfig.layers);
2545
+ const manualConnections = layerConfig.connections ?? [];
2546
+ const manualKeys = new Set(manualConnections.map(
2547
+ (c) => `${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`
2548
+ ));
2549
+ const merged = [
2550
+ ...manualConnections,
2551
+ ...autoConnections.filter(
2552
+ (c) => !manualKeys.has(`${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`)
2553
+ )
2554
+ ];
2555
+ result = {
2556
+ graph: multi.merged,
2557
+ multiLayer: multi,
2558
+ layerMetadata: multi.layerMetadata,
2559
+ crossLayerEdges: merged
2560
+ };
2561
+ } else {
2562
+ const graph = await analyzeProject(opts.targetDir, {
2563
+ exclude: opts.exclude,
2564
+ language: opts.language
2565
+ });
2566
+ result = { graph };
2567
+ }
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;
2587
+ }
2588
+
2589
+ // src/storage/snapshot.ts
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";
2592
+ import { z as z2 } from "zod";
2233
2593
 
2234
2594
  // src/types/schema.ts
2235
2595
  var SCHEMA_VERSION = "1.1";
2236
2596
 
2237
2597
  // src/storage/snapshot.ts
2238
- var ARCHTRACKER_DIR = ".archtracker";
2598
+ var ARCHTRACKER_DIR3 = ".archtracker";
2239
2599
  var SNAPSHOT_FILE = "snapshot.json";
2240
- var FileNodeSchema = z.object({
2241
- path: z.string(),
2242
- exists: z.boolean(),
2243
- dependencies: z.array(z.string()),
2244
- dependents: z.array(z.string())
2600
+ var HISTORY_DIR = "history";
2601
+ var FileNodeSchema = z2.object({
2602
+ path: z2.string(),
2603
+ exists: z2.boolean(),
2604
+ dependencies: z2.array(z2.string()),
2605
+ dependents: z2.array(z2.string())
2245
2606
  });
2246
- var DependencyGraphSchema = z.object({
2247
- rootDir: z.string(),
2248
- files: z.record(z.string(), FileNodeSchema),
2249
- edges: z.array(z.object({
2250
- source: z.string(),
2251
- target: z.string(),
2252
- type: z.enum(["static", "dynamic", "type-only"])
2607
+ var DependencyGraphSchema = z2.object({
2608
+ rootDir: z2.string(),
2609
+ files: z2.record(z2.string(), FileNodeSchema),
2610
+ edges: z2.array(z2.object({
2611
+ source: z2.string(),
2612
+ target: z2.string(),
2613
+ type: z2.enum(["static", "dynamic", "type-only"])
2253
2614
  })),
2254
- circularDependencies: z.array(z.object({ cycle: z.array(z.string()) })),
2255
- totalFiles: z.number(),
2256
- totalEdges: z.number()
2615
+ circularDependencies: z2.array(z2.object({ cycle: z2.array(z2.string()) })),
2616
+ totalFiles: z2.number(),
2617
+ totalEdges: z2.number()
2257
2618
  });
2258
- var SnapshotSchema = z.object({
2259
- version: z.enum([SCHEMA_VERSION, "1.0"]),
2260
- timestamp: z.string(),
2261
- rootDir: z.string(),
2619
+ var SnapshotSchema = z2.object({
2620
+ version: z2.enum([SCHEMA_VERSION, "1.0"]),
2621
+ timestamp: z2.string(),
2622
+ rootDir: z2.string(),
2262
2623
  graph: DependencyGraphSchema
2263
2624
  });
2264
- async function saveSnapshot(projectRoot, graph, multiLayer) {
2265
- const dirPath = join5(projectRoot, ARCHTRACKER_DIR);
2266
- const filePath = join5(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);
2267
2628
  const snapshot = {
2268
2629
  version: SCHEMA_VERSION,
2269
2630
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2270
2631
  rootDir: graph.rootDir,
2271
2632
  graph,
2272
- ...multiLayer ? { multiLayer } : {}
2633
+ ...multiLayer ? { multiLayer } : {},
2634
+ ...analysisOptions ? { analysisOptions } : {}
2273
2635
  };
2274
- await mkdir(dirPath, { recursive: true });
2275
- await writeFile(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
+ }
2276
2646
  return snapshot;
2277
2647
  }
2278
2648
  async function loadSnapshot(projectRoot) {
2279
- const filePath = join5(projectRoot, ARCHTRACKER_DIR, SNAPSHOT_FILE);
2649
+ const filePath = join7(projectRoot, ARCHTRACKER_DIR3, SNAPSHOT_FILE);
2280
2650
  let raw;
2281
2651
  try {
2282
- raw = await readFile2(filePath, "utf-8");
2652
+ raw = await readFile4(filePath, "utf-8");
2283
2653
  } catch (error) {
2284
- if (isNodeError(error) && error.code === "ENOENT") {
2654
+ if (isNodeError2(error) && error.code === "ENOENT") {
2285
2655
  return null;
2286
2656
  }
2287
2657
  throw new StorageError(
@@ -2306,13 +2676,74 @@ async function loadSnapshot(projectRoot) {
2306
2676
  }
2307
2677
  return result.data;
2308
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
+ }
2309
2740
  var StorageError = class extends Error {
2310
2741
  constructor(message, options) {
2311
2742
  super(message, options);
2312
2743
  this.name = "StorageError";
2313
2744
  }
2314
2745
  };
2315
- function isNodeError(error) {
2746
+ function isNodeError2(error) {
2316
2747
  return error instanceof Error && "code" in error;
2317
2748
  }
2318
2749
 
@@ -2367,6 +2798,17 @@ function computeDiff(oldGraph, newGraph) {
2367
2798
  }
2368
2799
  return { added, removed, modified, affectedDependents };
2369
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
+ }
2370
2812
  function formatDiffReport(diff) {
2371
2813
  const lines = [];
2372
2814
  lines.push(t("diff.title"));
@@ -2375,32 +2817,51 @@ function formatDiffReport(diff) {
2375
2817
  return lines.join("\n");
2376
2818
  }
2377
2819
  if (diff.added.length > 0) {
2820
+ const [testFiles, srcFiles] = partition(diff.added, isTestOrFixture);
2378
2821
  lines.push(t("diff.added", { count: diff.added.length }));
2379
- for (const f of diff.added) {
2822
+ for (const f of srcFiles) {
2380
2823
  lines.push(` + ${f}`);
2381
2824
  }
2825
+ if (testFiles.length > 0) {
2826
+ lines.push(t("diff.testSummary", { count: testFiles.length }));
2827
+ }
2382
2828
  lines.push("");
2383
2829
  }
2384
2830
  if (diff.removed.length > 0) {
2831
+ const [testFiles, srcFiles] = partition(diff.removed, isTestOrFixture);
2385
2832
  lines.push(t("diff.removed", { count: diff.removed.length }));
2386
- for (const f of diff.removed) {
2833
+ for (const f of srcFiles) {
2387
2834
  lines.push(` - ${f}`);
2388
2835
  }
2836
+ if (testFiles.length > 0) {
2837
+ lines.push(t("diff.testSummary", { count: testFiles.length }));
2838
+ }
2389
2839
  lines.push("");
2390
2840
  }
2391
2841
  if (diff.modified.length > 0) {
2842
+ const [testFiles, srcFiles] = partition(diff.modified, isTestOrFixture);
2392
2843
  lines.push(t("diff.modified", { count: diff.modified.length }));
2393
- for (const f of diff.modified) {
2844
+ for (const f of srcFiles) {
2394
2845
  lines.push(` ~ ${f}`);
2395
2846
  }
2847
+ if (testFiles.length > 0) {
2848
+ lines.push(t("diff.testSummary", { count: testFiles.length }));
2849
+ }
2396
2850
  lines.push("");
2397
2851
  }
2398
2852
  if (diff.affectedDependents.length > 0) {
2853
+ const [testEntries, srcEntries] = partition(
2854
+ diff.affectedDependents,
2855
+ (a) => isTestOrFixture(a.file) || isTestOrFixture(a.dependsOn)
2856
+ );
2399
2857
  lines.push(t("diff.affected", { count: diff.affectedDependents.length }));
2400
- for (const a of diff.affectedDependents) {
2858
+ for (const a of srcEntries) {
2401
2859
  lines.push(` ! ${a.file}`);
2402
2860
  lines.push(` ${a.reason}`);
2403
2861
  }
2862
+ if (testEntries.length > 0) {
2863
+ lines.push(t("diff.testAffectedSummary", { count: testEntries.length }));
2864
+ }
2404
2865
  lines.push("");
2405
2866
  }
2406
2867
  return lines.join("\n");
@@ -2413,76 +2874,11 @@ function arraysEqual(a, b) {
2413
2874
  return true;
2414
2875
  }
2415
2876
 
2416
- // src/storage/layers.ts
2417
- import { readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
2418
- import { join as join6 } from "path";
2419
- import { z as z2 } from "zod";
2420
- var ARCHTRACKER_DIR2 = ".archtracker";
2421
- var LAYERS_FILE = "layers.json";
2422
- var LayerDefinitionSchema = z2.object({
2423
- name: z2.string().min(1).regex(
2424
- /^[a-zA-Z0-9_-]+$/,
2425
- "Layer name must be alphanumeric (hyphens/underscores allowed)"
2426
- ),
2427
- targetDir: z2.string().min(1),
2428
- language: z2.enum(LANGUAGE_IDS).optional(),
2429
- exclude: z2.array(z2.string()).optional(),
2430
- color: z2.string().optional(),
2431
- description: z2.string().optional()
2432
- });
2433
- var CrossLayerConnectionSchema = z2.object({
2434
- fromLayer: z2.string(),
2435
- fromFile: z2.string(),
2436
- toLayer: z2.string(),
2437
- toFile: z2.string(),
2438
- type: z2.enum(["api-call", "event", "data-flow", "manual"]),
2439
- label: z2.string().optional()
2440
- });
2441
- var LayerConfigSchema = z2.object({
2442
- version: z2.literal("1.0"),
2443
- layers: z2.array(LayerDefinitionSchema).min(1).refine(
2444
- (layers) => {
2445
- const names = layers.map((l) => l.name);
2446
- return new Set(names).size === names.length;
2447
- },
2448
- { message: "Layer names must be unique" }
2449
- ),
2450
- connections: z2.array(CrossLayerConnectionSchema).optional()
2451
- });
2452
- async function loadLayerConfig(projectRoot) {
2453
- const filePath = join6(projectRoot, ARCHTRACKER_DIR2, LAYERS_FILE);
2454
- let raw;
2455
- try {
2456
- raw = await readFile3(filePath, "utf-8");
2457
- } catch (error) {
2458
- if (isNodeError2(error) && error.code === "ENOENT") {
2459
- return null;
2460
- }
2461
- throw new Error(`Failed to read ${filePath}`);
2462
- }
2463
- let parsed;
2464
- try {
2465
- parsed = JSON.parse(raw);
2466
- } catch {
2467
- throw new Error(`Invalid JSON in ${filePath}`);
2468
- }
2469
- const result = LayerConfigSchema.safeParse(parsed);
2470
- if (!result.success) {
2471
- const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).slice(0, 5).join("\n");
2472
- throw new Error(`layers.json validation failed:
2473
- ${issues}`);
2474
- }
2475
- return result.data;
2476
- }
2477
- function isNodeError2(error) {
2478
- return error instanceof Error && "code" in error;
2479
- }
2480
-
2481
2877
  // src/utils/path-guard.ts
2482
- import { resolve as resolve5 } from "path";
2878
+ import { resolve as resolve6 } from "path";
2483
2879
  function validatePath(inputPath, boundary) {
2484
- const resolved = resolve5(inputPath);
2485
- const root = boundary ? resolve5(boundary) : process.cwd();
2880
+ const root = boundary ? resolve6(boundary) : process.cwd();
2881
+ const resolved = boundary ? resolve6(root, inputPath) : resolve6(inputPath);
2486
2882
  if (!resolved.startsWith(root)) {
2487
2883
  throw new PathTraversalError(
2488
2884
  t("pathGuard.traversal", { input: inputPath, resolved, boundary: root })
@@ -2490,6 +2886,9 @@ function validatePath(inputPath, boundary) {
2490
2886
  }
2491
2887
  return resolved;
2492
2888
  }
2889
+ function resolveProjectRoot(inputPath) {
2890
+ return resolve6(inputPath);
2891
+ }
2493
2892
  var PathTraversalError = class extends Error {
2494
2893
  constructor(message) {
2495
2894
  super(message);
@@ -2499,13 +2898,13 @@ var PathTraversalError = class extends Error {
2499
2898
 
2500
2899
  // src/utils/version.ts
2501
2900
  import { readFileSync as readFileSync3 } from "fs";
2502
- import { join as join7, dirname as dirname2 } from "path";
2901
+ import { join as join8, dirname as dirname2 } from "path";
2503
2902
  import { fileURLToPath } from "url";
2504
2903
  function loadVersion() {
2505
2904
  let dir = dirname2(fileURLToPath(import.meta.url));
2506
2905
  for (let i = 0; i < 5; i++) {
2507
2906
  try {
2508
- const pkg = JSON.parse(readFileSync3(join7(dir, "package.json"), "utf-8"));
2907
+ const pkg = JSON.parse(readFileSync3(join8(dir, "package.json"), "utf-8"));
2509
2908
  return pkg.version;
2510
2909
  } catch {
2511
2910
  dir = dirname2(dir);
@@ -2527,30 +2926,45 @@ var LANG_DISPLAY = {
2527
2926
  "c-sharp": "C#"
2528
2927
  };
2529
2928
  var languageList = LANGUAGE_IDS.map((id) => LANG_DISPLAY[id] ?? id.charAt(0).toUpperCase() + id.slice(1)).join(", ");
2530
- async function resolveGraphForMcp(opts) {
2531
- if (opts.targetDir === "src") {
2532
- const layerConfig = await loadLayerConfig(opts.projectRoot);
2533
- if (layerConfig) {
2534
- const multi = await analyzeMultiLayer(opts.projectRoot, layerConfig.layers);
2535
- const autoConnections = detectCrossLayerConnections(multi.layers, layerConfig.layers);
2536
- const manualConnections = layerConfig.connections ?? [];
2537
- const manualKeys = new Set(manualConnections.map(
2538
- (c) => `${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`
2539
- ));
2540
- const allConnections = [
2541
- ...manualConnections,
2542
- ...autoConnections.filter(
2543
- (c) => !manualKeys.has(`${c.fromLayer}/${c.fromFile}\u2192${c.toLayer}/${c.toFile}`)
2544
- )
2545
- ];
2546
- return { graph: multi.merged, multiLayer: multi, layerMetadata: multi.layerMetadata, crossEdges: allConnections };
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
+ }
2941
+ async function resolveGraphMcp(opts) {
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;
2547
2956
  }
2957
+ memoryCache = { key, result: result2, timestamp: Date.now() };
2958
+ return result2;
2548
2959
  }
2549
- const graph = await analyzeProject(opts.targetDir, {
2550
- exclude: opts.exclude,
2551
- language: opts.language
2960
+ const result = await resolveGraph({
2961
+ targetDir: effectiveOpts.targetDir,
2962
+ projectRoot: effectiveOpts.projectRoot,
2963
+ exclude: effectiveOpts.exclude,
2964
+ language: effectiveOpts.language
2552
2965
  });
2553
- return { graph };
2966
+ memoryCache = { key, result, timestamp: Date.now() };
2967
+ return result;
2554
2968
  }
2555
2969
  function formatLayerSummary(metadata) {
2556
2970
  return metadata.map(
@@ -2564,16 +2978,15 @@ server.tool(
2564
2978
  targetDir: z3.string().default("src").describe("Target directory path (default: src). When layers.json exists and this is 'src', multi-layer analysis is used automatically."),
2565
2979
  projectRoot: z3.string().default(".").describe("Project root (where .archtracker/ is located)"),
2566
2980
  exclude: z3.array(z3.string()).optional().describe("Array of regex patterns to exclude (e.g. ['test', 'mock'])"),
2567
- maxDepth: z3.number().int().min(0).optional().describe("Max analysis depth (0 = unlimited)"),
2568
2981
  language: languageEnum.optional().describe("Target language (auto-detected if omitted)")
2569
2982
  },
2570
- async ({ targetDir, projectRoot, exclude, maxDepth, language }) => {
2983
+ async ({ targetDir, projectRoot, exclude, language }) => {
2571
2984
  try {
2572
- validatePath(targetDir);
2573
- validatePath(projectRoot);
2574
- const { graph, layerMetadata, crossEdges } = await resolveGraphForMcp({
2985
+ const root = resolveProjectRoot(projectRoot);
2986
+ validatePath(targetDir, root);
2987
+ const { graph, layerMetadata, crossLayerEdges } = await resolveGraphMcp({
2575
2988
  targetDir,
2576
- projectRoot,
2989
+ projectRoot: root,
2577
2990
  exclude,
2578
2991
  language
2579
2992
  });
@@ -2581,11 +2994,11 @@ server.tool(
2581
2994
  t("mcp.analyzeComplete", { files: graph.totalFiles, edges: graph.totalEdges }),
2582
2995
  graph.circularDependencies.length > 0 ? t("mcp.circularFound", { count: graph.circularDependencies.length }) : t("mcp.circularNone"),
2583
2996
  ...layerMetadata ? ["\nLayers:\n" + formatLayerSummary(layerMetadata)] : [],
2584
- ...crossEdges?.length ? [`
2585
- Cross-layer connections: ${crossEdges.length}`] : []
2997
+ ...crossLayerEdges?.length ? [`
2998
+ Cross-layer connections: ${crossLayerEdges.length}`] : []
2586
2999
  ].join("\n");
2587
3000
  const result = { ...graph };
2588
- if (crossEdges?.length) result.crossLayerConnections = crossEdges;
3001
+ if (crossLayerEdges?.length) result.crossLayerConnections = crossLayerEdges;
2589
3002
  return {
2590
3003
  content: [
2591
3004
  { type: "text", text: summary },
@@ -2599,21 +3012,22 @@ Cross-layer connections: ${crossEdges.length}`] : []
2599
3012
  );
2600
3013
  server.tool(
2601
3014
  "analyze_existing_architecture",
2602
- `Comprehensive architecture analysis for existing projects. Shows critical components, circular dependencies, orphan files, coupling hotspots, and directory breakdown. Supports ${LANGUAGE_IDS.length} languages.`,
3015
+ `Comprehensive architecture analysis for existing projects. Shows critical components, circular dependencies, orphan files, coupling hotspots, and directory breakdown. Supports ${languageList}.`,
2603
3016
  {
2604
3017
  targetDir: z3.string().default("src").describe("Target directory path (default: src)"),
2605
3018
  exclude: z3.array(z3.string()).optional().describe("Array of regex patterns to exclude"),
2606
3019
  topN: z3.number().int().min(1).max(50).optional().describe("Number of top items to show in each section (default: 10)"),
2607
3020
  saveSnapshot: z3.boolean().optional().describe("Also save a snapshot after analysis (default: false)"),
2608
- projectRoot: z3.string().default(".").describe("Project root (needed only when saveSnapshot is true)"),
3021
+ projectRoot: z3.string().default(".").describe("Project root (where .archtracker/ is located)"),
2609
3022
  language: languageEnum.optional().describe("Target language (auto-detected if omitted)")
2610
3023
  },
2611
3024
  async ({ targetDir, exclude, topN, saveSnapshot: doSave, projectRoot, language }) => {
2612
3025
  try {
2613
- validatePath(targetDir);
2614
- const { graph, multiLayer, layerMetadata, crossEdges } = await resolveGraphForMcp({
3026
+ const root = resolveProjectRoot(projectRoot);
3027
+ validatePath(targetDir, root);
3028
+ const { graph, multiLayer, layerMetadata, crossLayerEdges } = await resolveGraphMcp({
2615
3029
  targetDir,
2616
- projectRoot,
3030
+ projectRoot: root,
2617
3031
  exclude,
2618
3032
  language
2619
3033
  });
@@ -2624,17 +3038,16 @@ server.tool(
2624
3038
  if (layerMetadata) {
2625
3039
  content.push({ type: "text", text: "\nLayers:\n" + formatLayerSummary(layerMetadata) });
2626
3040
  }
2627
- if (crossEdges?.length) {
2628
- const crossSummary = crossEdges.map(
3041
+ if (crossLayerEdges?.length) {
3042
+ const crossSummary = crossLayerEdges.map(
2629
3043
  (c) => ` ${c.fromLayer}/${c.fromFile} \u2192 ${c.toLayer}/${c.toFile} [${c.type}] ${c.label ?? ""}`
2630
3044
  ).join("\n");
2631
3045
  content.push({ type: "text", text: `
2632
- Cross-layer connections (${crossEdges.length}):
3046
+ Cross-layer connections (${crossLayerEdges.length}):
2633
3047
  ${crossSummary}` });
2634
3048
  }
2635
3049
  if (doSave) {
2636
- validatePath(projectRoot);
2637
- await saveSnapshot(projectRoot, graph, multiLayer);
3050
+ await saveSnapshot(root, graph, multiLayer, { targetDir, language, exclude });
2638
3051
  content.push({ type: "text", text: t("analyze.snapshotSaved") });
2639
3052
  }
2640
3053
  return { content };
@@ -2653,14 +3066,14 @@ server.tool(
2653
3066
  },
2654
3067
  async ({ targetDir, projectRoot, language }) => {
2655
3068
  try {
2656
- validatePath(targetDir);
2657
- validatePath(projectRoot);
2658
- const { graph, multiLayer, layerMetadata } = await resolveGraphForMcp({
3069
+ const root = resolveProjectRoot(projectRoot);
3070
+ validatePath(targetDir, root);
3071
+ const { graph, multiLayer, layerMetadata } = await resolveGraphMcp({
2659
3072
  targetDir,
2660
- projectRoot,
3073
+ projectRoot: root,
2661
3074
  language
2662
3075
  });
2663
- const snapshot = await saveSnapshot(projectRoot, graph, multiLayer);
3076
+ const snapshot = await saveSnapshot(root, graph, multiLayer, { targetDir, language });
2664
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 })}`);
2665
3078
  const report = [
2666
3079
  t("mcp.snapshotSaved"),
@@ -2680,24 +3093,58 @@ server.tool(
2680
3093
  );
2681
3094
  server.tool(
2682
3095
  "check_architecture_diff",
2683
- "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.",
2684
3097
  {
2685
3098
  targetDir: z3.string().default("src").describe("Target directory path"),
2686
3099
  projectRoot: z3.string().default(".").describe("Project root (where .archtracker is placed)"),
2687
- 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")
2688
3103
  },
2689
- async ({ targetDir, projectRoot, language }) => {
3104
+ async ({ targetDir, projectRoot, language, fromTimestamp, listHistory }) => {
2690
3105
  try {
2691
- validatePath(targetDir);
2692
- validatePath(projectRoot);
2693
- 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
+ }
2694
3141
  if (!existingSnapshot) {
2695
- const { graph, multiLayer } = await resolveGraphForMcp({
3142
+ const { graph, multiLayer } = await resolveGraphMcp({
2696
3143
  targetDir,
2697
- projectRoot,
3144
+ projectRoot: root,
2698
3145
  language
2699
3146
  });
2700
- await saveSnapshot(projectRoot, graph, multiLayer);
3147
+ await saveSnapshot(root, graph, multiLayer, { targetDir, language });
2701
3148
  return {
2702
3149
  content: [
2703
3150
  {
@@ -2711,9 +3158,9 @@ server.tool(
2711
3158
  ]
2712
3159
  };
2713
3160
  }
2714
- const { graph: currentGraph } = await resolveGraphForMcp({
3161
+ const { graph: currentGraph } = await resolveGraphMcp({
2715
3162
  targetDir,
2716
- projectRoot,
3163
+ projectRoot: root,
2717
3164
  language
2718
3165
  });
2719
3166
  const diff = computeDiff(existingSnapshot.graph, currentGraph);
@@ -2734,16 +3181,14 @@ server.tool(
2734
3181
  },
2735
3182
  async ({ targetDir, projectRoot, language }) => {
2736
3183
  try {
2737
- let snapshot = await loadSnapshot(projectRoot);
2738
- if (!snapshot) {
2739
- const { graph: graph2, multiLayer } = await resolveGraphForMcp({
2740
- targetDir,
2741
- projectRoot,
2742
- language
2743
- });
2744
- snapshot = await saveSnapshot(projectRoot, graph2, multiLayer);
2745
- }
2746
- 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);
2747
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) => ({
2748
3193
  path: f.path,
2749
3194
  dependentCount: f.dependents.length,
@@ -2755,7 +3200,7 @@ server.tool(
2755
3200
  t("cli.fileCount", { count: graph.totalFiles }),
2756
3201
  t("cli.edgeCount", { count: graph.totalEdges }),
2757
3202
  t("cli.circularCount", { count: graph.circularDependencies.length }),
2758
- t("cli.snapshot", { ts: snapshot.timestamp }),
3203
+ ...existingSnapshot ? [t("cli.snapshot", { ts: existingSnapshot.timestamp })] : [],
2759
3204
  "",
2760
3205
  t("cli.keyComponents"),
2761
3206
  ...keyComponents.map(
@@ -2765,8 +3210,8 @@ server.tool(
2765
3210
  const context = {
2766
3211
  validPaths,
2767
3212
  summary,
2768
- snapshotExists: true,
2769
- snapshotTimestamp: snapshot.timestamp,
3213
+ snapshotExists: existingSnapshot !== null,
3214
+ snapshotTimestamp: existingSnapshot?.timestamp,
2770
3215
  keyComponents
2771
3216
  };
2772
3217
  return {
@@ -2798,18 +3243,13 @@ server.tool(
2798
3243
  },
2799
3244
  async ({ query, mode, targetDir, projectRoot, limit, language }) => {
2800
3245
  try {
2801
- validatePath(targetDir);
2802
- validatePath(projectRoot);
2803
- let snapshot = await loadSnapshot(projectRoot);
2804
- if (!snapshot) {
2805
- const { graph: graph2, multiLayer } = await resolveGraphForMcp({
2806
- targetDir,
2807
- projectRoot,
2808
- language
2809
- });
2810
- snapshot = await saveSnapshot(projectRoot, graph2, multiLayer);
2811
- }
2812
- 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
+ });
2813
3253
  const maxResults = limit ?? 10;
2814
3254
  let results;
2815
3255
  if ((mode === "path" || mode === "affected") && !query) {