@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,297 @@
1
+ /**
2
+ * Identity-aware no-follow directory scanning and mutable snapshot maps.
3
+ *
4
+ * @module src/serve/watch-snapshot-scan
5
+ */
6
+
7
+ import type {
8
+ ScanFailure,
9
+ SnapshotEntryFingerprint,
10
+ SnapshotMapHooks,
11
+ WatcherDirHandle,
12
+ WatcherSnapshot,
13
+ WatcherSnapshotClock,
14
+ WatcherSnapshotFs,
15
+ WatcherSnapshotStat,
16
+ } from "./watch-snapshot-types";
17
+
18
+ import { createDefaultWatcherFs } from "./watch-snapshot-handles";
19
+ import {
20
+ fingerprintFromStat,
21
+ isMissingFsError,
22
+ isWatcherSourceKind,
23
+ joinWatcherRelPath,
24
+ } from "./watch-snapshot-types";
25
+
26
+ export { createPathBackedWatcherFs } from "./watch-snapshot-handles";
27
+
28
+ export const defaultClock: WatcherSnapshotClock = {
29
+ nowMs: () => performance.now(),
30
+ };
31
+
32
+ export const defaultFs: WatcherSnapshotFs = createDefaultWatcherFs();
33
+
34
+ /** Mutable hierarchical maps with incremental entry accounting. */
35
+ export interface MutableSnapshotMaps {
36
+ directories: Map<string, Map<string, SnapshotEntryFingerprint>>;
37
+ entryCount: number;
38
+ }
39
+
40
+ export function cloneDirectoryMaps(
41
+ source: WatcherSnapshot
42
+ ): MutableSnapshotMaps {
43
+ const directories = new Map<string, Map<string, SnapshotEntryFingerprint>>();
44
+ for (const [dir, entries] of source.directories) {
45
+ directories.set(dir, new Map(entries));
46
+ }
47
+ return {
48
+ directories,
49
+ entryCount: source.entryCount,
50
+ };
51
+ }
52
+
53
+ export function freezeSnapshot(state: MutableSnapshotMaps): WatcherSnapshot {
54
+ const frozen = new Map<
55
+ string,
56
+ ReadonlyMap<string, SnapshotEntryFingerprint>
57
+ >();
58
+ for (const [dir, entries] of state.directories) {
59
+ frozen.set(dir, entries);
60
+ }
61
+ return {
62
+ directories: frozen,
63
+ entryCount: state.entryCount,
64
+ };
65
+ }
66
+
67
+ /** Replace one directory's entry map; O(1) entryCount update. */
68
+ export function setDirectoryEntries(
69
+ state: MutableSnapshotMaps,
70
+ dirRel: string,
71
+ entries: Map<string, SnapshotEntryFingerprint>
72
+ ): void {
73
+ const previous = state.directories.get(dirRel);
74
+ const previousSize = previous?.size ?? 0;
75
+ state.directories.set(dirRel, entries);
76
+ state.entryCount += entries.size - previousSize;
77
+ }
78
+
79
+ /**
80
+ * Hierarchical O(subtree-size) removal using stored child relationships.
81
+ * Single pass: collect non-directory paths and delete maps — no full-map
82
+ * prefix scan and no separate collect-then-remove phases.
83
+ */
84
+ export function removeSubtreeFromMaps(
85
+ state: MutableSnapshotMaps,
86
+ dirRel: string,
87
+ hooks?: SnapshotMapHooks
88
+ ): string[] {
89
+ const removedCandidates: string[] = [];
90
+ const stack: string[] = [dirRel];
91
+
92
+ while (stack.length > 0) {
93
+ const dir = stack.pop() as string;
94
+ hooks?.onDirectoryMapVisit?.();
95
+ const entries = state.directories.get(dir);
96
+ if (!entries) {
97
+ continue;
98
+ }
99
+ for (const [name, fingerprint] of entries) {
100
+ const childRel = joinWatcherRelPath(dir, name);
101
+ if (fingerprint.kind === "directory") {
102
+ stack.push(childRel);
103
+ } else if (isWatcherSourceKind(fingerprint.kind)) {
104
+ // Only file/symlink sources are indexable; ignore FIFO/socket/device.
105
+ removedCandidates.push(childRel);
106
+ }
107
+ }
108
+ state.entryCount -= entries.size;
109
+ state.directories.delete(dir);
110
+ }
111
+
112
+ return removedCandidates;
113
+ }
114
+
115
+ /**
116
+ * Hierarchical collect of file/symlink source paths under a stored directory.
117
+ * O(subtree-size) via child relationships — not a full-map prefix scan.
118
+ * Special `other` entries are never collected (never indexed as sources).
119
+ */
120
+ export function collectSnapshotFilesUnder(
121
+ directories: ReadonlyMap<
122
+ string,
123
+ ReadonlyMap<string, SnapshotEntryFingerprint>
124
+ >,
125
+ dirRel: string,
126
+ hooks?: SnapshotMapHooks
127
+ ): string[] {
128
+ const out: string[] = [];
129
+ const stack: string[] = [dirRel];
130
+ while (stack.length > 0) {
131
+ const dir = stack.pop() as string;
132
+ hooks?.onDirectoryMapVisit?.();
133
+ const entries = directories.get(dir);
134
+ if (!entries) {
135
+ continue;
136
+ }
137
+ for (const [name, fingerprint] of entries) {
138
+ const childRel = joinWatcherRelPath(dir, name);
139
+ if (fingerprint.kind === "directory") {
140
+ stack.push(childRel);
141
+ } else if (isWatcherSourceKind(fingerprint.kind)) {
142
+ out.push(childRel);
143
+ }
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /**
150
+ * Open a collection-relative directory by walking components from the root
151
+ * handle. Never opens a full joined path that could traverse intermediate
152
+ * symlinks.
153
+ */
154
+ export async function openDirByRel(
155
+ rootAbs: string,
156
+ dirRel: string,
157
+ fs: WatcherSnapshotFs
158
+ ): Promise<
159
+ | { status: "ok"; handle: WatcherDirHandle }
160
+ | { status: "missing" }
161
+ | ScanFailure
162
+ > {
163
+ if (!fs.supportsAnchoredHandles) {
164
+ return {
165
+ status: "scan_failed",
166
+ cause: new Error(
167
+ "Anchored no-follow directory handles unavailable; refusing path-based scan"
168
+ ),
169
+ };
170
+ }
171
+
172
+ let handle: WatcherDirHandle;
173
+ try {
174
+ handle = await fs.openDir(rootAbs);
175
+ } catch (cause) {
176
+ if (isMissingFsError(cause)) {
177
+ return { status: "missing" };
178
+ }
179
+ return { status: "scan_failed", cause };
180
+ }
181
+
182
+ if (dirRel === "") {
183
+ return { status: "ok", handle };
184
+ }
185
+
186
+ const segments = dirRel.split("/");
187
+ for (const segment of segments) {
188
+ let child: WatcherDirHandle;
189
+ try {
190
+ // Reject non-directory / symlink components by requiring openChildDir.
191
+ child = await fs.openChildDir(handle, segment);
192
+ } catch (cause) {
193
+ await fs.closeDir(handle);
194
+ if (isMissingFsError(cause)) {
195
+ return { status: "missing" };
196
+ }
197
+ return { status: "scan_failed", cause };
198
+ }
199
+ await fs.closeDir(handle);
200
+ handle = child;
201
+ }
202
+ return { status: "ok", handle };
203
+ }
204
+
205
+ /**
206
+ * Enumerate direct children via an anchored directory handle.
207
+ * Child metadata is resolved relative to the handle — a path swap after open
208
+ * cannot redirect lstat outside the pinned directory.
209
+ *
210
+ * `maxEntries` is the remaining entry budget for this directory map. Enumeration
211
+ * and stats stop after observing `maxEntries + 1` children so overflow is proven
212
+ * without materializing an unbounded name/stat map.
213
+ */
214
+ export async function readDirectChildren(
215
+ rootAbs: string,
216
+ dirRel: string,
217
+ fs: WatcherSnapshotFs,
218
+ maxEntries: number
219
+ ): Promise<
220
+ | { status: "present"; entries: Map<string, SnapshotEntryFingerprint> }
221
+ | { status: "missing" }
222
+ | ScanFailure
223
+ > {
224
+ if (!fs.supportsAnchoredHandles) {
225
+ return {
226
+ status: "scan_failed",
227
+ cause: new Error(
228
+ "Anchored no-follow directory handles unavailable; refusing path-based scan"
229
+ ),
230
+ };
231
+ }
232
+ if (!Number.isInteger(maxEntries) || maxEntries < 0) {
233
+ return {
234
+ status: "scan_failed",
235
+ cause: new Error("maxEntries must be a non-negative integer"),
236
+ };
237
+ }
238
+
239
+ const opened = await openDirByRel(rootAbs, dirRel, fs);
240
+ if (opened.status !== "ok") {
241
+ return opened;
242
+ }
243
+ const { handle } = opened;
244
+
245
+ try {
246
+ let listed;
247
+ try {
248
+ // Cap names at remaining budget; maxEntries+1th name → overflow.
249
+ listed = await fs.readDir(handle, maxEntries);
250
+ } catch (cause) {
251
+ if (isMissingFsError(cause)) {
252
+ return { status: "missing" };
253
+ }
254
+ return { status: "scan_failed", cause };
255
+ }
256
+ if (listed.status === "overflow") {
257
+ return { status: "overflow" };
258
+ }
259
+
260
+ const names = listed.names;
261
+ const entries = new Map<string, SnapshotEntryFingerprint>();
262
+ // Stable order keeps overflow selection deterministic across platforms.
263
+ names.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
264
+ for (const name of names) {
265
+ if (name === "" || name === "." || name === "..") {
266
+ continue;
267
+ }
268
+ if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
269
+ return {
270
+ status: "scan_failed",
271
+ cause: new Error(`Invalid directory entry name: ${name}`),
272
+ };
273
+ }
274
+ // Defense in depth: never stat/map more than the remaining budget.
275
+ if (entries.size >= maxEntries) {
276
+ return { status: "overflow" };
277
+ }
278
+ let stat: WatcherSnapshotStat;
279
+ try {
280
+ stat = await fs.lstatChild(handle, name);
281
+ } catch (cause) {
282
+ // Observed-then-missing (ENOENT/ENOTDIR after readdir listed the name)
283
+ // must fail closed: silently skipping would accept a partial directory
284
+ // image and can prove false removals against the previous snapshot.
285
+ return { status: "scan_failed", cause };
286
+ }
287
+ const fingerprinted = fingerprintFromStat(stat);
288
+ if (!fingerprinted.ok) {
289
+ return { status: "unreliable_metadata" };
290
+ }
291
+ entries.set(name, fingerprinted.fingerprint);
292
+ }
293
+ return { status: "present", entries };
294
+ } finally {
295
+ await fs.closeDir(handle);
296
+ }
297
+ }
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Watcher snapshot types, fingerprints, and pure path helpers.
3
+ *
4
+ * @module src/serve/watch-snapshot-types
5
+ */
6
+
7
+ // node:path — Bun has no path utilities
8
+ import { isAbsolute } from "node:path";
9
+
10
+ /** Fixed service-wide maximum entries retained in one collection snapshot. */
11
+ export const WATCHER_SNAPSHOT_ENTRY_CEILING = 100_000;
12
+
13
+ /**
14
+ * Result of a bounded directory enumeration.
15
+ * Overflow means more than `maxNames` child names were observed; callers must
16
+ * not treat a partial name list as a successful directory image.
17
+ */
18
+ export type WatcherReadDirResult =
19
+ | { status: "ok"; names: string[] }
20
+ | { status: "overflow" };
21
+
22
+ export type SnapshotEntryKind = "file" | "directory" | "symlink" | "other";
23
+
24
+ /**
25
+ * No-follow entry fingerprint used only for candidate discovery.
26
+ * Equality of fingerprints means "not a discovery candidate", never
27
+ * "content is unchanged".
28
+ */
29
+ export interface SnapshotEntryFingerprint {
30
+ kind: SnapshotEntryKind;
31
+ device: bigint;
32
+ inode: bigint;
33
+ size: number;
34
+ mtimeNs: bigint;
35
+ ctimeNs: bigint;
36
+ }
37
+
38
+ /** Hierarchical snapshot indexed by directory (POSIX collection-relative). */
39
+ export interface WatcherSnapshot {
40
+ /** dirRelPath → (entry name → fingerprint). `""` is the collection root. */
41
+ readonly directories: ReadonlyMap<
42
+ string,
43
+ ReadonlyMap<string, SnapshotEntryFingerprint>
44
+ >;
45
+ /** Total no-follow entries across every directory map. */
46
+ readonly entryCount: number;
47
+ }
48
+
49
+ /** Injectable lstat view. `unreliable` forces the correctness-preserving fallback. */
50
+ export interface WatcherSnapshotStat {
51
+ isFile(): boolean;
52
+ isDirectory(): boolean;
53
+ isSymbolicLink(): boolean;
54
+ dev: number | bigint;
55
+ ino: number | bigint;
56
+ size: number | bigint;
57
+ mtimeNs?: bigint | number;
58
+ ctimeNs?: bigint | number;
59
+ mtimeMs?: number;
60
+ ctimeMs?: number;
61
+ /** When true, metadata cannot be trusted for discovery. */
62
+ unreliable?: boolean;
63
+ }
64
+
65
+ /**
66
+ * Opaque directory handle for anchored (openat-style) enumeration.
67
+ * Production pins a real directory inode; tests may use path-backed handles.
68
+ */
69
+ export type WatcherDirHandle = {
70
+ readonly __watcherDirHandle: unique symbol;
71
+ };
72
+
73
+ /**
74
+ * Injectable filesystem surface for watcher snapshots.
75
+ *
76
+ * Production must set `supportsAnchoredHandles` and implement handle ops so
77
+ * child metadata is resolved relative to a stable directory fd (no path
78
+ * re-walk after the parent is opened). When handles are unavailable, scans
79
+ * return `scan_failed` / `unreliable_metadata` rather than claiming
80
+ * strict no-follow safety.
81
+ */
82
+ export interface WatcherSnapshotFs {
83
+ /**
84
+ * True only when openDir/readDir/lstatChild/openChildDir/closeDir form a
85
+ * safe anchored scan path for this runtime/platform.
86
+ */
87
+ readonly supportsAnchoredHandles: boolean;
88
+
89
+ /** Open an absolute path as a real directory without following a final symlink. */
90
+ openDir(absPath: string): Promise<WatcherDirHandle>;
91
+
92
+ /**
93
+ * Enumerate direct child names via the open directory handle, capped at
94
+ * `maxNames`. Implementations must stop after observing `maxNames + 1`
95
+ * names (overflow) without storing or returning more, and must not claim
96
+ * success with a truncated list.
97
+ */
98
+ readDir(
99
+ handle: WatcherDirHandle,
100
+ maxNames: number
101
+ ): Promise<WatcherReadDirResult>;
102
+
103
+ /**
104
+ * No-follow lstat of a direct child relative to an open directory handle.
105
+ * Must not re-resolve intermediate path components outside the handle.
106
+ */
107
+ lstatChild(
108
+ handle: WatcherDirHandle,
109
+ name: string
110
+ ): Promise<WatcherSnapshotStat>;
111
+
112
+ /**
113
+ * Open a direct child as a real directory relative to the parent handle
114
+ * (no-follow). Rejects symlink children.
115
+ */
116
+ openChildDir(
117
+ handle: WatcherDirHandle,
118
+ name: string
119
+ ): Promise<WatcherDirHandle>;
120
+
121
+ /** Deterministic close; safe to call once per open. */
122
+ closeDir(handle: WatcherDirHandle): Promise<void>;
123
+ }
124
+
125
+ export interface WatcherSnapshotClock {
126
+ nowMs(): number;
127
+ }
128
+
129
+ /** Test/prod hooks for map-visit complexity instrumentation. */
130
+ export interface SnapshotMapHooks {
131
+ /** Invoked once per directory-map visit during hierarchical subtree walks. */
132
+ onDirectoryMapVisit?: () => void;
133
+ }
134
+
135
+ export type SnapshotFallbackReason =
136
+ | "overflow"
137
+ | "scan_failed"
138
+ | "unreliable_metadata";
139
+
140
+ export type WatcherSnapshotBuildResult =
141
+ | {
142
+ status: "ok";
143
+ snapshot: WatcherSnapshot;
144
+ durationMs: number;
145
+ }
146
+ | {
147
+ status: "fallback";
148
+ reason: SnapshotFallbackReason;
149
+ durationMs: number;
150
+ cause?: unknown;
151
+ };
152
+
153
+ /** True when a snapshot entry kind can be an indexed document source. */
154
+ export function isWatcherSourceKind(
155
+ kind: SnapshotEntryKind
156
+ ): kind is "file" | "symlink" {
157
+ return kind === "file" || kind === "symlink";
158
+ }
159
+
160
+ export type WatcherSnapshotDiffResult =
161
+ | {
162
+ status: "ok";
163
+ /**
164
+ * Present/changed file and symlink paths that still exist and need
165
+ * content-hash consideration (added, edited, or new under a directory).
166
+ * Never includes `other` (FIFO/socket/device) or directories.
167
+ * Does not include proven removals.
168
+ */
169
+ candidates: string[];
170
+ /**
171
+ * Proven removable source paths: deleted files/symlinks, prior files
172
+ * replaced by a directory or other special entry, and expanded nested
173
+ * files under removed or directory→non-directory transitions.
174
+ * Never includes `other` entries (they were never indexed as sources).
175
+ */
176
+ removals: string[];
177
+ nextSnapshot: WatcherSnapshot;
178
+ discoveryMs: number;
179
+ }
180
+ | {
181
+ status: "fallback";
182
+ reason: SnapshotFallbackReason;
183
+ discoveryMs: number;
184
+ cause?: unknown;
185
+ };
186
+
187
+ export interface WatcherSnapshotOptions {
188
+ fs?: WatcherSnapshotFs;
189
+ clock?: WatcherSnapshotClock;
190
+ /** Override the service-wide ceiling (tests only). */
191
+ entryCeiling?: number;
192
+ /** Map-visit instrumentation (tests / complexity regression). */
193
+ mapHooks?: SnapshotMapHooks;
194
+ }
195
+
196
+ export type ScanFailure =
197
+ | { status: "overflow" }
198
+ | { status: "scan_failed"; cause: unknown }
199
+ | { status: "unreliable_metadata" };
200
+
201
+ export type DiffWorkResult = { status: "ok" } | ScanFailure;
202
+
203
+ export function toBigInt(value: number | bigint | undefined): bigint | null {
204
+ if (value === undefined) {
205
+ return null;
206
+ }
207
+ if (typeof value === "bigint") {
208
+ return value;
209
+ }
210
+ if (!Number.isFinite(value)) {
211
+ return null;
212
+ }
213
+ return BigInt(Math.trunc(value));
214
+ }
215
+
216
+ /**
217
+ * Require actual finite nanosecond timestamps. Millisecond fields alone are
218
+ * insufficient for the fast path (no ms→ns fabrication).
219
+ */
220
+ export function nsFromStat(ns: bigint | number | undefined): bigint | null {
221
+ return toBigInt(ns);
222
+ }
223
+
224
+ export function kindOf(stat: WatcherSnapshotStat): SnapshotEntryKind {
225
+ if (stat.isSymbolicLink()) {
226
+ return "symlink";
227
+ }
228
+ if (stat.isDirectory()) {
229
+ return "directory";
230
+ }
231
+ if (stat.isFile()) {
232
+ return "file";
233
+ }
234
+ return "other";
235
+ }
236
+
237
+ export function fingerprintFromStat(
238
+ stat: WatcherSnapshotStat
239
+ ):
240
+ | { ok: true; fingerprint: SnapshotEntryFingerprint }
241
+ | { ok: false; reason: "unreliable_metadata" } {
242
+ if (stat.unreliable) {
243
+ return { ok: false, reason: "unreliable_metadata" };
244
+ }
245
+ const device = toBigInt(stat.dev);
246
+ const inode = toBigInt(stat.ino);
247
+ const sizeValue = toBigInt(stat.size);
248
+ const mtimeNs = nsFromStat(stat.mtimeNs);
249
+ const ctimeNs = nsFromStat(stat.ctimeNs);
250
+ if (
251
+ device === null ||
252
+ inode === null ||
253
+ sizeValue === null ||
254
+ mtimeNs === null ||
255
+ ctimeNs === null
256
+ ) {
257
+ return { ok: false, reason: "unreliable_metadata" };
258
+ }
259
+ // size may exceed Number.MAX_SAFE_INTEGER on huge files; clamp via Number is
260
+ // fine for discovery equality against the same platform read.
261
+ const size =
262
+ sizeValue > BigInt(Number.MAX_SAFE_INTEGER)
263
+ ? Number.MAX_SAFE_INTEGER
264
+ : Number(sizeValue);
265
+ return {
266
+ ok: true,
267
+ fingerprint: {
268
+ kind: kindOf(stat),
269
+ device,
270
+ inode,
271
+ size,
272
+ mtimeNs,
273
+ ctimeNs,
274
+ },
275
+ };
276
+ }
277
+
278
+ export function fingerprintsEqual(
279
+ left: SnapshotEntryFingerprint,
280
+ right: SnapshotEntryFingerprint
281
+ ): boolean {
282
+ return (
283
+ left.kind === right.kind &&
284
+ left.device === right.device &&
285
+ left.inode === right.inode &&
286
+ left.size === right.size &&
287
+ left.mtimeNs === right.mtimeNs &&
288
+ left.ctimeNs === right.ctimeNs
289
+ );
290
+ }
291
+
292
+ /**
293
+ * Normalize an untrusted watcher hint to a POSIX collection-relative path.
294
+ * Returns null for absolute, escaping, empty-invalid, or NUL-bearing values.
295
+ */
296
+ export function normalizeWatcherRelPath(relPath: string): string | null {
297
+ if (relPath.includes("\0")) {
298
+ return null;
299
+ }
300
+ const normalized = relPath.replaceAll("\\", "/");
301
+ if (normalized.startsWith("/") || isAbsolute(relPath)) {
302
+ return null;
303
+ }
304
+ // Windows drive-shaped absolute escapes (`C:/...`) after slash normalization.
305
+ if (/^[A-Za-z]:(\/|$)/.test(normalized)) {
306
+ return null;
307
+ }
308
+ const segments: string[] = [];
309
+ for (const segment of normalized.split("/")) {
310
+ if (segment === "" || segment === ".") {
311
+ continue;
312
+ }
313
+ if (segment === "..") {
314
+ return null;
315
+ }
316
+ segments.push(segment);
317
+ }
318
+ return segments.join("/");
319
+ }
320
+
321
+ /** Parent directory of a POSIX relative path; `null` when path is the root. */
322
+ export function parentWatcherDir(relPath: string): string | null {
323
+ if (relPath === "") {
324
+ return null;
325
+ }
326
+ const index = relPath.lastIndexOf("/");
327
+ return index === -1 ? "" : relPath.slice(0, index);
328
+ }
329
+
330
+ export function joinWatcherRelPath(dir: string, name: string): string {
331
+ return dir === "" ? name : `${dir}/${name}`;
332
+ }
333
+
334
+ export function createEmptyWatcherSnapshot(): WatcherSnapshot {
335
+ return { directories: new Map(), entryCount: 0 };
336
+ }
337
+
338
+ export function isMissingFsError(error: unknown): boolean {
339
+ const code =
340
+ error && typeof error === "object" && "code" in error
341
+ ? String(error.code)
342
+ : "";
343
+ return code === "ENOENT" || code === "ENOTDIR";
344
+ }
345
+
346
+ export function sortPathList(paths: Iterable<string>): string[] {
347
+ return [...paths].sort((left, right) =>
348
+ left < right ? -1 : left > right ? 1 : 0
349
+ );
350
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Watcher-owned hierarchical no-follow filesystem snapshot.
3
+ *
4
+ * Fingerprints identify ambiguous candidates only. They never prove that
5
+ * indexed content is unchanged; exact eligible paths still content-hash.
6
+ *
7
+ * Public contract only — implementation is split across focused modules.
8
+ * This facade re-exports the concrete public surface (not an unrelated barrel).
9
+ *
10
+ * @module src/serve/watch-snapshot
11
+ */
12
+
13
+ export {
14
+ WATCHER_SNAPSHOT_ENTRY_CEILING,
15
+ createEmptyWatcherSnapshot,
16
+ fingerprintsEqual,
17
+ joinWatcherRelPath,
18
+ normalizeWatcherRelPath,
19
+ parentWatcherDir,
20
+ } from "./watch-snapshot-types";
21
+
22
+ export type {
23
+ SnapshotEntryFingerprint,
24
+ SnapshotEntryKind,
25
+ SnapshotFallbackReason,
26
+ SnapshotMapHooks,
27
+ WatcherDirHandle,
28
+ WatcherReadDirResult,
29
+ WatcherSnapshot,
30
+ WatcherSnapshotBuildResult,
31
+ WatcherSnapshotClock,
32
+ WatcherSnapshotDiffResult,
33
+ WatcherSnapshotFs,
34
+ WatcherSnapshotOptions,
35
+ WatcherSnapshotStat,
36
+ } from "./watch-snapshot-types";
37
+
38
+ export {
39
+ buildWatcherSnapshot,
40
+ diffWatcherSnapshot,
41
+ } from "./watch-snapshot-ops";
42
+
43
+ export {
44
+ reconcileWatcherHints,
45
+ resolveWatcherDirtyDirectory,
46
+ } from "./watch-snapshot-resolve";
47
+
48
+ export {
49
+ createPathBackedWatcherFs,
50
+ removeSubtreeFromMaps,
51
+ } from "./watch-snapshot-scan";
@@ -98,4 +98,4 @@ export type {
98
98
  StorePort,
99
99
  StoreResult,
100
100
  } from "./types";
101
- export { err, ok } from "./types";
101
+ export { err, ok, WATCHER_ACTIVE_SOURCE_PATH_MAX } from "./types";