@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.
- package/README.md +289 -78
- package/cache_ctrl.ts +107 -25
- package/package.json +2 -1
- package/skills/cache-ctrl-caller/SKILL.md +53 -114
- package/skills/cache-ctrl-external/SKILL.md +29 -89
- package/skills/cache-ctrl-local/SKILL.md +82 -164
- package/src/analysis/graphBuilder.ts +85 -0
- package/src/analysis/pageRank.ts +164 -0
- package/src/analysis/symbolExtractor.ts +240 -0
- package/src/cache/cacheManager.ts +53 -4
- package/src/cache/externalCache.ts +72 -77
- package/src/cache/graphCache.ts +12 -0
- package/src/cache/localCache.ts +2 -0
- package/src/commands/checkFiles.ts +9 -6
- package/src/commands/flush.ts +9 -2
- package/src/commands/graph.ts +131 -0
- package/src/commands/inspect.ts +13 -181
- package/src/commands/inspectExternal.ts +79 -0
- package/src/commands/inspectLocal.ts +134 -0
- package/src/commands/install.ts +6 -0
- package/src/commands/invalidate.ts +24 -24
- package/src/commands/list.ts +11 -11
- package/src/commands/map.ts +87 -0
- package/src/commands/prune.ts +20 -8
- package/src/commands/search.ts +9 -2
- package/src/commands/touch.ts +15 -25
- package/src/commands/uninstall.ts +103 -0
- package/src/commands/update.ts +65 -0
- package/src/commands/version.ts +14 -0
- package/src/commands/watch.ts +270 -0
- package/src/commands/writeExternal.ts +51 -0
- package/src/commands/writeLocal.ts +121 -0
- package/src/files/changeDetector.ts +15 -0
- package/src/files/gitFiles.ts +15 -0
- package/src/files/openCodeInstaller.ts +21 -2
- package/src/index.ts +314 -58
- package/src/search/keywordSearch.ts +24 -0
- package/src/types/cache.ts +38 -26
- package/src/types/commands.ts +123 -22
- package/src/types/result.ts +26 -9
- package/src/utils/errors.ts +14 -0
- package/src/utils/traversal.ts +42 -0
- package/src/commands/checkFreshness.ts +0 -123
- package/src/commands/write.ts +0 -170
- package/src/http/freshnessChecker.ts +0 -116
package/src/types/commands.ts
CHANGED
|
@@ -2,10 +2,12 @@ import type { AgentType, ExternalCacheFile, LocalCacheFile } from "./cache.js";
|
|
|
2
2
|
|
|
3
3
|
// ── list ──────────────────────────────────────────────────────────────────────
|
|
4
4
|
|
|
5
|
+
/** Arguments accepted by the `list` command. */
|
|
5
6
|
export interface ListArgs {
|
|
6
7
|
agent?: AgentType | "all";
|
|
7
8
|
}
|
|
8
9
|
|
|
10
|
+
/** One entry returned by the `list` command. */
|
|
9
11
|
export interface ListEntry {
|
|
10
12
|
file: string;
|
|
11
13
|
agent: AgentType;
|
|
@@ -16,10 +18,16 @@ export interface ListEntry {
|
|
|
16
18
|
is_stale: boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
/** Success payload shape returned by the `list` command. */
|
|
19
22
|
export type ListResult = { ok: true; value: ListEntry[] };
|
|
20
23
|
|
|
21
24
|
// ── inspect ───────────────────────────────────────────────────────────────────
|
|
22
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Arguments accepted by the `inspect` command.
|
|
28
|
+
* @remarks `agent` controls result shape: `external` returns an external entry, while
|
|
29
|
+
* `local` returns local cache content with `tracked_files` removed and optional facts filters.
|
|
30
|
+
*/
|
|
23
31
|
export interface InspectArgs {
|
|
24
32
|
agent: AgentType;
|
|
25
33
|
subject: string;
|
|
@@ -39,9 +47,10 @@ export interface InspectArgs {
|
|
|
39
47
|
searchFacts?: string[];
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
/** Success payload shape returned by the `inspect` command. */
|
|
42
51
|
export type InspectResult = {
|
|
43
52
|
ok: true;
|
|
44
|
-
value: (ExternalCacheFile | LocalCacheFile) & {
|
|
53
|
+
value: (ExternalCacheFile | Omit<LocalCacheFile, "tracked_files">) & {
|
|
45
54
|
file: string;
|
|
46
55
|
agent: AgentType;
|
|
47
56
|
};
|
|
@@ -49,11 +58,13 @@ export type InspectResult = {
|
|
|
49
58
|
|
|
50
59
|
// ── flush ─────────────────────────────────────────────────────────────────────
|
|
51
60
|
|
|
61
|
+
/** Arguments accepted by the `flush` command. */
|
|
52
62
|
export interface FlushArgs {
|
|
53
63
|
agent: AgentType | "all";
|
|
54
64
|
confirm: boolean;
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
/** Success payload shape returned by the `flush` command. */
|
|
57
68
|
export type FlushResult = {
|
|
58
69
|
ok: true;
|
|
59
70
|
value: {
|
|
@@ -64,11 +75,13 @@ export type FlushResult = {
|
|
|
64
75
|
|
|
65
76
|
// ── invalidate ────────────────────────────────────────────────────────────────
|
|
66
77
|
|
|
78
|
+
/** Arguments accepted by the `invalidate` command. */
|
|
67
79
|
export interface InvalidateArgs {
|
|
68
80
|
agent: AgentType;
|
|
69
81
|
subject?: string;
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
/** Success payload shape returned by the `invalidate` command. */
|
|
72
85
|
export type InvalidateResult = {
|
|
73
86
|
ok: true;
|
|
74
87
|
value: {
|
|
@@ -78,11 +91,13 @@ export type InvalidateResult = {
|
|
|
78
91
|
|
|
79
92
|
// ── touch ─────────────────────────────────────────────────────────────────────
|
|
80
93
|
|
|
94
|
+
/** Arguments accepted by the `touch` command. */
|
|
81
95
|
export interface TouchArgs {
|
|
82
96
|
agent: AgentType;
|
|
83
97
|
subject?: string;
|
|
84
98
|
}
|
|
85
99
|
|
|
100
|
+
/** Success payload shape returned by the `touch` command. */
|
|
86
101
|
export type TouchResult = {
|
|
87
102
|
ok: true;
|
|
88
103
|
value: {
|
|
@@ -93,12 +108,14 @@ export type TouchResult = {
|
|
|
93
108
|
|
|
94
109
|
// ── prune ─────────────────────────────────────────────────────────────────────
|
|
95
110
|
|
|
111
|
+
/** Arguments accepted by the `prune` command. */
|
|
96
112
|
export interface PruneArgs {
|
|
97
113
|
agent?: AgentType | "all";
|
|
98
114
|
maxAge?: string;
|
|
99
115
|
delete?: boolean;
|
|
100
116
|
}
|
|
101
117
|
|
|
118
|
+
/** Success payload shape returned by the `prune` command. */
|
|
102
119
|
export type PruneResult = {
|
|
103
120
|
ok: true;
|
|
104
121
|
value: {
|
|
@@ -108,29 +125,9 @@ export type PruneResult = {
|
|
|
108
125
|
};
|
|
109
126
|
};
|
|
110
127
|
|
|
111
|
-
// ── check-freshness ───────────────────────────────────────────────────────────
|
|
112
|
-
|
|
113
|
-
export interface CheckFreshnessArgs {
|
|
114
|
-
subject: string;
|
|
115
|
-
url?: string;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export type CheckFreshnessResult = {
|
|
119
|
-
ok: true;
|
|
120
|
-
value: {
|
|
121
|
-
subject: string;
|
|
122
|
-
sources: Array<{
|
|
123
|
-
url: string;
|
|
124
|
-
status: "fresh" | "stale" | "error";
|
|
125
|
-
http_status?: number;
|
|
126
|
-
error?: string;
|
|
127
|
-
}>;
|
|
128
|
-
overall: "fresh" | "stale" | "error";
|
|
129
|
-
};
|
|
130
|
-
};
|
|
131
|
-
|
|
132
128
|
// ── check-files ───────────────────────────────────────────────────────────────
|
|
133
129
|
|
|
130
|
+
/** Success payload shape returned by the `check-files` command. */
|
|
134
131
|
export type CheckFilesResult = {
|
|
135
132
|
ok: true;
|
|
136
133
|
value: {
|
|
@@ -148,10 +145,12 @@ export type CheckFilesResult = {
|
|
|
148
145
|
|
|
149
146
|
// ── search ────────────────────────────────────────────────────────────────────
|
|
150
147
|
|
|
148
|
+
/** Arguments accepted by the `search` command. */
|
|
151
149
|
export interface SearchArgs {
|
|
152
150
|
keywords: string[];
|
|
153
151
|
}
|
|
154
152
|
|
|
153
|
+
/** Success payload shape returned by the `search` command. */
|
|
155
154
|
export type SearchResult = {
|
|
156
155
|
ok: true;
|
|
157
156
|
value: Array<{
|
|
@@ -166,12 +165,18 @@ export type SearchResult = {
|
|
|
166
165
|
|
|
167
166
|
// ── write ─────────────────────────────────────────────────────────────────────
|
|
168
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Shared write-command input contract.
|
|
170
|
+
* @remarks `agent` selects write mode: `external` requires `subject`; `local` ignores
|
|
171
|
+
* `subject` and writes `context.json` using local schema semantics.
|
|
172
|
+
*/
|
|
169
173
|
export interface WriteArgs {
|
|
170
174
|
agent: AgentType;
|
|
171
175
|
subject?: string; // required for external, unused for local
|
|
172
176
|
content: Record<string, unknown>;
|
|
173
177
|
}
|
|
174
178
|
|
|
179
|
+
/** Success payload shape returned by write commands. */
|
|
175
180
|
export type WriteResult = {
|
|
176
181
|
ok: true;
|
|
177
182
|
value: {
|
|
@@ -179,14 +184,110 @@ export type WriteResult = {
|
|
|
179
184
|
};
|
|
180
185
|
};
|
|
181
186
|
|
|
187
|
+
// ── graph ─────────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/** Arguments accepted by the `graph` command. */
|
|
190
|
+
export interface GraphArgs {
|
|
191
|
+
maxTokens?: number;
|
|
192
|
+
seed?: string[];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── watch ─────────────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/** Arguments accepted by the `watch` command. */
|
|
198
|
+
export interface WatchArgs {
|
|
199
|
+
verbose?: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Success payload shape returned by the `graph` command. */
|
|
203
|
+
export interface GraphResult {
|
|
204
|
+
value: {
|
|
205
|
+
ranked_files: Array<{
|
|
206
|
+
path: string;
|
|
207
|
+
rank: number;
|
|
208
|
+
deps: string[];
|
|
209
|
+
defs: string[];
|
|
210
|
+
ref_count: number;
|
|
211
|
+
}>;
|
|
212
|
+
total_files: number;
|
|
213
|
+
computed_at: string;
|
|
214
|
+
token_estimate: number;
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── map ───────────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/** Output depth levels supported by the `map` command. */
|
|
221
|
+
export type MapDepth = "overview" | "modules" | "full";
|
|
222
|
+
|
|
223
|
+
/** Arguments accepted by the `map` command. */
|
|
224
|
+
export interface MapArgs {
|
|
225
|
+
depth?: MapDepth;
|
|
226
|
+
folder?: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Success payload shape returned by the `map` command. */
|
|
230
|
+
export interface MapResult {
|
|
231
|
+
value: {
|
|
232
|
+
depth: MapDepth;
|
|
233
|
+
global_facts: string[];
|
|
234
|
+
files: Array<{
|
|
235
|
+
path: string;
|
|
236
|
+
summary?: string;
|
|
237
|
+
role?: string;
|
|
238
|
+
importance?: number;
|
|
239
|
+
facts?: string[];
|
|
240
|
+
}>;
|
|
241
|
+
modules?: Record<string, string[]>;
|
|
242
|
+
total_files: number;
|
|
243
|
+
folder_filter?: string;
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
182
247
|
// ── install ───────────────────────────────────────────────────────────────────
|
|
183
248
|
|
|
249
|
+
/** Arguments accepted by the `install` command. */
|
|
184
250
|
export interface InstallArgs {
|
|
185
251
|
configDir?: string;
|
|
186
252
|
}
|
|
187
253
|
|
|
254
|
+
/** Success payload shape returned by the `install` command. */
|
|
188
255
|
export interface InstallResult {
|
|
189
256
|
toolPath: string;
|
|
190
257
|
skillPaths: string[];
|
|
191
258
|
configDir: string;
|
|
192
259
|
}
|
|
260
|
+
|
|
261
|
+
// ── version ───────────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
/** Arguments accepted by the `version` command. */
|
|
264
|
+
export type VersionArgs = Record<string, never>;
|
|
265
|
+
|
|
266
|
+
/** Success payload shape returned by the `version` command. */
|
|
267
|
+
export type VersionResult = { value: { version: string } };
|
|
268
|
+
|
|
269
|
+
// ── update / uninstall ────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/** Arguments accepted by the `update` command. */
|
|
272
|
+
export interface UpdateArgs {
|
|
273
|
+
configDir?: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Success payload shape returned by the `update` command. */
|
|
277
|
+
export interface UpdateResult {
|
|
278
|
+
packageUpdated: boolean;
|
|
279
|
+
installedPaths: string[];
|
|
280
|
+
warnings: string[];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Arguments accepted by the `uninstall` command. */
|
|
284
|
+
export interface UninstallArgs {
|
|
285
|
+
configDir?: string;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Success payload shape returned by the `uninstall` command. */
|
|
289
|
+
export interface UninstallResult {
|
|
290
|
+
removed: string[];
|
|
291
|
+
packageUninstalled: boolean;
|
|
292
|
+
warnings: string[];
|
|
293
|
+
}
|
package/src/types/result.ts
CHANGED
|
@@ -1,36 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enumerates all typed failure categories returned by cache-ctrl commands and services.
|
|
3
|
+
*/
|
|
1
4
|
export enum ErrorCode {
|
|
2
|
-
|
|
5
|
+
/** Returned when an expected cache file is absent on disk. */
|
|
3
6
|
FILE_NOT_FOUND = "FILE_NOT_FOUND",
|
|
7
|
+
/** Returned when a file cannot be read due to I/O or permission errors. */
|
|
4
8
|
FILE_READ_ERROR = "FILE_READ_ERROR",
|
|
9
|
+
/** Returned when a write, rename, delete, or other mutating file operation fails. */
|
|
5
10
|
FILE_WRITE_ERROR = "FILE_WRITE_ERROR",
|
|
11
|
+
/** Returned when file content is present but not valid JSON. */
|
|
6
12
|
PARSE_ERROR = "PARSE_ERROR",
|
|
7
13
|
|
|
8
|
-
|
|
14
|
+
/** Returned when advisory lock acquisition exceeds the configured wait timeout. */
|
|
9
15
|
LOCK_TIMEOUT = "LOCK_TIMEOUT",
|
|
16
|
+
/** Returned when lock file operations fail for reasons other than contention timeout. */
|
|
10
17
|
LOCK_ERROR = "LOCK_ERROR",
|
|
11
18
|
|
|
12
|
-
|
|
19
|
+
/** Returned when CLI arguments are missing, malformed, or violate command constraints. */
|
|
13
20
|
INVALID_ARGS = "INVALID_ARGS",
|
|
21
|
+
/** Returned when a destructive operation is requested without explicit confirmation. */
|
|
14
22
|
CONFIRMATION_REQUIRED = "CONFIRMATION_REQUIRED",
|
|
23
|
+
/** Returned when schema or structural validation fails for user-supplied content. */
|
|
15
24
|
VALIDATION_ERROR = "VALIDATION_ERROR",
|
|
16
25
|
|
|
17
|
-
|
|
26
|
+
/** Returned when keyword matching yields zero candidates. */
|
|
18
27
|
NO_MATCH = "NO_MATCH",
|
|
28
|
+
/** Returned when keyword matching yields multiple top-scoring candidates. */
|
|
19
29
|
AMBIGUOUS_MATCH = "AMBIGUOUS_MATCH",
|
|
20
30
|
|
|
21
|
-
|
|
22
|
-
HTTP_REQUEST_FAILED = "HTTP_REQUEST_FAILED",
|
|
23
|
-
URL_NOT_FOUND = "URL_NOT_FOUND",
|
|
24
|
-
|
|
25
|
-
// Internal
|
|
31
|
+
/** Returned for unexpected internal exceptions converted at command boundaries. */
|
|
26
32
|
UNKNOWN = "UNKNOWN",
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Canonical failure payload used by the error branch of {@link Result}.
|
|
37
|
+
*/
|
|
29
38
|
export interface CacheError {
|
|
30
39
|
code: ErrorCode;
|
|
31
40
|
error: string;
|
|
32
41
|
}
|
|
33
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Discriminated union used for recoverable operation outcomes.
|
|
45
|
+
*
|
|
46
|
+
* @typeParam T - Success payload type carried when `ok` is `true`.
|
|
47
|
+
* @typeParam E - Failure payload shape; defaults to {@link CacheError}.
|
|
48
|
+
* @remarks Consumers must branch on `ok`. The success branch contains `value`; the
|
|
49
|
+
* failure branch contains `error` and a typed `code`.
|
|
50
|
+
*/
|
|
34
51
|
export type Result<T, E extends CacheError = CacheError> =
|
|
35
52
|
| { ok: true; value: T }
|
|
36
53
|
| { ok: false; error: string; code: E["code"] };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ErrorCode } from "../types/result.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Converts an unknown thrown value into the canonical UNKNOWN result shape.
|
|
5
|
+
*
|
|
6
|
+
* @param err - Untrusted thrown value caught at a command boundary.
|
|
7
|
+
* @returns `Result<never>`-compatible failure payload with {@link ErrorCode.UNKNOWN}.
|
|
8
|
+
* @remarks This is the canonical catch-all converter used by command handlers to avoid
|
|
9
|
+
* leaking thrown exceptions across the Result-based API boundary.
|
|
10
|
+
*/
|
|
11
|
+
export function toUnknownResult(err: unknown): { ok: false; error: string; code: ErrorCode.UNKNOWN } {
|
|
12
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
13
|
+
return { ok: false, error, code: ErrorCode.UNKNOWN };
|
|
14
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime guard for Zod `superRefine` contexts.
|
|
3
|
+
*
|
|
4
|
+
* @param value - Candidate refinement context value.
|
|
5
|
+
* @returns Type predicate that confirms `value` exposes Zod-compatible `addIssue`.
|
|
6
|
+
* @remarks This guard avoids unsafe assumptions when helper functions are reused outside
|
|
7
|
+
* Zod's refinement pipeline.
|
|
8
|
+
*/
|
|
9
|
+
export function isRefinementContext(
|
|
10
|
+
value: unknown,
|
|
11
|
+
): value is { addIssue: (issue: { code: "custom"; message: string; path: string[] }) => void } {
|
|
12
|
+
if (typeof value !== "object" || value === null) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return typeof (value as Record<string, unknown>)["addIssue"] === "function";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Rejects object keys that can be interpreted as traversal-capable file paths.
|
|
21
|
+
*
|
|
22
|
+
* @param record - Object whose keys are validated (for example `facts` map keys).
|
|
23
|
+
* @param ctx - Zod refinement context used to report validation issues.
|
|
24
|
+
* @remarks Security control: blocks keys containing `..`, leading `/`, `\\`, or `\x00`
|
|
25
|
+
* to prevent path-traversal and null-byte injection when keys are later consumed as
|
|
26
|
+
* filesystem-relative paths.
|
|
27
|
+
*/
|
|
28
|
+
export function rejectTraversalKeys(record: Record<string, unknown>, ctx: unknown): void {
|
|
29
|
+
if (!isRefinementContext(ctx)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const key of Object.keys(record)) {
|
|
34
|
+
if (key.includes("..") || key.startsWith("/") || key.includes("\\") || key.includes("\x00")) {
|
|
35
|
+
ctx.addIssue({
|
|
36
|
+
code: "custom",
|
|
37
|
+
message: `facts key contains a path traversal or invalid character: "${key}"`,
|
|
38
|
+
path: [key],
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { findRepoRoot, readCache, writeCache } from "../cache/cacheManager.js";
|
|
2
|
-
import { isExternalStale, mergeHeaderMetadata, resolveTopExternalMatch } from "../cache/externalCache.js";
|
|
3
|
-
import { checkFreshness } from "../http/freshnessChecker.js";
|
|
4
|
-
import type { ExternalCacheFile } from "../types/cache.js";
|
|
5
|
-
import { ExternalCacheFileSchema } from "../types/cache.js";
|
|
6
|
-
import { ErrorCode, type Result } from "../types/result.js";
|
|
7
|
-
import type { CheckFreshnessArgs, CheckFreshnessResult } from "../types/commands.js";
|
|
8
|
-
import type { HeaderMeta } from "../cache/externalCache.js";
|
|
9
|
-
import { getFileStem } from "../utils/fileStem.js";
|
|
10
|
-
|
|
11
|
-
export async function checkFreshnessCommand(args: CheckFreshnessArgs): Promise<Result<CheckFreshnessResult["value"]>> {
|
|
12
|
-
try {
|
|
13
|
-
const repoRoot = await findRepoRoot(process.cwd());
|
|
14
|
-
|
|
15
|
-
// Find the best-matching external cache entry file path
|
|
16
|
-
const matchResult = await resolveTopExternalMatch(repoRoot, args.subject);
|
|
17
|
-
if (!matchResult.ok) return matchResult;
|
|
18
|
-
|
|
19
|
-
const filePath = matchResult.value;
|
|
20
|
-
|
|
21
|
-
// Load the matched entry's data
|
|
22
|
-
const readResult = await readCache(filePath);
|
|
23
|
-
if (!readResult.ok) return readResult;
|
|
24
|
-
|
|
25
|
-
const parseResult = ExternalCacheFileSchema.safeParse(readResult.value);
|
|
26
|
-
if (!parseResult.success) {
|
|
27
|
-
return { ok: false, error: `Malformed external cache file: ${filePath}`, code: ErrorCode.PARSE_ERROR };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const cacheEntry = parseResult.data;
|
|
31
|
-
const stem = getFileStem(filePath);
|
|
32
|
-
const subject = cacheEntry.subject ?? stem;
|
|
33
|
-
|
|
34
|
-
// Determine which URLs to check
|
|
35
|
-
const sources = cacheEntry.sources ?? [];
|
|
36
|
-
let urlsToCheck = sources;
|
|
37
|
-
|
|
38
|
-
if (args.url) {
|
|
39
|
-
const found = sources.find((s) => s.url === args.url);
|
|
40
|
-
if (!found) {
|
|
41
|
-
return {
|
|
42
|
-
ok: false,
|
|
43
|
-
error: `URL not found in sources for subject '${subject}'`,
|
|
44
|
-
code: ErrorCode.URL_NOT_FOUND,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
urlsToCheck = [found];
|
|
48
|
-
} else {
|
|
49
|
-
urlsToCheck = sources;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Check freshness for each URL
|
|
53
|
-
const sourceResults: Array<{
|
|
54
|
-
url: string;
|
|
55
|
-
status: "fresh" | "stale" | "error";
|
|
56
|
-
http_status?: number;
|
|
57
|
-
error?: string;
|
|
58
|
-
}> = [];
|
|
59
|
-
|
|
60
|
-
const headerUpdates: Record<string, HeaderMeta> = {};
|
|
61
|
-
|
|
62
|
-
for (const source of urlsToCheck) {
|
|
63
|
-
const stored = cacheEntry.header_metadata?.[source.url];
|
|
64
|
-
const result = await checkFreshness({
|
|
65
|
-
url: source.url,
|
|
66
|
-
...(stored?.etag !== undefined ? { etag: stored.etag } : {}),
|
|
67
|
-
...(stored?.last_modified !== undefined ? { last_modified: stored.last_modified } : {}),
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
sourceResults.push({
|
|
71
|
-
url: result.url,
|
|
72
|
-
status: result.status,
|
|
73
|
-
...(result.http_status !== undefined ? { http_status: result.http_status } : {}),
|
|
74
|
-
...(result.error !== undefined ? { error: result.error } : {}),
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
if (result.status !== "error") {
|
|
78
|
-
headerUpdates[source.url] = {
|
|
79
|
-
...(result.etag !== undefined ? { etag: result.etag } : {}),
|
|
80
|
-
...(result.last_modified !== undefined ? { last_modified: result.last_modified } : {}),
|
|
81
|
-
checked_at: new Date().toISOString(),
|
|
82
|
-
status: result.status,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Only write back if at least one URL succeeded
|
|
88
|
-
const hasSuccessfulChecks = Object.keys(headerUpdates).length > 0;
|
|
89
|
-
if (hasSuccessfulChecks) {
|
|
90
|
-
const updated = mergeHeaderMetadata(cacheEntry, headerUpdates);
|
|
91
|
-
const writeResult = await writeCache(filePath, { header_metadata: updated.header_metadata });
|
|
92
|
-
if (!writeResult.ok) return writeResult;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Determine overall status: entryIsOld always wins (stale by age), then anyStale, then allError
|
|
96
|
-
const allError = sourceResults.every((r) => r.status === "error");
|
|
97
|
-
const anyStale = sourceResults.some((r) => r.status === "stale");
|
|
98
|
-
const entryIsOld = isExternalStale(cacheEntry);
|
|
99
|
-
|
|
100
|
-
let overall: "fresh" | "stale" | "error";
|
|
101
|
-
if (entryIsOld) {
|
|
102
|
-
overall = "stale";
|
|
103
|
-
} else if (anyStale) {
|
|
104
|
-
overall = "stale";
|
|
105
|
-
} else if (allError && sourceResults.length > 0) {
|
|
106
|
-
overall = "error";
|
|
107
|
-
} else {
|
|
108
|
-
overall = "fresh";
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return {
|
|
112
|
-
ok: true,
|
|
113
|
-
value: {
|
|
114
|
-
subject,
|
|
115
|
-
sources: sourceResults,
|
|
116
|
-
overall,
|
|
117
|
-
},
|
|
118
|
-
};
|
|
119
|
-
} catch (err) {
|
|
120
|
-
const error = err as Error;
|
|
121
|
-
return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
|
|
122
|
-
}
|
|
123
|
-
}
|
package/src/commands/write.ts
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
|
-
import { ExternalCacheFileSchema, LocalCacheFileSchema } from "../types/cache.js";
|
|
3
|
-
import { ErrorCode, type Result } from "../types/result.js";
|
|
4
|
-
import type { WriteArgs, WriteResult } from "../types/commands.js";
|
|
5
|
-
import { writeCache, findRepoRoot, resolveCacheDir, readCache } from "../cache/cacheManager.js";
|
|
6
|
-
import { validateSubject, formatZodError } from "../utils/validate.js";
|
|
7
|
-
import { resolveTrackedFileStats, filterExistingFiles } from "../files/changeDetector.js";
|
|
8
|
-
import type { TrackedFile } from "../types/cache.js";
|
|
9
|
-
import { TrackedFileSchema } from "../types/cache.js";
|
|
10
|
-
|
|
11
|
-
function evictFactsForDeletedPaths(
|
|
12
|
-
facts: Record<string, string[]>,
|
|
13
|
-
survivingFiles: TrackedFile[],
|
|
14
|
-
): Record<string, string[]> {
|
|
15
|
-
const survivingPaths = new Set(survivingFiles.map((f) => f.path));
|
|
16
|
-
return Object.fromEntries(Object.entries(facts).filter(([path]) => survivingPaths.has(path)));
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export async function writeCommand(args: WriteArgs): Promise<Result<WriteResult["value"]>> {
|
|
20
|
-
try {
|
|
21
|
-
const repoRoot = await findRepoRoot(process.cwd());
|
|
22
|
-
|
|
23
|
-
if (args.agent === "external") {
|
|
24
|
-
// subject is required
|
|
25
|
-
if (!args.subject) {
|
|
26
|
-
return { ok: false, error: "subject is required for external agent", code: ErrorCode.INVALID_ARGS };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const subjectValidation = validateSubject(args.subject);
|
|
30
|
-
if (!subjectValidation.ok) return subjectValidation;
|
|
31
|
-
|
|
32
|
-
// if content.subject is set but mismatches the subject param → error
|
|
33
|
-
if (args.content["subject"] !== undefined && args.content["subject"] !== args.subject) {
|
|
34
|
-
return {
|
|
35
|
-
ok: false,
|
|
36
|
-
error: `content.subject "${String(args.content["subject"])}" does not match subject argument "${args.subject}"`,
|
|
37
|
-
code: ErrorCode.VALIDATION_ERROR,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// inject subject into content if absent
|
|
42
|
-
const contentWithSubject = { ...args.content, subject: args.subject };
|
|
43
|
-
|
|
44
|
-
// validate against ExternalCacheFileSchema
|
|
45
|
-
const parsed = ExternalCacheFileSchema.safeParse(contentWithSubject);
|
|
46
|
-
if (!parsed.success) {
|
|
47
|
-
const message = formatZodError(parsed.error);
|
|
48
|
-
return { ok: false, error: `Validation failed: ${message}`, code: ErrorCode.VALIDATION_ERROR };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const cacheDir = resolveCacheDir("external", repoRoot);
|
|
52
|
-
const filePath = join(cacheDir, `${args.subject}.json`);
|
|
53
|
-
const writeResult = await writeCache(filePath, contentWithSubject);
|
|
54
|
-
if (!writeResult.ok) return writeResult;
|
|
55
|
-
return { ok: true, value: { file: filePath } };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// local — auto-inject server-side timestamp; agent must not control this field
|
|
59
|
-
const contentWithTimestamp: Record<string, unknown> = {
|
|
60
|
-
...args.content,
|
|
61
|
-
timestamp: new Date().toISOString(),
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
// Resolve real mtimes for submitted tracked_files if present
|
|
65
|
-
const rawTrackedFiles = contentWithTimestamp["tracked_files"];
|
|
66
|
-
let survivingSubmitted: TrackedFile[] = [];
|
|
67
|
-
let guardedPaths = new Set<string>();
|
|
68
|
-
|
|
69
|
-
if (Array.isArray(rawTrackedFiles)) {
|
|
70
|
-
const validEntries = rawTrackedFiles
|
|
71
|
-
.filter(
|
|
72
|
-
(entry): entry is { path: string } =>
|
|
73
|
-
entry !== null &&
|
|
74
|
-
typeof entry === "object" &&
|
|
75
|
-
typeof (entry as Record<string, unknown>)["path"] === "string",
|
|
76
|
-
)
|
|
77
|
-
.map((entry) => ({ path: entry.path }));
|
|
78
|
-
|
|
79
|
-
guardedPaths = new Set(validEntries.map((e) => e.path));
|
|
80
|
-
|
|
81
|
-
const resolved = await resolveTrackedFileStats(validEntries, repoRoot);
|
|
82
|
-
// Evict submitted entries for files that are missing or path-traversal-rejected
|
|
83
|
-
survivingSubmitted = resolved.filter((f) => f.mtime !== 0);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Guard: submitted facts paths must be a strict subset of submitted tracked_files paths
|
|
87
|
-
const rawSubmittedFacts = contentWithTimestamp["facts"];
|
|
88
|
-
if (
|
|
89
|
-
rawSubmittedFacts !== null &&
|
|
90
|
-
rawSubmittedFacts !== undefined &&
|
|
91
|
-
typeof rawSubmittedFacts === "object" &&
|
|
92
|
-
!Array.isArray(rawSubmittedFacts)
|
|
93
|
-
) {
|
|
94
|
-
const violatingPaths = Object.keys(rawSubmittedFacts as Record<string, string[]>).filter(
|
|
95
|
-
(p) => !guardedPaths.has(p),
|
|
96
|
-
);
|
|
97
|
-
if (violatingPaths.length > 0) {
|
|
98
|
-
return {
|
|
99
|
-
ok: false,
|
|
100
|
-
error: `facts contains paths not in submitted tracked_files: ${violatingPaths.join(", ")}`,
|
|
101
|
-
code: ErrorCode.VALIDATION_ERROR,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Read existing cache to perform per-path merge
|
|
107
|
-
const localCacheDir = resolveCacheDir("local", repoRoot);
|
|
108
|
-
const filePath = join(localCacheDir, "context.json");
|
|
109
|
-
|
|
110
|
-
const readResult = await readCache(filePath);
|
|
111
|
-
let existingContent: Record<string, unknown> = {};
|
|
112
|
-
let existingTrackedFiles: TrackedFile[] = [];
|
|
113
|
-
|
|
114
|
-
if (readResult.ok) {
|
|
115
|
-
existingContent = readResult.value;
|
|
116
|
-
// Validate the on-disk tracked_files against the schema — fall back to [] on corrupt/missing data
|
|
117
|
-
const parseResult = TrackedFileSchema.array().safeParse(existingContent["tracked_files"]);
|
|
118
|
-
existingTrackedFiles = parseResult.success ? parseResult.data : [];
|
|
119
|
-
} else if (readResult.code !== ErrorCode.FILE_NOT_FOUND) {
|
|
120
|
-
return { ok: false, error: readResult.error, code: readResult.code };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Keep existing entries whose paths are NOT being replaced by the submitted set
|
|
124
|
-
const submittedPaths = new Set(survivingSubmitted.map((f) => f.path));
|
|
125
|
-
const existingNotSubmitted = existingTrackedFiles.filter((f) => !submittedPaths.has(f.path));
|
|
126
|
-
|
|
127
|
-
// Evict deleted files from the preserved existing entries
|
|
128
|
-
const survivingExisting = await filterExistingFiles(existingNotSubmitted, repoRoot);
|
|
129
|
-
|
|
130
|
-
const mergedTrackedFiles = [...survivingExisting, ...survivingSubmitted];
|
|
131
|
-
|
|
132
|
-
// Per-path merge for facts (mirrors tracked_files merge)
|
|
133
|
-
const existingFactsRaw = existingContent["facts"];
|
|
134
|
-
const submittedFactsRaw = contentWithTimestamp["facts"];
|
|
135
|
-
|
|
136
|
-
const existingFacts =
|
|
137
|
-
typeof existingFactsRaw === "object" && existingFactsRaw !== null && !Array.isArray(existingFactsRaw)
|
|
138
|
-
? (existingFactsRaw as Record<string, string[]>)
|
|
139
|
-
: {};
|
|
140
|
-
const submittedFacts =
|
|
141
|
-
typeof submittedFactsRaw === "object" && submittedFactsRaw !== null && !Array.isArray(submittedFactsRaw)
|
|
142
|
-
? (submittedFactsRaw as Record<string, string[]>)
|
|
143
|
-
: {};
|
|
144
|
-
|
|
145
|
-
const rawMergedFacts = { ...existingFacts, ...submittedFacts };
|
|
146
|
-
const mergedFacts = evictFactsForDeletedPaths(rawMergedFacts, mergedTrackedFiles);
|
|
147
|
-
|
|
148
|
-
// Merge top-level fields: existing base → then submitted content (submitted wins)
|
|
149
|
-
const processedContent: Record<string, unknown> = {
|
|
150
|
-
...existingContent,
|
|
151
|
-
...contentWithTimestamp,
|
|
152
|
-
tracked_files: mergedTrackedFiles,
|
|
153
|
-
facts: mergedFacts,
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
const parsed = LocalCacheFileSchema.safeParse(processedContent);
|
|
157
|
-
if (!parsed.success) {
|
|
158
|
-
const message = formatZodError(parsed.error);
|
|
159
|
-
return { ok: false, error: `Validation failed: ${message}`, code: ErrorCode.VALIDATION_ERROR };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// processedContent is used (not parsed.data) to preserve loose fields not known to the schema — intentional merge semantics
|
|
163
|
-
const writeResult = await writeCache(filePath, processedContent, "replace");
|
|
164
|
-
if (!writeResult.ok) return writeResult;
|
|
165
|
-
return { ok: true, value: { file: filePath } };
|
|
166
|
-
} catch (err) {
|
|
167
|
-
const error = err as Error;
|
|
168
|
-
return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
|
|
169
|
-
}
|
|
170
|
-
}
|