@thecat69/cache-ctrl 1.0.0 → 1.2.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.
Files changed (45) hide show
  1. package/README.md +289 -78
  2. package/cache_ctrl.ts +107 -25
  3. package/package.json +2 -1
  4. package/skills/cache-ctrl-caller/SKILL.md +53 -114
  5. package/skills/cache-ctrl-external/SKILL.md +29 -89
  6. package/skills/cache-ctrl-local/SKILL.md +82 -164
  7. package/src/analysis/graphBuilder.ts +85 -0
  8. package/src/analysis/pageRank.ts +164 -0
  9. package/src/analysis/symbolExtractor.ts +240 -0
  10. package/src/cache/cacheManager.ts +53 -4
  11. package/src/cache/externalCache.ts +72 -77
  12. package/src/cache/graphCache.ts +12 -0
  13. package/src/cache/localCache.ts +2 -0
  14. package/src/commands/checkFiles.ts +9 -6
  15. package/src/commands/flush.ts +9 -2
  16. package/src/commands/graph.ts +131 -0
  17. package/src/commands/inspect.ts +13 -181
  18. package/src/commands/inspectExternal.ts +79 -0
  19. package/src/commands/inspectLocal.ts +134 -0
  20. package/src/commands/install.ts +6 -0
  21. package/src/commands/invalidate.ts +24 -24
  22. package/src/commands/list.ts +11 -11
  23. package/src/commands/map.ts +87 -0
  24. package/src/commands/prune.ts +20 -8
  25. package/src/commands/search.ts +9 -2
  26. package/src/commands/touch.ts +15 -25
  27. package/src/commands/uninstall.ts +103 -0
  28. package/src/commands/update.ts +65 -0
  29. package/src/commands/version.ts +14 -0
  30. package/src/commands/watch.ts +270 -0
  31. package/src/commands/writeExternal.ts +51 -0
  32. package/src/commands/writeLocal.ts +121 -0
  33. package/src/files/changeDetector.ts +15 -0
  34. package/src/files/gitFiles.ts +15 -0
  35. package/src/files/openCodeInstaller.ts +21 -2
  36. package/src/index.ts +314 -58
  37. package/src/search/keywordSearch.ts +24 -0
  38. package/src/types/cache.ts +38 -26
  39. package/src/types/commands.ts +123 -22
  40. package/src/types/result.ts +26 -9
  41. package/src/utils/errors.ts +14 -0
  42. package/src/utils/traversal.ts +42 -0
  43. package/src/commands/checkFreshness.ts +0 -123
  44. package/src/commands/write.ts +0 -170
  45. package/src/http/freshnessChecker.ts +0 -116
