@gmickel/gno 1.34.6 → 1.35.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 (38) hide show
  1. package/README.md +12 -1
  2. package/browser-extension/artifacts/{gno-browser-clipper-v1.34.6.zip → gno-browser-clipper-v1.35.0.zip} +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.35.0.zip.sha256 +1 -0
  4. package/browser-extension/dist/manifest.json +1 -1
  5. package/package.json +1 -1
  6. package/spec/cli.md +37 -0
  7. package/spec/output-schemas/mcp-job-status.schema.json +6 -2
  8. package/src/config/index.ts +4 -0
  9. package/src/config/types.ts +14 -0
  10. package/src/core/path-rules.ts +34 -0
  11. package/src/ingestion/index.ts +21 -0
  12. package/src/ingestion/record-container.ts +23 -1
  13. package/src/ingestion/source-availability/darwin-io.ts +295 -0
  14. package/src/ingestion/source-availability/darwin-path.ts +58 -0
  15. package/src/ingestion/source-availability/directory.ts +402 -0
  16. package/src/ingestion/source-availability/index.ts +74 -0
  17. package/src/ingestion/source-availability/readers.ts +360 -0
  18. package/src/ingestion/source-availability/resolve.ts +28 -0
  19. package/src/ingestion/source-availability/types.ts +170 -0
  20. package/src/ingestion/sync.ts +197 -24
  21. package/src/ingestion/types.ts +45 -3
  22. package/src/ingestion/walker.ts +263 -5
  23. package/src/serve/public/globals.built.css +1 -1
  24. package/src/serve/watch-reconciliation-fallback-disk.ts +239 -100
  25. package/src/serve/watch-reconciliation-fallback.ts +35 -5
  26. package/src/serve/watch-reconciliation-shared.ts +8 -3
  27. package/src/serve/watch-reconciliation.ts +7 -0
  28. package/src/serve/watch-service-flush.ts +10 -0
  29. package/src/serve/watch-service-lifecycle.ts +2 -0
  30. package/src/serve/watch-service-snapshot.ts +27 -3
  31. package/src/serve/watch-service.ts +1 -0
  32. package/src/serve/watch-snapshot-availability.ts +51 -0
  33. package/src/serve/watch-snapshot-handles.ts +117 -37
  34. package/src/serve/watch-snapshot-libc.ts +141 -22
  35. package/src/serve/watch-snapshot-ops.ts +151 -9
  36. package/src/serve/watch-snapshot-scan.ts +3 -0
  37. package/src/serve/watch-snapshot-types.ts +45 -3
  38. package/browser-extension/artifacts/gno-browser-clipper-v1.34.6.zip.sha256 +0 -1
@@ -5,6 +5,7 @@
5
5
  * @module src/serve/watch-snapshot-ops
6
6
  */
7
7
 
