@gmickel/gno 1.34.4 → 1.34.6

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 (37) hide show
  1. package/README.md +11 -1
  2. package/THIRD_PARTY_NOTICES.md +16 -0
  3. package/browser-extension/artifacts/{gno-browser-clipper-v1.34.4.zip → gno-browser-clipper-v1.34.6.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.34.6.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +5 -1
  7. package/spec/cli.md +16 -10
  8. package/src/cli/pager.ts +29 -14
  9. package/src/ingestion/sync.ts +368 -84
  10. package/src/serve/public/globals.built.css +1 -1
  11. package/src/serve/watch-reconciliation-fallback-disk.ts +220 -0
  12. package/src/serve/watch-reconciliation-fallback.ts +404 -0
  13. package/src/serve/watch-reconciliation-shared.ts +343 -0
  14. package/src/serve/watch-reconciliation.ts +122 -0
  15. package/src/serve/watch-service-events.ts +261 -0
  16. package/src/serve/watch-service-flush-generation.ts +140 -0
  17. package/src/serve/watch-service-flush-helpers.ts +147 -0
  18. package/src/serve/watch-service-flush.ts +433 -0
  19. package/src/serve/watch-service-hosts.ts +109 -0
  20. package/src/serve/watch-service-lifecycle.ts +219 -0
  21. package/src/serve/watch-service-run-flush.ts +236 -0
  22. package/src/serve/watch-service-snapshot.ts +101 -0
  23. package/src/serve/watch-service-state.ts +146 -0
  24. package/src/serve/watch-service.ts +265 -306
  25. package/src/serve/watch-snapshot-handles.ts +285 -0
  26. package/src/serve/watch-snapshot-libc.ts +391 -0
  27. package/src/serve/watch-snapshot-ops.ts +399 -0
  28. package/src/serve/watch-snapshot-resolve.ts +246 -0
  29. package/src/serve/watch-snapshot-scan.ts +297 -0
  30. package/src/serve/watch-snapshot-types.ts +350 -0
  31. package/src/serve/watch-snapshot.ts +51 -0
  32. package/src/store/index.ts +1 -1
  33. package/src/store/sqlite/adapter.ts +191 -0
  34. package/src/store/types.ts +66 -0
  35. package/vendor/fts5-snowball/README.md +5 -1
  36. package/vendor/fts5-snowball/darwin-x64/fts5stemmer.dylib +0 -0
  37. package/browser-extension/artifacts/gno-browser-clipper-v1.34.4.zip.sha256 +0 -1
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Build and diff orchestration for watcher snapshots.
3
+ * Hint reconciliation lives in `watch-snapshot-resolve`.
4
+ *
5
+ * @module src/serve/watch-snapshot-ops
6
+ */
7
+
8
+ import type {
9
+ DiffWorkResult,
10
+ SnapshotEntryFingerprint,
11
+ WatcherSnapshot,
12
+ WatcherSnapshotBuildResult,
13
+ WatcherSnapshotDiffResult,
14
+ WatcherSnapshotFs,
15
+ WatcherSnapshotOptions,
16
+ } from "./watch-snapshot-types";
17
+
18
+ import {
19
+ cloneDirectoryMaps,
20
+ defaultClock,
21
+ defaultFs,
22
+ freezeSnapshot,
23
+ readDirectChildren,
24
+ removeSubtreeFromMaps,
25
+ setDirectoryEntries,
26
+ type MutableSnapshotMaps,
27
+ } from "./watch-snapshot-scan";
28
+ import {
29
+ WATCHER_SNAPSHOT_ENTRY_CEILING,
30
+ fingerprintsEqual,
31
+ isWatcherSourceKind,
32
+ joinWatcherRelPath,
33
+ normalizeWatcherRelPath,
34
+ sortPathList,
35
+ } from "./watch-snapshot-types";
36
+
37
+ /**
38
+ * Build a full no-follow hierarchical snapshot of `rootAbs`.
39
+ * On overflow, scan failure, or unreliable metadata the last proven snapshot
40
+ * is untouched (this function simply does not return one).
41
+ */
42
+ export async function buildWatcherSnapshot(
43
+ rootAbs: string,
44
+ options: WatcherSnapshotOptions = {}
45
+ ): Promise<WatcherSnapshotBuildResult> {
46
+ const fs = options.fs ?? defaultFs;
47
+ const clock = options.clock ?? defaultClock;
48
+ const ceiling = options.entryCeiling ?? WATCHER_SNAPSHOT_ENTRY_CEILING;
49
+ const started = clock.nowMs();
50
+
51
+ const state: MutableSnapshotMaps = {
52
+ directories: new Map(),
53
+ entryCount: 0,
54
+ };
55
+ // Cursor-index queue avoids O(n) shift on large directory sets.
56
+ const queue: string[] = [""];
57
+ let head = 0;
58
+
59
+ while (head < queue.length) {
60
+ const dirRel = queue[head] as string;
61
+ head += 1;
62
+ // Remaining slots before this directory map is installed.
63
+ const remaining = ceiling - state.entryCount;
64
+ if (remaining < 0) {
65
+ return {
66
+ status: "fallback",
67
+ reason: "overflow",
68
+ durationMs: clock.nowMs() - started,
69
+ };
70
+ }
71
+ const scanned = await readDirectChildren(rootAbs, dirRel, fs, remaining);
72
+ if (scanned.status === "missing") {
73
+ if (dirRel === "") {
74
+ return {
75
+ status: "fallback",
76
+ reason: "scan_failed",
77
+ durationMs: clock.nowMs() - started,
78
+ cause: new Error("Collection root is missing"),
79
+ };
80
+ }
81
+ // Nested directory vanished mid-scan: fail closed rather than prove removals.
82
+ return {
83
+ status: "fallback",
84
+ reason: "scan_failed",
85
+ durationMs: clock.nowMs() - started,
86
+ cause: new Error(`Directory vanished during snapshot: ${dirRel}`),
87
+ };
88
+ }
89
+ if (scanned.status !== "present") {
90
+ return {
91
+ status: "fallback",
92
+ reason: scanned.status,
93
+ durationMs: clock.nowMs() - started,
94
+ cause: scanned.status === "scan_failed" ? scanned.cause : undefined,
95
+ };
96
+ }
97
+
98
+ setDirectoryEntries(state, dirRel, scanned.entries);
99
+ if (state.entryCount > ceiling) {
100
+ return {
101
+ status: "fallback",
102
+ reason: "overflow",
103
+ durationMs: clock.nowMs() - started,
104
+ };
105
+ }
106
+
107
+ for (const [name, fingerprint] of scanned.entries) {
108
+ // No-follow: never recurse through symlinks (even if they point inside root).
109
+ if (fingerprint.kind === "directory") {
110
+ queue.push(joinWatcherRelPath(dirRel, name));
111
+ }
112
+ }
113
+ }
114
+
115
+ return {
116
+ status: "ok",
117
+ snapshot: freezeSnapshot(state),
118
+ durationMs: clock.nowMs() - started,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Diff one or more dirty directories against the last proven snapshot.
124
+ *
125
+ * - Compares direct children only
126
+ * - Recurses into changed or new real directories
127
+ * - Expands removals hierarchically from the old snapshot (O(subtree))
128
+ * - Emits explicit `removals` separate from present/changed `candidates`
129
+ * - Never mutates `previous`; overflow/failure leaves it as the last proven state
130
+ */
131
+ export async function diffWatcherSnapshot(
132
+ rootAbs: string,
133
+ previous: WatcherSnapshot,
134
+ dirtyDirectories: readonly string[],
135
+ options: WatcherSnapshotOptions = {}
136
+ ): Promise<WatcherSnapshotDiffResult> {
137
+ const fs = options.fs ?? defaultFs;
138
+ const clock = options.clock ?? defaultClock;
139
+ const ceiling = options.entryCeiling ?? WATCHER_SNAPSHOT_ENTRY_CEILING;
140
+ const mapHooks = options.mapHooks;
141
+ const started = clock.nowMs();
142
+
143
+ const normalizedDirs: string[] = [];
144
+ for (const raw of dirtyDirectories) {
145
+ const normalized = normalizeWatcherRelPath(raw === "" ? "." : raw);
146
+ // `normalizeWatcherRelPath(".")` → `""`; empty string is the root and is
147
+ // already valid. Accept explicit "" without going through normalize of "".
148
+ if (raw === "") {
149
+ normalizedDirs.push("");
150
+ continue;
151
+ }
152
+ if (normalized === null) {
153
+ // Invalid dirty directory: ignore as a no-op rather than failing the batch.
154
+ // Callers should resolve hints first; this guard keeps the primitive safe.
155
+ continue;
156
+ }
157
+ normalizedDirs.push(normalized);
158
+ }
159
+
160
+ if (normalizedDirs.length === 0) {
161
+ return {
162
+ status: "ok",
163
+ candidates: [],
164
+ removals: [],
165
+ nextSnapshot: previous,
166
+ discoveryMs: clock.nowMs() - started,
167
+ };
168
+ }
169
+
170
+ const nextState = cloneDirectoryMaps(previous);
171
+ const candidates = new Set<string>();
172
+ const removals = new Set<string>();
173
+ const visited = new Set<string>();
174
+
175
+ const recordSubtreeRemovals = (dirRel: string): void => {
176
+ // One hierarchical collect+remove pass — no prior full-map scan.
177
+ for (const path of removeSubtreeFromMaps(nextState, dirRel, mapHooks)) {
178
+ removals.add(path);
179
+ }
180
+ };
181
+
182
+ const diffDirectory = async (dirRel: string): Promise<DiffWorkResult> => {
183
+ if (visited.has(dirRel)) {
184
+ return { status: "ok" };
185
+ }
186
+ visited.add(dirRel);
187
+
188
+ // Replacement frees prior slots for this directory map before the new scan.
189
+ const previousSize = nextState.directories.get(dirRel)?.size ?? 0;
190
+ const remaining = ceiling - nextState.entryCount + previousSize;
191
+ if (remaining < 0) {
192
+ return { status: "overflow" };
193
+ }
194
+ const scanned = await readDirectChildren(rootAbs, dirRel, fs, remaining);
195
+ if (scanned.status === "missing") {
196
+ // Open-time ENOENT/ENOTDIR is never directory-deletion proof.
197
+ // A nested dirty target may have raced into a file/symlink/other after
198
+ // resolve, and recursive open may disagree with a parent listing that
199
+ // still observed a directory — both are inconsistent scans. Deletion is
200
+ // proven only when a successful containing-parent listing observes the
201
+ // child absent (handled via oldFp && !newFp below).
202
+ return {
203
+ status: "scan_failed",
204
+ cause: new Error(
205
+ dirRel === ""
206
+ ? "Collection root is missing"
207
+ : `Directory open failed (missing or not a directory): ${dirRel}`
208
+ ),
209
+ };
210
+ }
211
+ if (scanned.status !== "present") {
212
+ return scanned;
213
+ }
214
+
215
+ const oldEntries =
216
+ nextState.directories.get(dirRel) ??
217
+ new Map<string, SnapshotEntryFingerprint>();
218
+ const newEntries = scanned.entries;
219
+ setDirectoryEntries(nextState, dirRel, new Map(newEntries));
220
+
221
+ if (nextState.entryCount > ceiling) {
222
+ return { status: "overflow" };
223
+ }
224
+
225
+ const names = new Set([...oldEntries.keys(), ...newEntries.keys()]);
226
+ for (const name of names) {
227
+ const oldFp = oldEntries.get(name);
228
+ const newFp = newEntries.get(name);
229
+ const childRel = joinWatcherRelPath(dirRel, name);
230
+
231
+ if (oldFp && !newFp) {
232
+ // Removed entry: expand prior subtree for directories; files/symlinks directly.
233
+ // `other` was never an indexable source — drop fingerprint only.
234
+ if (oldFp.kind === "directory") {
235
+ recordSubtreeRemovals(childRel);
236
+ } else if (isWatcherSourceKind(oldFp.kind)) {
237
+ removals.add(childRel);
238
+ }
239
+ continue;
240
+ }
241
+
242
+ if (newFp && !oldFp) {
243
+ // Added entry. Candidates are file/symlink only — ignore new FIFO/etc.
244
+ if (newFp.kind === "directory") {
245
+ const built = await scanNewSubtree(
246
+ rootAbs,
247
+ childRel,
248
+ fs,
249
+ nextState,
250
+ candidates,
251
+ ceiling
252
+ );
253
+ if (built.status !== "ok") {
254
+ return built;
255
+ }
256
+ } else if (isWatcherSourceKind(newFp.kind)) {
257
+ candidates.add(childRel);
258
+ }
259
+ // new `other`: fingerprint retained for future transitions; no candidate.
260
+ continue;
261
+ }
262
+
263
+ if (oldFp && newFp && !fingerprintsEqual(oldFp, newFp)) {
264
+ // Changed entry — handle kind transitions with the other-kind contract.
265
+ if (oldFp.kind === "directory" && newFp.kind !== "directory") {
266
+ // Directory → file/symlink/other: expand nested indexable removals.
267
+ recordSubtreeRemovals(childRel);
268
+ if (isWatcherSourceKind(newFp.kind)) {
269
+ candidates.add(childRel);
270
+ }
271
+ // directory → other: no candidate for the special entry.
272
+ continue;
273
+ }
274
+
275
+ if (oldFp.kind !== "directory" && newFp.kind === "directory") {
276
+ // File/symlink → directory: old source is removable.
277
+ // Other → directory: prior other was never indexed — no removal.
278
+ if (isWatcherSourceKind(oldFp.kind)) {
279
+ removals.add(childRel);
280
+ }
281
+ const built = await scanNewSubtree(
282
+ rootAbs,
283
+ childRel,
284
+ fs,
285
+ nextState,
286
+ candidates,
287
+ ceiling
288
+ );
289
+ if (built.status !== "ok") {
290
+ return built;
291
+ }
292
+ continue;
293
+ }
294
+
295
+ if (newFp.kind === "directory") {
296
+ // Directory → directory (metadata change): recurse.
297
+ const nested = await diffDirectory(childRel);
298
+ if (nested.status !== "ok") {
299
+ return nested;
300
+ }
301
+ continue;
302
+ }
303
+
304
+ // Both non-directory.
305
+ if (
306
+ isWatcherSourceKind(oldFp.kind) &&
307
+ isWatcherSourceKind(newFp.kind)
308
+ ) {
309
+ // file↔symlink or metadata change on an indexable source.
310
+ candidates.add(childRel);
311
+ } else if (isWatcherSourceKind(oldFp.kind) && newFp.kind === "other") {
312
+ // file/symlink → other: remove old path; special entry is not a candidate.
313
+ removals.add(childRel);
314
+ } else if (oldFp.kind === "other" && isWatcherSourceKind(newFp.kind)) {
315
+ // other → file/symlink: new source is a candidate.
316
+ candidates.add(childRel);
317
+ }
318
+ // other → other (metadata): fingerprint only.
319
+ }
320
+ // Equal fingerprints: leave alone (do not recurse into unchanged dirs).
321
+ }
322
+
323
+ return { status: "ok" };
324
+ };
325
+
326
+ for (const dir of normalizedDirs) {
327
+ const result = await diffDirectory(dir);
328
+ if (result.status !== "ok") {
329
+ return {
330
+ status: "fallback",
331
+ reason: result.status,
332
+ discoveryMs: clock.nowMs() - started,
333
+ cause: result.status === "scan_failed" ? result.cause : undefined,
334
+ };
335
+ }
336
+ }
337
+
338
+ if (nextState.entryCount > ceiling) {
339
+ return {
340
+ status: "fallback",
341
+ reason: "overflow",
342
+ discoveryMs: clock.nowMs() - started,
343
+ };
344
+ }
345
+
346
+ return {
347
+ status: "ok",
348
+ candidates: sortPathList(candidates),
349
+ removals: sortPathList(removals),
350
+ nextSnapshot: freezeSnapshot(nextState),
351
+ discoveryMs: clock.nowMs() - started,
352
+ };
353
+ }
354
+
355
+ async function scanNewSubtree(
356
+ rootAbs: string,
357
+ dirRel: string,
358
+ fs: WatcherSnapshotFs,
359
+ state: MutableSnapshotMaps,
360
+ candidates: Set<string>,
361
+ ceiling: number
362
+ ): Promise<DiffWorkResult> {
363
+ const queue = [dirRel];
364
+ let head = 0;
365
+ while (head < queue.length) {
366
+ const current = queue[head] as string;
367
+ head += 1;
368
+ const previousSize = state.directories.get(current)?.size ?? 0;
369
+ const remaining = ceiling - state.entryCount + previousSize;
370
+ if (remaining < 0) {
371
+ return { status: "overflow" };
372
+ }
373
+ const scanned = await readDirectChildren(rootAbs, current, fs, remaining);
374
+ if (scanned.status === "missing") {
375
+ // New directory vanished while scanning — fail closed.
376
+ return {
377
+ status: "scan_failed",
378
+ cause: new Error(`New directory vanished during scan: ${current}`),
379
+ };
380
+ }
381
+ if (scanned.status !== "present") {
382
+ return scanned;
383
+ }
384
+ setDirectoryEntries(state, current, new Map(scanned.entries));
385
+ if (state.entryCount > ceiling) {
386
+ return { status: "overflow" };
387
+ }
388
+ for (const [name, fingerprint] of scanned.entries) {
389
+ const childRel = joinWatcherRelPath(current, name);
390
+ if (fingerprint.kind === "directory") {
391
+ queue.push(childRel);
392
+ } else if (isWatcherSourceKind(fingerprint.kind)) {
393
+ candidates.add(childRel);
394
+ }
395
+ // Nested `other` under a new directory: fingerprint only, never a candidate.
396
+ }
397
+ }
398
+ return { status: "ok" };
399
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Untrusted watcher hint → dirty directory resolution + reconcile orchestration.
3
+ *
4
+ * Resolves path components one at a time from an anchored root handle so
5
+ * intermediate symlinks are never descended and outside children are never
6
+ * lstat'd through a link-out prefix.
7
+ *
8
+ * @module src/serve/watch-snapshot-resolve
9
+ */
10
+
11
+ // node:path — Bun has no path utilities
12
+ import { resolve, sep } from "node:path";
13
+
14
+ import type {
15
+ WatcherDirHandle,
16
+ WatcherSnapshot,
17
+ WatcherSnapshotDiffResult,
18
+ WatcherSnapshotOptions,
19
+ } from "./watch-snapshot-types";
20
+
21
+ import { diffWatcherSnapshot } from "./watch-snapshot-ops";
22
+ import { defaultClock, defaultFs } from "./watch-snapshot-scan";
23
+ import {
24
+ isMissingFsError,
25
+ joinWatcherRelPath,
26
+ normalizeWatcherRelPath,
27
+ parentWatcherDir,
28
+ } from "./watch-snapshot-types";
29
+
30
+ /**
31
+ * Resolve an untrusted hint to the dirty directory that should be diffed.
32
+ * Missing paths climb to the nearest surviving in-root ancestor.
33
+ *
34
+ * A missing collection root is not a deletion proof: returns scan_failed
35
+ * fallback so callers do not emit removal candidates or advance snapshots.
36
+ *
37
+ * Symlink (or non-directory) components stop descent: the containing directory
38
+ * is returned so the parent listing observes the link/file without following it.
39
+ */
40
+ export async function resolveWatcherDirtyDirectory(
41
+ rootAbs: string,
42
+ hint: string,
43
+ options: WatcherSnapshotOptions = {}
44
+ ): Promise<
45
+ | { status: "ok"; directory: string }
46
+ | { status: "invalid" }
47
+ | { status: "fallback"; reason: "scan_failed"; cause?: unknown }
48
+ > {
49
+ const normalized = normalizeWatcherRelPath(hint);
50
+ if (normalized === null) {
51
+ return { status: "invalid" };
52
+ }
53
+
54
+ // Reject absolute-after-join escapes: only allow paths under rootAbs lexically.
55
+ const rootResolved = resolve(rootAbs);
56
+ if (normalized !== "") {
57
+ const absCandidate = resolve(rootResolved, ...normalized.split("/"));
58
+ const rel = absCandidate.startsWith(rootResolved + sep)
59
+ ? absCandidate.slice(rootResolved.length + sep.length)
60
+ : absCandidate === rootResolved
61
+ ? ""
62
+ : null;
63
+ if (rel === null) {
64
+ return { status: "invalid" };
65
+ }
66
+ }
67
+
68
+ const fs = options.fs ?? defaultFs;
69
+ if (!fs.supportsAnchoredHandles) {
70
+ return {
71
+ status: "fallback",
72
+ reason: "scan_failed",
73
+ cause: new Error(
74
+ "Anchored no-follow directory handles unavailable; refusing path-based hint resolution"
75
+ ),
76
+ };
77
+ }
78
+
79
+ let rootHandle: WatcherDirHandle;
80
+ try {
81
+ rootHandle = await fs.openDir(rootResolved);
82
+ } catch (cause) {
83
+ if (isMissingFsError(cause)) {
84
+ return {
85
+ status: "fallback",
86
+ reason: "scan_failed",
87
+ cause: new Error("Collection root is missing"),
88
+ };
89
+ }
90
+ return { status: "fallback", reason: "scan_failed", cause };
91
+ }
92
+
93
+ const opened: WatcherDirHandle[] = [rootHandle];
94
+ const closeAll = async (): Promise<void> => {
95
+ for (let i = opened.length - 1; i >= 0; i -= 1) {
96
+ const handle = opened[i];
97
+ if (handle) {
98
+ await fs.closeDir(handle);
99
+ }
100
+ }
101
+ opened.length = 0;
102
+ };
103
+
104
+ try {
105
+ if (normalized === "") {
106
+ return { status: "ok", directory: "" };
107
+ }
108
+
109
+ const segments = normalized.split("/");
110
+ let parentHandle = rootHandle;
111
+ let parentRel = "";
112
+
113
+ for (let index = 0; index < segments.length; index += 1) {
114
+ const segment = segments[index] as string;
115
+ let stat;
116
+ try {
117
+ stat = await fs.lstatChild(parentHandle, segment);
118
+ } catch (cause) {
119
+ if (isMissingFsError(cause)) {
120
+ // Missing component: dirty directory is the nearest surviving ancestor.
121
+ return { status: "ok", directory: parentRel };
122
+ }
123
+ return {
124
+ status: "fallback",
125
+ reason: "scan_failed",
126
+ cause: new Error(`Failed to inspect hint segment: ${segment}`),
127
+ };
128
+ }
129
+
130
+ const childRel = joinWatcherRelPath(parentRel, segment);
131
+ const isLast = index === segments.length - 1;
132
+
133
+ // Never descend through symlink / file / other — parent listing is dirty.
134
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
135
+ return { status: "ok", directory: parentRel };
136
+ }
137
+
138
+ // Real directory.
139
+ if (isLast) {
140
+ return { status: "ok", directory: childRel };
141
+ }
142
+
143
+ try {
144
+ const childHandle = await fs.openChildDir(parentHandle, segment);
145
+ opened.push(childHandle);
146
+ parentHandle = childHandle;
147
+ parentRel = childRel;
148
+ } catch (cause) {
149
+ if (isMissingFsError(cause)) {
150
+ return { status: "ok", directory: parentRel };
151
+ }
152
+ return { status: "fallback", reason: "scan_failed", cause };
153
+ }
154
+ }
155
+
156
+ return { status: "ok", directory: parentRel };
157
+ } finally {
158
+ await closeAll();
159
+ }
160
+ }
161
+
162
+ /** True when `dirRel` is a directory edge under its parent in `snapshot`. */
163
+ function directoryHasParentEdge(
164
+ snapshot: WatcherSnapshot,
165
+ dirRel: string
166
+ ): boolean {
167
+ if (dirRel === "") {
168
+ return true;
169
+ }
170
+ const parent = parentWatcherDir(dirRel);
171
+ if (parent === null) {
172
+ return true;
173
+ }
174
+ const base = dirRel.slice(parent === "" ? 0 : parent.length + 1);
175
+ return snapshot.directories.get(parent)?.get(base)?.kind === "directory";
176
+ }
177
+
178
+ /**
179
+ * Choose the dirty directory for a resolved on-disk directory hint.
180
+ * New-relative-to-snapshot directories climb to the nearest known container so
181
+ * parent edges are recorded (no orphan maps).
182
+ */
183
+ function dirtyDirectoryForResolvedHint(
184
+ previous: WatcherSnapshot,
185
+ dirRel: string
186
+ ): string {
187
+ if (dirRel === "" || directoryHasParentEdge(previous, dirRel)) {
188
+ return dirRel;
189
+ }
190
+ let climb: string | null = parentWatcherDir(dirRel);
191
+ while (climb !== null) {
192
+ if (
193
+ climb === "" ||
194
+ directoryHasParentEdge(previous, climb) ||
195
+ previous.directories.has(climb)
196
+ ) {
197
+ return climb;
198
+ }
199
+ climb = parentWatcherDir(climb);
200
+ }
201
+ return "";
202
+ }
203
+
204
+ /**
205
+ * Resolve dirty directories from untrusted hints, then diff against `previous`.
206
+ * Invalid hints are skipped; all-invalid yields an empty ok diff.
207
+ * Scan/metadata failure never advances the snapshot.
208
+ */
209
+ export async function reconcileWatcherHints(
210
+ rootAbs: string,
211
+ previous: WatcherSnapshot,
212
+ hints: readonly string[],
213
+ options: WatcherSnapshotOptions = {}
214
+ ): Promise<WatcherSnapshotDiffResult> {
215
+ const clock = options.clock ?? defaultClock;
216
+ const started = clock.nowMs();
217
+ const dirty = new Set<string>();
218
+
219
+ for (const hint of hints) {
220
+ const resolved = await resolveWatcherDirtyDirectory(rootAbs, hint, options);
221
+ if (resolved.status === "invalid") {
222
+ continue;
223
+ }
224
+ if (resolved.status === "fallback") {
225
+ return {
226
+ status: "fallback",
227
+ reason: "scan_failed",
228
+ discoveryMs: clock.nowMs() - started,
229
+ cause: resolved.cause,
230
+ };
231
+ }
232
+ dirty.add(dirtyDirectoryForResolvedHint(previous, resolved.directory));
233
+ }
234
+
235
+ if (dirty.size === 0) {
236
+ return {
237
+ status: "ok",
238
+ candidates: [],
239
+ removals: [],
240
+ nextSnapshot: previous,
241
+ discoveryMs: clock.nowMs() - started,
242
+ };
243
+ }
244
+
245
+ return diffWatcherSnapshot(rootAbs, previous, [...dirty], options);
246
+ }