@@ -0,0 +1,87 @@
1
+ import { findRepoRoot, readCache } from "../cache/cacheManager.js";
2
+ import { resolveLocalCachePath } from "../cache/localCache.js";
3
+ import { LocalCacheFileSchema } from "../types/cache.js";
4
+ import type { MapArgs, MapDepth, MapResult } from "../types/commands.js";
5
+ import { ErrorCode, type Result } from "../types/result.js";
6
+ import { toUnknownResult } from "../utils/errors.js";
7
+
8
+ /**
9
+ * Returns a semantic map view of local context cache content.
10
+ *
11
+ * @param args - {@link MapArgs} command arguments.
12
+ * @returns Promise<Result<MapResult["value"]>>; common failures include FILE_NOT_FOUND,
13
+ * PARSE_ERROR, FILE_READ_ERROR, and UNKNOWN.
14
+ */
15
+ export async function mapCommand(args: MapArgs): Promise<Result<MapResult["value"]>> {
16
+ try {
17
+ const depth: MapDepth = args.depth ?? "overview";
18
+ const repoRoot = await findRepoRoot(process.cwd());
19
+ const contextPath = resolveLocalCachePath(repoRoot);
20
+
21
+ const readResult = await readCache(contextPath);
22
+ if (!readResult.ok) {
23
+ if (readResult.code === ErrorCode.FILE_NOT_FOUND) {
24
+ return {
25
+ ok: false,
26
+ error: "context.json not found — run local-context-gatherer to populate the cache",
27
+ code: ErrorCode.FILE_NOT_FOUND,
28
+ };
29
+ }
30
+ return readResult;
31
+ }
32
+
33
+ const parseResult = LocalCacheFileSchema.safeParse(readResult.value);
34
+ if (!parseResult.success) {
35
+ return {
36
+ ok: false,
37
+ error: `Malformed local cache file: ${contextPath}`,
38
+ code: ErrorCode.PARSE_ERROR,
39
+ };
40
+ }
41
+
42
+ const parsed = parseResult.data;
43
+ const allFacts = parsed.facts ?? {};
44
+ const folderPrefix = args.folder;
45
+ const filteredFacts =
46
+ folderPrefix !== undefined
47
+ ? Object.fromEntries(
48
+ Object.entries(allFacts).filter(
49
+ ([filePath]) => filePath === folderPrefix || filePath.startsWith(`${folderPrefix}/`),
50
+ ),
51
+ )
52
+ : allFacts;
53
+
54
+ const files = Object.entries(filteredFacts)
55
+ .map(([path, fileFacts]) => ({
56
+ path,
57
+ ...(fileFacts.summary !== undefined ? { summary: fileFacts.summary } : {}),
58
+ ...(fileFacts.role !== undefined ? { role: fileFacts.role } : {}),
59
+ ...(fileFacts.importance !== undefined ? { importance: fileFacts.importance } : {}),
60
+ ...(depth === "full" && fileFacts.facts !== undefined ? { facts: fileFacts.facts } : {}),
61
+ }))
62
+ .sort((a, b) => {
63
+ const aImportance = a.importance ?? Number.POSITIVE_INFINITY;
64
+ const bImportance = b.importance ?? Number.POSITIVE_INFINITY;
65
+ if (aImportance !== bImportance) {
66
+ return aImportance - bImportance;
67
+ }
68
+ return a.path.localeCompare(b.path);
69
+ });
70
+
71
+ return {
72
+ ok: true,
73
+ value: {
74
+ depth,
75
+ global_facts: parsed.global_facts ?? [],
76
+ files,
77
+ ...((depth === "modules" || depth === "full") && parsed.modules !== undefined
78
+ ? { modules: parsed.modules }
79
+ : {}),
80
+ total_files: files.length,
81
+ ...(args.folder !== undefined ? { folder_filter: args.folder } : {}),
82
+ },
83
+ };
84
+ } catch (err) {
85
+ return toUnknownResult(err);
86
+ }
87
+ }
@@ -6,17 +6,32 @@ import { ExternalCacheFileSchema } from "../types/cache.js";
6
6
  import { ErrorCode, type Result } from "../types/result.js";
7
7
  import type { PruneArgs, PruneResult } from "../types/commands.js";
8
8
  import { getFileStem } from "../utils/fileStem.js";
9
+ import { toUnknownResult } from "../utils/errors.js";
9
10
 
11
+ /**
12
+ * Parses duration text in `<number><unit>` form.
13
+ *
14
+ * @param duration - Duration string where unit is one of `s`, `m`, `h`, `d`.
15
+ * @returns Milliseconds value, or `null` when format is invalid.
16
+ */
10
17
  export function parseDurationMs(duration: string): number | null {
11
- const match = /^(\d+)(h|d)$/.exec(duration);
18
+ const match = /^(\d+)(s|m|h|d)$/.exec(duration);
12
19
  if (!match) return null;
13
20
  const value = parseInt(match[1]!, 10);
14
21
  const unit = match[2]!;
22
+ if (unit === "s") return value * 1_000;
23
+ if (unit === "m") return value * 60_000;
15
24
  if (unit === "h") return value * 3_600_000;
16
- if (unit === "d") return value * 86_400_000;
17
- return null;
25
+ return value * 86_400_000;
18
26
  }
19
27
 