8
+ import type { DirectoryAvailabilityPort } from "../ingestion/source-availability";
8
9
  import type {
9
10
  DiffWorkResult,
10
11
  SnapshotEntryFingerprint,
@@ -15,12 +16,15 @@ import type {
15
16
  WatcherSnapshotOptions,
16
17
  } from "./watch-snapshot-types";
17
18
 
19
+ import {
20
+ directoryAllowsDescent,
21
+ readAvailableDirectory,
22
+ } from "./watch-snapshot-availability";
18
23
  import {
19
24
  cloneDirectoryMaps,
20
25
  defaultClock,
21
26
  defaultFs,
22
27
  freezeSnapshot,
23
- readDirectChildren,
24
28
  removeSubtreeFromMaps,
25
29
  setDirectoryEntries,
26
30
  type MutableSnapshotMaps,
@@ -46,11 +50,25 @@ export async function buildWatcherSnapshot(
46
50
  const fs = options.fs ?? defaultFs;
47
51
  const clock = options.clock ?? defaultClock;
48
52
  const ceiling = options.entryCeiling ?? WATCHER_SNAPSHOT_ENTRY_CEILING;
53
+ const directoryAvailability = options.directoryAvailability;
49
54
  const started = clock.nowMs();
50
55
 
56
+ if (!(await directoryAllowsDescent(rootAbs, "", directoryAvailability))) {
57
+ // Root itself is unproven — fail closed without claiming an empty tree.
58
+ return {
59
+ status: "fallback",
60
+ reason: "scan_failed",
61
+ durationMs: clock.nowMs() - started,
62
+ cause: new Error(
63
+ "Collection root availability is unproven; refusing snapshot descent"
64
+ ),
65
+ };
66
+ }
67
+
51
68
  const state: MutableSnapshotMaps = {
52
69
  directories: new Map(),
53
70
  entryCount: 0,
71
+ unprovenSubtrees: new Set(),
54
72
  };
55
73
  // Cursor-index queue avoids O(n) shift on large directory sets.
56
74
  const queue: string[] = [""];
@@ -68,7 +86,27 @@ export async function buildWatcherSnapshot(
68
86
  durationMs: clock.nowMs() - started,
69
87
  };
70
88
  }
71
- const scanned = await readDirectChildren(rootAbs, dirRel, fs, remaining);
89
+ const scanned = await readAvailableDirectory(
90
+ rootAbs,
91
+ dirRel,
92
+ fs,
93
+ remaining,
94
+ directoryAvailability
95
+ );
96
+ if (scanned.status === "unproven") {
97
+ if (dirRel === "") {
98
+ return {
99
+ status: "fallback",
100
+ reason: "scan_failed",
101
+ durationMs: clock.nowMs() - started,
102
+ cause: new Error(
103
+ "Collection root availability changed before snapshot enumeration"
104
+ ),
105
+ };
106
+ }
107
+ state.unprovenSubtrees.add(dirRel);
108
+ continue;
109
+ }
72
110
  if (scanned.status === "missing") {
73
111
  if (dirRel === "") {
74
112
  return {
@@ -107,7 +145,19 @@ export async function buildWatcherSnapshot(
107
145
  for (const [name, fingerprint] of scanned.entries) {
108
146
  // No-follow: never recurse through symlinks (even if they point inside root).
109
147
  if (fingerprint.kind === "directory") {
110
- queue.push(joinWatcherRelPath(dirRel, name));
148
+ const childRel = joinWatcherRelPath(dirRel, name);
149
+ if (
150
+ !(await directoryAllowsDescent(
151
+ rootAbs,
152
+ childRel,
153
+ directoryAvailability
154
+ ))
155
+ ) {
156
+ // Refuse descent/enumeration; leave subtree absent from this build.
157
+ state.unprovenSubtrees.add(childRel);
158
+ continue;
159
+ }
160
+ queue.push(childRel);
111
161
  }
112
162
  }
113
163
  }
@@ -138,6 +188,7 @@ export async function diffWatcherSnapshot(
138
188
  const clock = options.clock ?? defaultClock;
139
189
  const ceiling = options.entryCeiling ?? WATCHER_SNAPSHOT_ENTRY_CEILING;
140
190
  const mapHooks = options.mapHooks;
191
+ const directoryAvailability = options.directoryAvailability;
141
192
  const started = clock.nowMs();
142
193
 
143
194
  const normalizedDirs: string[] = [];
@@ -179,19 +230,45 @@ export async function diffWatcherSnapshot(
179
230
  }
180
231
  };
181
232
 
233
+ const subtreeInventoryIsUnproven = (dirRel: string): boolean => {
234
+ const prefix = `${dirRel}/`;
235
+ for (const unproven of nextState.unprovenSubtrees) {
236
+ if (unproven === dirRel || unproven.startsWith(prefix)) {
237
+ return true;
238
+ }
239
+ }
240
+ return false;
241
+ };
242
+
182
243
  const diffDirectory = async (dirRel: string): Promise<DiffWorkResult> => {
183
244
  if (visited.has(dirRel)) {
184
245
  return { status: "ok" };
185
246
  }
186
247
  visited.add(dirRel);
187
248
 
249
+ if (
250
+ !(await directoryAllowsDescent(rootAbs, dirRel, directoryAvailability))
251
+ ) {
252
+ // Dirty target is unproven — keep prior subtree, prove nothing.
253
+ return { status: "ok" };
254
+ }
255
+
188
256
  // Replacement frees prior slots for this directory map before the new scan.
189
257
  const previousSize = nextState.directories.get(dirRel)?.size ?? 0;
190
258
  const remaining = ceiling - nextState.entryCount + previousSize;
191
259
  if (remaining < 0) {
192
260
  return { status: "overflow" };
193
261
  }
194
- const scanned = await readDirectChildren(rootAbs, dirRel, fs, remaining);
262
+ const scanned = await readAvailableDirectory(
263
+ rootAbs,
264
+ dirRel,
265
+ fs,
266
+ remaining,
267
+ directoryAvailability
268
+ );
269
+ if (scanned.status === "unproven") {
270
+ return { status: "ok" };
271
+ }
195
272
  if (scanned.status === "missing") {
196
273
  // Open-time ENOENT/ENOTDIR is never directory-deletion proof.
197
274
  // A nested dirty target may have raced into a file/symlink/other after
@@ -217,6 +294,7 @@ export async function diffWatcherSnapshot(
217
294
  new Map<string, SnapshotEntryFingerprint>();
218
295
  const newEntries = scanned.entries;
219
296
  setDirectoryEntries(nextState, dirRel, new Map(newEntries));
297
+ nextState.unprovenSubtrees.delete(dirRel);
220
298
 
221
299
  if (nextState.entryCount > ceiling) {
222
300
  return { status: "overflow" };
@@ -232,6 +310,9 @@ export async function diffWatcherSnapshot(
232
310
  // Removed entry: expand prior subtree for directories; files/symlinks directly.
233
311
  // `other` was never an indexable source — drop fingerprint only.
234
312
  if (oldFp.kind === "directory") {
313
+ if (subtreeInventoryIsUnproven(childRel)) {
314
+ return { status: "unproven_subtree" };
315
+ }
235
316
  recordSubtreeRemovals(childRel);
236
317
  } else if (isWatcherSourceKind(oldFp.kind)) {
237
318
  removals.add(childRel);
@@ -242,13 +323,24 @@ export async function diffWatcherSnapshot(
242
323
  if (newFp && !oldFp) {
243
324
  // Added entry. Candidates are file/symlink only — ignore new FIFO/etc.
244
325
  if (newFp.kind === "directory") {
326
+ if (
327
+ !(await directoryAllowsDescent(
328
+ rootAbs,
329
+ childRel,
330
+ directoryAvailability
331
+ ))
332
+ ) {
333
+ // Unproven new directory: keep fingerprint, do not enumerate.
334
+ continue;
335
+ }
245
336
  const built = await scanNewSubtree(
246
337
  rootAbs,
247
338
  childRel,
248
339
  fs,
249
340
  nextState,
250
341
  candidates,
251
- ceiling
342
+ ceiling,
343
+ directoryAvailability
252
344
  );
253
345
  if (built.status !== "ok") {
254
346
  return built;
@@ -264,6 +356,9 @@ export async function diffWatcherSnapshot(
264
356
  // Changed entry — handle kind transitions with the other-kind contract.
265
357
  if (oldFp.kind === "directory" && newFp.kind !== "directory") {
266
358
  // Directory → file/symlink/other: expand nested indexable removals.
359
+ if (subtreeInventoryIsUnproven(childRel)) {
360
+ return { status: "unproven_subtree" };
361
+ }
267
362
  recordSubtreeRemovals(childRel);
268
363
  if (isWatcherSourceKind(newFp.kind)) {
269
364
  candidates.add(childRel);
@@ -278,13 +373,23 @@ export async function diffWatcherSnapshot(
278
373
  if (isWatcherSourceKind(oldFp.kind)) {
279
374
  removals.add(childRel);
280
375
  }
376
+ if (
377
+ !(await directoryAllowsDescent(
378
+ rootAbs,
379
+ childRel,
380
+ directoryAvailability
381
+ ))
382
+ ) {
383
+ continue;
384
+ }
281
385
  const built = await scanNewSubtree(
282
386
  rootAbs,
283
387
  childRel,
284
388
  fs,
285
389
  nextState,
286
390
  candidates,
287
- ceiling
391
+ ceiling,
392
+ directoryAvailability
288
393
  );
289
394
  if (built.status !== "ok") {
290
395
  return built;
@@ -293,7 +398,17 @@ export async function diffWatcherSnapshot(
293
398
  }
294
399
 
295
400
  if (newFp.kind === "directory") {
296
- // Directory → directory (metadata change): recurse.
401
+ // Directory → directory (metadata change): recurse only when available.
402
+ if (
403
+ !(await directoryAllowsDescent(
404
+ rootAbs,
405
+ childRel,
406
+ directoryAvailability
407
+ ))
408
+ ) {
409
+ // Preserve prior subtree under unproven directory — do not re-scan.
410
+ continue;
411
+ }
297
412
  const nested = await diffDirectory(childRel);
298
413
  if (nested.status !== "ok") {
299
414
  return nested;
@@ -358,8 +473,14 @@ async function scanNewSubtree(
358
473
  fs: WatcherSnapshotFs,
359
474
  state: MutableSnapshotMaps,
360
475
  candidates: Set<string>,
361
- ceiling: number
476
+ ceiling: number,
477
+ directoryAvailability?: DirectoryAvailabilityPort
362
478
  ): Promise<DiffWorkResult> {
479
+ if (!(await directoryAllowsDescent(rootAbs, dirRel, directoryAvailability))) {
480
+ // Keep the directory fingerprint from the parent listing; do not enumerate.
481
+ state.unprovenSubtrees.add(dirRel);
482
+ return { status: "ok" };
483
+ }
363
484
  const queue = [dirRel];
364
485
  let head = 0;
365
486
  while (head < queue.length) {
@@ -370,7 +491,17 @@ async function scanNewSubtree(
370
491
  if (remaining < 0) {
371
492
  return { status: "overflow" };
372
493
  }
373
- const scanned = await readDirectChildren(rootAbs, current, fs, remaining);
494
+ const scanned = await readAvailableDirectory(
495
+ rootAbs,
496
+ current,
497
+ fs,
498
+ remaining,
499
+ directoryAvailability
500
+ );
501
+ if (scanned.status === "unproven") {
502
+ state.unprovenSubtrees.add(current);
503
+ continue;
504
+ }
374
505
  if (scanned.status === "missing") {
375
506
  // New directory vanished while scanning — fail closed.
376
507
  return {
@@ -382,12 +513,23 @@ async function scanNewSubtree(
382
513
  return scanned;
383
514
  }
384
515
  setDirectoryEntries(state, current, new Map(scanned.entries));
516
+ state.unprovenSubtrees.delete(current);
385
517
  if (state.entryCount > ceiling) {
386
518
  return { status: "overflow" };
387
519
  }
388
520
  for (const [name, fingerprint] of scanned.entries) {
389
521
  const childRel = joinWatcherRelPath(current, name);
390
522
  if (fingerprint.kind === "directory") {
523
+ if (
524
+ !(await directoryAllowsDescent(
525
+ rootAbs,
526
+ childRel,
527
+ directoryAvailability
528
+ ))
529
+ ) {
530
+ state.unprovenSubtrees.add(childRel);
531
+ continue;
532
+ }
391
533
  queue.push(childRel);
392
534
  } else if (isWatcherSourceKind(fingerprint.kind)) {
393
535
  candidates.add(childRel);
@@ -35,6 +35,7 @@ export const defaultFs: WatcherSnapshotFs = createDefaultWatcherFs();
35
35
  export interface MutableSnapshotMaps {
36
36
  directories: Map<string, Map<string, SnapshotEntryFingerprint>>;
37
37
  entryCount: number;
38
+ unprovenSubtrees: Set<string>;
38
39
  }
39
40
 
40
41
  export function cloneDirectoryMaps(
@@ -47,6 +48,7 @@ export function cloneDirectoryMaps(
47
48
  return {
48
49
  directories,
49
50
  entryCount: source.entryCount,
51
+ unprovenSubtrees: new Set(source.unprovenSubtrees ?? []),
50
52
  };
51
53
  }
52
54
 
@@ -61,6 +63,7 @@ export function freezeSnapshot(state: MutableSnapshotMaps): WatcherSnapshot {
61
63
  return {
62
64
  directories: frozen,
63
65
  entryCount: state.entryCount,
66
+ unprovenSubtrees: new Set(state.unprovenSubtrees),
64
67
  };
65
68
  }
66
69
 
@@ -7,6 +7,8 @@
7
7
  // node:path — Bun has no path utilities
8
8
  import { isAbsolute } from "node:path";
9
9
 
10
+ import type { DirectoryAvailabilityPort } from "../ingestion/source-availability";
11
+
10
12
  /** Fixed service-wide maximum entries retained in one collection snapshot. */
11
13
  export const WATCHER_SNAPSHOT_ENTRY_CEILING = 100_000;
12
14
 
@@ -44,6 +46,12 @@ export interface WatcherSnapshot {
44
46
  >;
45
47
  /** Total no-follow entries across every directory map. */
46
48
  readonly entryCount: number;
49
+ /**
50
+ * Directory roots observed in a parent listing but not enumerated because
51
+ * source availability was unproven. Their stored descendant inventory is
52
+ * incomplete, so a later proven removal requires full reconciliation.
53
+ */
54
+ readonly unprovenSubtrees?: ReadonlySet<string>;
47
55
  }
48
56
 
49
57
  /** Injectable lstat view. `unreliable` forces the correctness-preserving fallback. */
@@ -120,6 +128,26 @@ export interface WatcherSnapshotFs {
120
128
 
121
129
  /** Deterministic close; safe to call once per open. */
122
130
  closeDir(handle: WatcherDirHandle): Promise<void>;
131
+
132
+ /**
133
+ * Optional production-grade synchronous scan used only while a process-wide
134
+ * no-materialization policy is active. Missing support fails local mode closed.
135
+ */
136
+ readDirectChildrenSync?: (
137
+ rootAbs: string,
138
+ dirRel: string,
139
+ maxEntries: number
140
+ ) =>
141
+ | { status: "present"; entries: Map<string, SnapshotEntryFingerprint> }
142
+ | { status: "missing" }
143
+ | ScanFailure;
144
+
145
+ /** Synchronous anchored child lstat for guarded local-mode presence checks. */
146
+ lstatChildByRelSync?: (
147
+ rootAbs: string,
148
+ parentRel: string,
149
+ name: string
150
+ ) => WatcherSnapshotStat;
123
151
  }
124
152
 
125
153
  export interface WatcherSnapshotClock {
@@ -135,7 +163,8 @@ export interface SnapshotMapHooks {
135
163
  export type SnapshotFallbackReason =
136
164
  | "overflow"
137
165
  | "scan_failed"
138
- | "unreliable_metadata";
166
+ | "unreliable_metadata"
167
+ | "unproven_subtree";
139
168
 
140
169
  export type WatcherSnapshotBuildResult =
141
170
  | {
@@ -191,6 +220,12 @@ export interface WatcherSnapshotOptions {
191
220
  entryCeiling?: number;
192
221
  /** Map-visit instrumentation (tests / complexity regression). */
193
222
  mapHooks?: SnapshotMapHooks;
223
+ /**
224
+ * Optional directory-availability classifier for local-mode collections.
225
+ * When set, dataless / availability-unknown directories are not descended;
226
+ * previously observed subtrees are preserved rather than proven removed.
227
+ */
228
+ directoryAvailability?: DirectoryAvailabilityPort;
194
229
  }
195
230
 
196
231
  export type ScanFailure =
@@ -198,7 +233,10 @@ export type ScanFailure =
198
233
  | { status: "scan_failed"; cause: unknown }
199
234
  | { status: "unreliable_metadata" };
200
235
 
201
- export type DiffWorkResult = { status: "ok" } | ScanFailure;
236
+ export type DiffWorkResult =
237
+ | { status: "ok" }
238
+ | ScanFailure
239
+ | { status: "unproven_subtree" };
202
240
 
203
241
  export function toBigInt(value: number | bigint | undefined): bigint | null {
204
242
  if (value === undefined) {
@@ -332,7 +370,11 @@ export function joinWatcherRelPath(dir: string, name: string): string {
332
370
  }
333
371
 
334
372
  export function createEmptyWatcherSnapshot(): WatcherSnapshot {
335
- return { directories: new Map(), entryCount: 0 };
373
+ return {
374
+ directories: new Map(),
375
+ entryCount: 0,
376
+ unprovenSubtrees: new Set(),
377
+ };
336
378
  }
337
379
 
338
380
  export function isMissingFsError(error: unknown): boolean {
@@ -1 +0,0 @@
1
- 76e1e560efa69f780e5e61130008e863cf8288a8ffe5cd82e16b48608c6c8578 gno-browser-clipper-v1.34.6.zip