28
+ /**
29
+ * Prunes stale cache entries by invalidating or deleting them.
30
+ *
31
+ * @param args - {@link PruneArgs} command arguments.
32
+ * @returns Promise<Result<PruneResult["value"]>>; common failures include INVALID_ARGS,
33
+ * FILE_READ_ERROR, FILE_WRITE_ERROR, and UNKNOWN.
34
+ */
20
35
  export async function pruneCommand(args: PruneArgs): Promise<Result<PruneResult["value"]>> {
21
36
  try {
22
37
  const repoRoot = await findRepoRoot(process.cwd());
@@ -82,9 +97,7 @@ export async function pruneCommand(args: PruneArgs): Promise<Result<PruneResult[
82
97
  // Only invalidate if the file already exists
83
98
  const readResult = await readCache(localPath);
84
99
  if (!readResult.ok) {
85
- if (readResult.code === ErrorCode.FILE_NOT_FOUND) {
86
- // Nothing to prune — skip silently
87
- } else {
100
+ if (readResult.code !== ErrorCode.FILE_NOT_FOUND) {
88
101
  return readResult;
89
102
  }
90
103
  } else {
@@ -104,7 +117,6 @@ export async function pruneCommand(args: PruneArgs): Promise<Result<PruneResult[
104
117
  },
105
118
  };
106
119
  } catch (err) {
107
- const error = err as Error;
108
- return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
120
+ return toUnknownResult(err);
109
121
  }
110
122
  }
@@ -5,7 +5,15 @@ import type { CacheEntry } from "../types/cache.js";
5
5
  import { LocalCacheFileSchema } from "../types/cache.js";
6
6
  import { ErrorCode, type Result } from "../types/result.js";
7
7
  import type { SearchArgs, SearchResult } from "../types/commands.js";
8
+ import { toUnknownResult } from "../utils/errors.js";
8
9
 
10
+ /**
11
+ * Searches cache entries across namespaces using keyword-based scoring.
12
+ *
13
+ * @param args - {@link SearchArgs} command arguments.
14
+ * @returns Promise<Result<SearchResult["value"]>>; common failures include FILE_READ_ERROR
15
+ * (external directory listing), PARSE_ERROR (malformed local cache), and UNKNOWN.
16
+ */
9
17
  export async function searchCommand(args: SearchArgs): Promise<Result<SearchResult["value"]>> {
10
18
  try {
11
19
  const repoRoot = await findRepoRoot(process.cwd());
@@ -51,7 +59,6 @@ export async function searchCommand(args: SearchArgs): Promise<Result<SearchResu
51
59
  })),
52
60
  };
53
61
  } catch (err) {
54
- const error = err as Error;
55
- return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
62
+ return toUnknownResult(err);
56
63
  }
57
64
  }
@@ -1,10 +1,17 @@
1
- import { findRepoRoot, listCacheFiles, writeCache } from "../cache/cacheManager.js";
2
- import { resolveTopExternalMatch } from "../cache/externalCache.js";
1
+ import { findRepoRoot, writeCache } from "../cache/cacheManager.js";
2
+ import { updateExternalFetchedAt } from "../cache/externalCache.js";
3
3
  import { resolveLocalCachePath } from "../cache/localCache.js";
4
- import { ErrorCode, type Result } from "../types/result.js";
4
+ import { type Result } from "../types/result.js";
5
5
  import type { TouchArgs, TouchResult } from "../types/commands.js";
6
- import { validateSubject } from "../utils/validate.js";
6
+ import { toUnknownResult } from "../utils/errors.js";
7
7
 
8
+ /**
9
+ * Marks cache entries fresh by setting timestamps to current UTC time.
10
+ *
11
+ * @param args - {@link TouchArgs} command arguments.
12
+ * @returns Promise<Result<TouchResult["value"]>>; common failures include INVALID_ARGS,
13
+ * NO_MATCH, FILE_WRITE_ERROR, and UNKNOWN.
14
+ */
8
15
  export async function touchCommand(args: TouchArgs): Promise<Result<TouchResult["value"]>> {
9
16
  try {
10
17
  const repoRoot = await findRepoRoot(process.cwd());
@@ -12,25 +19,9 @@ export async function touchCommand(args: TouchArgs): Promise<Result<TouchResult[
12
19
  const touched: string[] = [];
13
20
 
14
21
  if (args.agent === "external") {
15
- let filesToTouch: string[];
16
-
17
- if (args.subject) {
18
- const subjectCheck = validateSubject(args.subject);
19
- if (!subjectCheck.ok) return subjectCheck;
20
- const matchResult = await resolveTopExternalMatch(repoRoot, args.subject);
21
- if (!matchResult.ok) return matchResult;
22
- filesToTouch = [matchResult.value];
23
- } else {
24
- const filesResult = await listCacheFiles("external", repoRoot);
25
- if (!filesResult.ok) return filesResult;
26
- filesToTouch = filesResult.value;
27
- }
28
-
29
- for (const filePath of filesToTouch) {
30
- const writeResult = await writeCache(filePath, { fetched_at: newTimestamp });
31
- if (!writeResult.ok) return writeResult;
32
- touched.push(filePath);
33
- }
22
+ const updateResult = await updateExternalFetchedAt(repoRoot, args.subject, newTimestamp);
23
+ if (!updateResult.ok) return updateResult;
24
+ touched.push(...updateResult.value);
34
25
  } else {
35
26
  // local
36
27
  const localPath = resolveLocalCachePath(repoRoot);
@@ -41,7 +32,6 @@ export async function touchCommand(args: TouchArgs): Promise<Result<TouchResult[
41
32
 
42
33
  return { ok: true, value: { touched, new_timestamp: newTimestamp } };
43
34
  } catch (err) {
44
- const error = err as Error;
45
- return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
35
+ return toUnknownResult(err);
46
36
  }
47
37
  }
@@ -0,0 +1,103 @@
1
+ import { readdir, rm, unlink } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { resolveOpenCodeConfigDir } from "../files/openCodeInstaller.js";
6
+ import type { UninstallArgs, UninstallResult } from "../types/commands.js";
7
+ import { ErrorCode, type Result } from "../types/result.js";
8
+
9
+ const textDecoder = new TextDecoder();
10
+ const CACHE_CTRL_SKILL_DIR_PATTERN = /^cache-ctrl-/;
11
+
12
+ /**
13
+ * Removes cache-ctrl OpenCode integration files and uninstalls the global npm package.
14
+ */
15
+ export async function uninstallCommand(args: UninstallArgs): Promise<Result<UninstallResult>> {
16
+ try {
17
+ if (args.configDir !== undefined) {
18
+ const absConfigDir = path.isAbsolute(args.configDir)
19
+ ? path.resolve(args.configDir)
20
+ : path.resolve(process.cwd(), args.configDir);
21
+ const home = os.homedir();
22
+ if (!absConfigDir.startsWith(home + path.sep) && absConfigDir !== home) {
23
+ return {
24
+ ok: false,
25
+ error: `--config-dir must be within the user home directory, got: ${args.configDir}`,
26
+ code: ErrorCode.INVALID_ARGS,
27
+ };
28
+ }
29
+ }
30
+
31
+ const removed: string[] = [];
32
+ const warnings: string[] = [];
33
+ let packageUninstalled = true;
34
+
35
+ const configDir = resolveOpenCodeConfigDir(args.configDir);
36
+ const toolFilePath = path.join(configDir, "tools", "cache_ctrl.ts");
37
+ const skillsDirPath = path.join(configDir, "skills");
38
+ const localBinaryPath = path.join(os.homedir(), ".local", "bin", "cache-ctrl");
39
+
40
+ try {
41
+ await unlink(toolFilePath);
42
+ removed.push(toolFilePath);
43
+ } catch (err) {
44
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
45
+ warnings.push(`Tool file not found: ${toolFilePath}`);
46
+ } else {
47
+ throw err;
48
+ }
49
+ }
50
+
51
+ try {
52
+ const skillEntries = await readdir(skillsDirPath, { withFileTypes: true });
53
+ for (const skillEntry of skillEntries) {
54
+ if (!skillEntry.isDirectory() || !CACHE_CTRL_SKILL_DIR_PATTERN.test(skillEntry.name)) {
55
+ continue;
56
+ }
57
+ const skillPath = path.join(skillsDirPath, skillEntry.name);
58
+ await rm(skillPath, { recursive: true });
59
+ removed.push(skillPath);
60
+ }
61
+ } catch (err) {
62
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
63
+ warnings.push(`Skills directory not found: ${skillsDirPath}`);
64
+ } else {
65
+ throw err;
66
+ }
67
+ }
68
+
69
+ try {
70
+ await unlink(localBinaryPath);
71
+ removed.push(localBinaryPath);
72
+ } catch (err) {
73
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
74
+ warnings.push(`Local binary not found: ${localBinaryPath}`);
75
+ } else {
76
+ throw err;
77
+ }
78
+ }
79
+
80
+ const uninstallProcess = Bun.spawnSync(["npm", "uninstall", "-g", "@thecat69/cache-ctrl"]);
81
+ if (uninstallProcess.exitCode !== 0) {
82
+ packageUninstalled = false;
83
+ const npmError = textDecoder.decode(uninstallProcess.stderr);
84
+ warnings.push(npmError.length > 0 ? npmError : "npm uninstall -g @thecat69/cache-ctrl failed");
85
+ }
86
+
87
+ return {
88
+ ok: true,
89
+ value: {
90
+ removed,
91
+ packageUninstalled,
92
+ warnings,
93
+ },
94
+ };
95
+ } catch (err) {
96
+ const message = err instanceof Error ? err.message : String(err);
97
+ return {
98
+ ok: false,
99
+ error: message,
100
+ code: ErrorCode.UNKNOWN,
101
+ };
102
+ }
103
+ }
@@ -0,0 +1,65 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ import type { UpdateArgs, UpdateResult } from "../types/commands.js";
5
+ import { ErrorCode, type Result } from "../types/result.js";
6
+
7
+ import { installCommand } from "./install.js";
8
+
9
+ const textDecoder = new TextDecoder();
10
+
11
+ /**
12
+ * Updates the globally installed npm package and refreshes OpenCode integration files.
13
+ */
14
+ export async function updateCommand(args: UpdateArgs): Promise<Result<UpdateResult>> {
15
+ try {
16
+ if (args.configDir !== undefined) {
17
+ const absConfigDir = path.isAbsolute(args.configDir)
18
+ ? path.resolve(args.configDir)
19
+ : path.resolve(process.cwd(), args.configDir);
20
+ const home = os.homedir();
21
+ if (!absConfigDir.startsWith(home + path.sep) && absConfigDir !== home) {
22
+ return {
23
+ ok: false,
24
+ error: `--config-dir must be within the user home directory, got: ${args.configDir}`,
25
+ code: ErrorCode.INVALID_ARGS,
26
+ };
27
+ }
28
+ }
29
+
30
+ const warnings: string[] = [];
31
+ let packageUpdated = true;
32
+
33
+ const installProcess = Bun.spawnSync(["npm", "install", "-g", "@thecat69/cache-ctrl@latest"]);
34
+ if (installProcess.exitCode !== 0) {
35
+ packageUpdated = false;
36
+ const npmError = textDecoder.decode(installProcess.stderr);
37
+ warnings.push(npmError.length > 0 ? npmError : "npm install -g @thecat69/cache-ctrl@latest failed");
38
+ }
39
+
40
+ const installResult = await installCommand({ ...(args.configDir !== undefined ? { configDir: args.configDir } : {}) });
41
+ if (!installResult.ok) {
42
+ return {
43
+ ok: false,
44
+ error: installResult.error,
45
+ code: installResult.code,
46
+ };
47
+ }
48
+
49
+ return {
50
+ ok: true,
51
+ value: {
52
+ packageUpdated,
53
+ installedPaths: [installResult.value.toolPath, ...installResult.value.skillPaths],
54
+ warnings,
55
+ },
56
+ };
57
+ } catch (err) {
58
+ const message = err instanceof Error ? err.message : String(err);
59
+ return {
60
+ ok: false,
61
+ error: message,
62
+ code: ErrorCode.UNKNOWN,
63
+ };
64
+ }
65
+ }
@@ -0,0 +1,14 @@
1
+ import type { VersionArgs, VersionResult } from "../types/commands.js";
2
+ import type { Result } from "../types/result.js";
3
+
4
+ import packageJson from "../../package.json" with { type: "json" };
5
+
6
+ /**
7
+ * Returns the package version from `package.json`.
8
+ *
9
+ * @param args - {@link VersionArgs} command arguments (unused).
10
+ * @returns `Result<VersionResult["value"]>` containing the CLI version.
11
+ */
12
+ export function versionCommand(_args: VersionArgs = {}): Result<VersionResult["value"]> {
13
+ return { ok: true, value: { version: packageJson.version } };
14
+ }
@@ -0,0 +1,270 @@
1
+ import path from "node:path";
2
+ import { realpath } from "node:fs/promises";
3
+
4
+ import { buildGraph, type DependencyGraph } from "../analysis/graphBuilder.js";
5
+ import { findRepoRoot, writeCache } from "../cache/cacheManager.js";
6
+ import { resolveGraphCachePath } from "../cache/graphCache.js";
7
+ import { getGitTrackedFiles } from "../files/gitFiles.js";
8
+ import type { GraphCacheFile } from "../types/cache.js";
9
+ import type { WatchArgs } from "../types/commands.js";
10
+ import { ErrorCode, type Result } from "../types/result.js";
11
+ import { toUnknownResult } from "../utils/errors.js";
12
+
13
+ const WATCH_DEBOUNCE_MS = 200;
14
+ const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
15
+
16
+ type TrackedFilesProvider = (repoRoot: string) => Promise<string[]>;
17
+ type WatchEvent = "rename" | "change";
18
+ type BunWatchCallback = (event: WatchEvent, filename: string | null) => void;
19
+
20
+ interface WatcherHandle {
21
+ close?: () => void;
22
+ stop?: () => void;
23
+ }
24
+
25
+ type BunWatchFunction = (
26
+ watchPath: string,
27
+ options: { recursive: boolean },
28
+ callback: BunWatchCallback,
29
+ ) => WatcherHandle;
30
+
31
+ export function resolveBunWatch(): Result<BunWatchFunction> {
32
+ const bunRuntime = Reflect.get(globalThis, "Bun");
33
+ if (typeof bunRuntime !== "object" || bunRuntime === null) {
34
+ return {
35
+ ok: false,
36
+ error: "Bun.watch is not available in this runtime",
37
+ code: ErrorCode.UNKNOWN,
38
+ };
39
+ }
40
+ const watchFn = Reflect.get(bunRuntime, "watch");
41
+ if (typeof watchFn !== "function") {
42
+ return {
43
+ ok: false,
44
+ error: "Bun.watch is not available in this runtime",
45
+ code: ErrorCode.UNKNOWN,
46
+ };
47
+ }
48
+ return { ok: true, value: watchFn };
49
+ }
50
+
51
+ /**
52
+ * Checks whether a file path is a supported source file for graph analysis.
53
+ *
54
+ * @param filePath - Absolute or relative file path.
55
+ * @returns `true` when extension is one of `.ts`, `.tsx`, `.js`, `.jsx`.
56
+ */
57
+ export function isSourceFile(filePath: string): boolean {
58
+ const extension = path.extname(filePath).toLowerCase();
59
+ return SOURCE_EXTENSIONS.has(extension);
60
+ }
61
+
62
+ /**
63
+ * Converts in-memory dependency graph nodes into graph cache file shape.
64
+ *
65
+ * @param graph - Dependency graph to serialize.
66
+ * @returns `GraphCacheFile["files"]` payload suitable for `graph.json`.
67
+ */
68
+ export function serializeGraphToCache(graph: DependencyGraph): GraphCacheFile["files"] {
69
+ const files: GraphCacheFile["files"] = {};
70
+
71
+ for (const [filePath, node] of graph.entries()) {
72
+ files[filePath] = {
73
+ rank: 0.0,
74
+ deps: node.deps,
75
+ defs: node.defs,
76
+ };
77
+ }
78
+
79
+ return files;
80
+ }
81
+
82
+ /**
83
+ * Resolves tracked source files to safe absolute paths for graph building.
84
+ *
85
+ * @param repoRoot - Repository root used for path resolution.
86
+ * @param trackedFilesProvider - Optional provider for tracked paths (defaults to git).
87
+ * @returns Absolute, de-duplicated source file paths constrained to `repoRoot`.
88
+ */
89
+ export async function resolveSourceFilePaths(
90
+ repoRoot: string,
91
+ trackedFilesProvider: TrackedFilesProvider = getGitTrackedFiles,
92
+ ): Promise<string[]> {
93
+ const trackedFiles = await trackedFilesProvider(repoRoot);
94
+ const normalizedRepoRoot = path.resolve(repoRoot);
95
+ const repoPrefix = `${normalizedRepoRoot}${path.sep}`;
96
+ const sourcePaths = new Set<string>();
97
+
98
+ await Promise.all(
99
+ trackedFiles.filter(isSourceFile).map(async (relPath) => {
100
+ const absolutePath = path.join(normalizedRepoRoot, relPath);
101
+ try {
102
+ const resolvedPath = await realpath(absolutePath);
103
+ if (resolvedPath === normalizedRepoRoot || resolvedPath.startsWith(repoPrefix)) {
104
+ sourcePaths.add(resolvedPath);
105
+ }
106
+ } catch {
107
+ // Ignore missing or unreadable paths from tracked files.
108
+ }
109
+ }),
110
+ );
111
+
112
+ return [...sourcePaths];
113
+ }
114
+
115
+ interface RebuildGraphCacheDependencies {
116
+ resolveSourceFilePaths: typeof resolveSourceFilePaths;
117
+ buildGraph: typeof buildGraph;
118
+ resolveGraphCachePath: typeof resolveGraphCachePath;
119
+ writeCache: typeof writeCache;
120
+ }
121
+
122
+ const defaultRebuildGraphCacheDependencies: RebuildGraphCacheDependencies = {
123
+ resolveSourceFilePaths,
124
+ buildGraph,
125
+ resolveGraphCachePath,
126
+ writeCache,
127
+ };
128
+
129
+ export async function rebuildGraphCache(
130
+ repoRoot: string,
131
+ changedPath: string | undefined,
132
+ verbose: boolean,
133
+ dependencies: RebuildGraphCacheDependencies = defaultRebuildGraphCacheDependencies,
134
+ ): Promise<Result<void>> {
135
+ try {
136
+ const sourceFilePaths = await dependencies.resolveSourceFilePaths(repoRoot);
137
+ const graph = await dependencies.buildGraph(sourceFilePaths, repoRoot);
138
+ const graphCachePath = dependencies.resolveGraphCachePath(repoRoot);
139
+ const graphPayload: GraphCacheFile = {
140
+ files: serializeGraphToCache(graph),
141
+ computed_at: new Date().toISOString(),
142
+ };
143
+ const writeResult = await dependencies.writeCache(graphCachePath, graphPayload, "replace");
144
+ if (!writeResult.ok) {
145
+ process.stderr.write(`[watch] Failed to update graph cache: ${writeResult.error}\n`);
146
+ return writeResult;
147
+ }
148
+ if (verbose) {
149
+ if (changedPath !== undefined) {
150
+ process.stdout.write(`[watch] Graph updated: ${graph.size} files, changed: ${changedPath}\n`);
151
+ } else {
152
+ process.stdout.write(`[watch] Initial graph computed: ${graph.size} files\n`);
153
+ }
154
+ }
155
+ return { ok: true, value: undefined };
156
+ } catch (err) {
157
+ const unknownError = toUnknownResult(err);
158
+ process.stderr.write(`[watch] Failed to rebuild graph: ${unknownError.error}\n`);
159
+ return unknownError;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Starts the long-running graph watch daemon.
165
+ *
166
+ * @param args - {@link WatchArgs} command arguments.
167
+ * @returns Promise<Result<never>>; common failures include FILE_WRITE_ERROR via wrapped
168
+ * UNKNOWN, runtime unavailability errors, and UNKNOWN.
169
+ */
170
+ export async function watchCommand(args: WatchArgs): Promise<Result<never>> {
171
+ try {
172
+ const repoRoot = await findRepoRoot(process.cwd());
173
+
174
+ const initialRebuildResult = await rebuildGraphCache(repoRoot, undefined, args.verbose === true);
175
+ if (!initialRebuildResult.ok) {
176
+ return initialRebuildResult;
177
+ }
178
+
179
+ let pendingChangedPath: string | undefined;
180
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
181
+ let rebuildInProgress = false;
182
+ let rebuildQueued = false;
183
+
184
+ const triggerRebuild = async (): Promise<void> => {
185
+ if (rebuildInProgress) {
186
+ rebuildQueued = true;
187
+ return;
188
+ }
189
+
190
+ rebuildInProgress = true;
191
+ try {
192
+ do {
193
+ rebuildQueued = false;
194
+ const changedPath = pendingChangedPath;
195
+ pendingChangedPath = undefined;
196
+ const rebuildResult = await rebuildGraphCache(repoRoot, changedPath, args.verbose === true);
197
+ if (!rebuildResult.ok) {
198
+ // Error already logged in rebuildGraphCache; continue watching for future changes.
199
+ continue;
200
+ }
201
+ } while (rebuildQueued);
202
+ } finally {
203
+ rebuildInProgress = false;
204
+ }
205
+ };
206
+
207
+ const scheduleRebuild = (changedPath: string): void => {
208
+ pendingChangedPath = changedPath;
209
+
210
+ if (debounceTimer !== undefined) {
211
+ clearTimeout(debounceTimer);
212
+ }
213
+
214
+ debounceTimer = setTimeout(() => {
215
+ debounceTimer = undefined;
216
+ void triggerRebuild();
217
+ }, WATCH_DEBOUNCE_MS);
218
+ };
219
+
220
+ const watchResult = resolveBunWatch();
221
+ if (!watchResult.ok) {
222
+ process.stderr.write(`[watch] ${watchResult.error}\n`);
223
+ return watchResult;
224
+ }
225
+
226
+ const watcher = watchResult.value(repoRoot, { recursive: true }, (_event, filename) => {
227
+ if (filename === null) {
228
+ return;
229
+ }
230
+
231
+ const absolutePath = path.join(repoRoot, filename);
232
+ if (!isSourceFile(absolutePath)) {
233
+ return;
234
+ }
235
+
236
+ if (args.verbose) {
237
+ process.stdout.write(`[watch] File changed: ${absolutePath}, recomputing...\n`);
238
+ }
239
+
240
+ scheduleRebuild(absolutePath);
241
+ });
242
+
243
+ const shutdown = (): void => {
244
+ if (debounceTimer !== undefined) {
245
+ clearTimeout(debounceTimer);
246
+ }
247
+ if (args.verbose) {
248
+ process.stdout.write("[watch] Shutting down\n");
249
+ }
250
+ if (typeof watcher.close === "function") {
251
+ watcher.close();
252
+ } else if (typeof watcher.stop === "function") {
253
+ watcher.stop();
254
+ }
255
+ process.exit(0);
256
+ };
257
+
258
+ process.once("SIGINT", shutdown);
259
+ process.once("SIGTERM", shutdown);
260
+
261
+ return new Promise<never>(() => {
262
+ // Keep command alive until signal-based shutdown.
263
+ });
264
+ } catch (err) {
265
+ const unknownError = toUnknownResult(err);
266
+ const message = unknownError.error;
267
+ process.stderr.write(`[watch] Failed to start watcher: ${message}\n`);
268
+ return unknownError;
269
+ }
270
+ }