@gmickel/gno 1.34.5 → 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 (49) hide show
  1. package/README.md +22 -1
  2. package/browser-extension/artifacts/{gno-browser-clipper-v1.34.5.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 +4 -1
  6. package/spec/cli.md +43 -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 +565 -108
  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 +359 -0
  25. package/src/serve/watch-reconciliation-fallback.ts +434 -0
  26. package/src/serve/watch-reconciliation-shared.ts +348 -0
  27. package/src/serve/watch-reconciliation.ts +129 -0
  28. package/src/serve/watch-service-events.ts +261 -0
  29. package/src/serve/watch-service-flush-generation.ts +140 -0
  30. package/src/serve/watch-service-flush-helpers.ts +147 -0
  31. package/src/serve/watch-service-flush.ts +443 -0
  32. package/src/serve/watch-service-hosts.ts +109 -0
  33. package/src/serve/watch-service-lifecycle.ts +221 -0
  34. package/src/serve/watch-service-run-flush.ts +236 -0
  35. package/src/serve/watch-service-snapshot.ts +125 -0
  36. package/src/serve/watch-service-state.ts +146 -0
  37. package/src/serve/watch-service.ts +266 -306
  38. package/src/serve/watch-snapshot-availability.ts +51 -0
  39. package/src/serve/watch-snapshot-handles.ts +365 -0
  40. package/src/serve/watch-snapshot-libc.ts +510 -0
  41. package/src/serve/watch-snapshot-ops.ts +541 -0
  42. package/src/serve/watch-snapshot-resolve.ts +246 -0
  43. package/src/serve/watch-snapshot-scan.ts +300 -0
  44. package/src/serve/watch-snapshot-types.ts +392 -0
  45. package/src/serve/watch-snapshot.ts +51 -0
  46. package/src/store/index.ts +1 -1
  47. package/src/store/sqlite/adapter.ts +191 -0
  48. package/src/store/types.ts +66 -0
  49. package/browser-extension/artifacts/gno-browser-clipper-v1.34.5.zip.sha256 +0 -1
@@ -12,6 +12,7 @@ import type {
12
12
  RecordAttachmentInventoryItem,
13
13
  } from "../converters/types";
14
14
  import type { EgressLineage } from "../core/egress-provenance";
15
+ import type { DirectoryAvailabilityPort } from "./source-availability/types";
15
16
 
16
17
  // ─────────────────────────────────────────────────────────────────────────────
17
18
  // Walker Types
@@ -45,14 +46,46 @@ export interface WalkConfig {
45
46
  exclude: string[];
46
47
  /** Max file size in bytes (files larger are skipped) */
47
48
  maxBytes: number;
49
+ /**
50
+ * Source availability mode for this walk.
51
+ * `any` (default) keeps Bun.Glob traversal unchanged.
52
+ * `local` refuses descent into unproven/dataless directories.
53
+ */
54
+ sourceAvailability?: "any" | "local";
55
+ /**
56
+ * Optional injectable directory classifier (tests / SyncService wiring).
57
+ * When omitted, FileWalker builds one from `sourceAvailability`.
58
+ */
59
+ directoryAvailability?: DirectoryAvailabilityPort;
48
60
  }
49
61
 
50
- /** Skipped file entry (for error tracking) */
62
+ /** Skip reasons emitted by the walker (and mirrored into sync receipts). */
63
+ export type WalkSkipReason =
64
+ | "TOO_LARGE"
65
+ | "EXCLUDED"
66
+ | "DATALESS_DIRECTORY"
67
+ | "CLOUD_PLACEHOLDER"
68
+ | "CLOUD_PARTIAL"
69
+ | "SOURCE_AVAILABILITY_UNSUPPORTED"
70
+ | "SOURCE_AVAILABILITY_POLICY_FAILED"
71
+ | "SOURCE_AVAILABILITY_UNKNOWN"
72
+ | "PERMISSION"
73
+ | "NOT_FOUND"
74
+ | "NOT_FILE"
75
+ | "IO_ERROR";
76
+
77
+ /** Skipped file or directory-prefix entry (for error tracking / reconciliation) */
51
78
  export interface SkippedEntry {
52
79
  absPath: string;
53
80
  relPath: string;
54
- reason: "TOO_LARGE" | "EXCLUDED";
81
+ reason: WalkSkipReason;
55
82
  size?: number;
83
+ /**
84
+ * When true, absence of descendants under this prefix is unproven —
85
+ * reconciliation must preserve previously indexed sources.
86
+ */
87
+ unprovenPrefix?: boolean;
88
+ message?: string;
56
89
  }
57
90
 
58
91
  /** Walker port interface */
@@ -146,6 +179,11 @@ export interface SyncOptions {
146
179
  contentTypeRulesFingerprint?: string;
147
180
  /** Internal orchestration flag: defer graph projection to an outer sync. */
148
181
  projectTypedEdges?: boolean;
182
+ /**
183
+ * Optional run-level override for source availability (`any` | `local`).
184
+ * Wins over collection config when set. Distinct from egress policy.
185
+ */
186
+ sourceAvailability?: "any" | "local";
149
187
  }
150
188
 
151
189
  export type ContentTypeSource =
@@ -292,9 +330,12 @@ const TRANSCRIPT_EXTENSION_BY_FORMAT = {
292
330
  */
293
331
  export function collectionToWalkConfig(
294
332
  collection: Collection,
295
- maxBytes: number
333
+ maxBytes: number,
334
+ options?: Pick<SyncOptions, "sourceAvailability">
296
335
  ): WalkConfig {
297
336
  const transcriptFormat = collection.recordAdapters?.transcript?.format;
337
+ const sourceAvailability =
338
+ options?.sourceAvailability ?? collection.sourceAvailability ?? undefined;
298
339
  return {
299
340
  root: collection.path,
300
341
  pattern: collection.pattern,
@@ -304,5 +345,6 @@ export function collectionToWalkConfig(
304
345
  : [],
305
346
  exclude: collection.exclude,
306
347
  maxBytes,
348
+ ...(sourceAvailability ? { sourceAvailability } : {}),
307
349
  };
308
350
  }
@@ -1,16 +1,20 @@
1
1
  /**
2
2
  * File walker implementation.
3
- * Walks collection directories using Bun.Glob with include/exclude filtering.
3
+ * Walks collection directories using Bun.Glob (`any`) or hierarchical
4
+ * availability-aware descent (`local`).
4
5
  *
5
6
  * @module src/ingestion/walker
6
7
  */
7
8
 
8
- // node:fs/promises - Bun has no realpath equivalent
9
- import { realpath } from "node:fs/promises";
9
+ // node:fs - Bun has no synchronous Dirent enumeration for the guarded local walk.
10
+ import { readdirSync } from "node:fs";
11
+ // node:fs/promises - Bun has no realpath equivalent for symlink-safe containment.
12
+ import { lstat, realpath } from "node:fs/promises";
10
13
  // node:path - Bun has no path manipulation module
11
14
  import {
12
15
  extname,
13
16
  isAbsolute,
17
+ join,
14
18
  normalize as normalizePath,
15
19
  relative,
16
20
  resolve,
@@ -20,8 +24,16 @@ import {
20
24
  import type { SkippedEntry, WalkConfig, WalkEntry, WalkerPort } from "./types";
21
25
 
22
26
  import { SUPPORTED_EXTENSIONS } from "../converters/mime";
23
- import { matchesCollectionExclusion } from "../core/path-rules";
27
+ import {
28
+ matchesCollectionExclusion,
29
+ matchesCollectionSubtreeExclusion,
30
+ } from "../core/path-rules";
24
31
  import { isRecordVirtualPath } from "./record-path";
32
+ import {
33
+ createDirectoryAvailability,
34
+ type DirectoryAvailabilityPort,
35
+ isUnprovenDirectoryResult,
36
+ } from "./source-availability";
25
37
 
26
38
  /**
27
39
  * Regex to detect dangerous patterns with parent directory traversal.
@@ -218,8 +230,27 @@ export function matchesWalkPath(
218
230
  );
219
231
  }
220
232
 
233
+ function pushUnprovenDirectorySkip(
234
+ skipped: SkippedEntry[],
235
+ absPath: string,
236
+ relPath: string,
237
+ result: Exclude<
238
+ Awaited<ReturnType<DirectoryAvailabilityPort["classify"]>>,
239
+ { kind: "available" }
240
+ >
241
+ ): void {
242
+ skipped.push({
243
+ absPath,
244
+ relPath,
245
+ reason: result.code,
246
+ unprovenPrefix: true,
247
+ message: result.message,
248
+ });
249
+ }
250
+
221
251
  /**
222
- * File walker implementation using Bun.Glob.
252
+ * File walker implementation using Bun.Glob (`any`) or hierarchical local-mode
253
+ * descent that refuses dataless / availability-unknown directories.
223
254
  *
224
255
  * Security: Validates patterns and ensures all matched files are within
225
256
  * the collection root directory. Files outside root are silently ignored.
@@ -228,6 +259,18 @@ export class FileWalker implements WalkerPort {
228
259
  async walk(config: WalkConfig): Promise<{
229
260
  entries: WalkEntry[];
230
261
  skipped: SkippedEntry[];
262
+ }> {
263
+ const mode = config.sourceAvailability ?? "any";
264
+ if (mode === "local") {
265
+ return this.walkLocal(config);
266
+ }
267
+ return this.walkAny(config);
268
+ }
269
+
270
+ /** Legacy Bun.Glob traversal — behaviorally unchanged for `any`. */
271
+ private async walkAny(config: WalkConfig): Promise<{
272
+ entries: WalkEntry[];
273
+ skipped: SkippedEntry[];
231
274
  }> {
232
275
  const entries: WalkEntry[] = [];
233
276
  const skipped: SkippedEntry[] = [];
@@ -316,6 +359,221 @@ export class FileWalker implements WalkerPort {
316
359
 
317
360
  return { entries, skipped };
318
361
  }
362
+
363
+ /**
364
+ * Local-mode hierarchical walk: classify each directory before descent.
365
+ * Does not add a per-file availability syscall — content recheck stays at
366
+ * the SourceContentReaderPort boundary.
367
+ */
368
+ private async walkLocal(config: WalkConfig): Promise<{
369
+ entries: WalkEntry[];
370
+ skipped: SkippedEntry[];
371
+ }> {
372
+ const entries: WalkEntry[] = [];
373
+ const skipped: SkippedEntry[] = [];
374
+
375
+ for (const pattern of scanPatterns(config.pattern)) {
376
+ const patternError = validatePattern(pattern);
377
+ if (patternError) {
378
+ throw new Error(`Invalid glob pattern: ${patternError}`);
379
+ }
380
+ }
381
+
382
+ const rootAbs = resolve(config.root);
383
+ const classifier =
384
+ config.directoryAvailability ??
385
+ createDirectoryAvailability(config.sourceAvailability ?? "local");
386
+ const configuredRootClassified = await classifier.classify(rootAbs);
387
+ if (isUnprovenDirectoryResult(configuredRootClassified)) {
388
+ pushUnprovenDirectorySkip(skipped, rootAbs, "", configuredRootClassified);
389
+ return { entries, skipped };
390
+ }
391
+
392
+ let rootReal: string;
393
+ try {
394
+ rootReal = await realpath(rootAbs);
395
+ } catch {
396
+ skipped.push({
397
+ absPath: rootAbs,
398
+ relPath: "",
399
+ reason: "SOURCE_AVAILABILITY_UNKNOWN",
400
+ unprovenPrefix: true,
401
+ message:
402
+ "Collection root could not be resolved after availability check",
403
+ });
404
+ return { entries, skipped };
405
+ }
406
+
407
+ if (rootReal !== rootAbs) {
408
+ const canonicalRootClassified = await classifier.classify(rootReal);
409
+ if (isUnprovenDirectoryResult(canonicalRootClassified)) {
410
+ pushUnprovenDirectorySkip(
411
+ skipped,
412
+ rootReal,
413
+ "",
414
+ canonicalRootClassified
415
+ );
416
+ return { entries, skipped };
417
+ }
418
+ }
419
+
420
+ const queue: Array<{ absPath: string; relPath: string }> = [
421
+ { absPath: rootReal, relPath: "" },
422
+ ];
423
+ let head = 0;
424
+
425
+ while (head < queue.length) {
426
+ const dir = queue[head] as { absPath: string; relPath: string };
427
+ head += 1;
428
+
429
+ let dirents;
430
+ try {
431
+ const read = classifier.readDirectory(dir.absPath, () =>
432
+ readdirSync(dir.absPath, { withFileTypes: true })
433
+ );
434
+ if (read.kind !== "available") {
435
+ pushUnprovenDirectorySkip(skipped, dir.absPath, dir.relPath, read);
436
+ continue;
437
+ }
438
+ dirents = read.value;
439
+ } catch {
440
+ skipped.push({
441
+ absPath: dir.absPath,
442
+ relPath: dir.relPath,
443
+ reason: "SOURCE_AVAILABILITY_UNKNOWN",
444
+ unprovenPrefix: true,
445
+ message: "Failed to enumerate directory after availability check",
446
+ });
447
+ continue;
448
+ }
449
+
450
+ dirents.sort((left, right) => left.name.localeCompare(right.name));
451
+ for (const dirent of dirents) {
452
+ const name = dirent.name;
453
+ if (name === "" || name === "." || name === "..") {
454
+ continue;
455
+ }
456
+ if (name.includes("\0")) {
457
+ continue;
458
+ }
459
+
460
+ const childAbs = join(dir.absPath, name);
461
+ const childRel =
462
+ dir.relPath === "" ? name : `${dir.relPath}/${toPosixPath(name)}`;
463
+
464
+ // No-follow: symlinks are leaf candidates so the guarded content open
465
+ // can refuse them with a receipt; never descend through them.
466
+ if (dirent.isSymbolicLink()) {
467
+ if (!matchesWalkPath(childRel, config)) {
468
+ skipped.push({
469
+ absPath: childAbs,
470
+ relPath: childRel,
471
+ reason: "EXCLUDED",
472
+ });
473
+ continue;
474
+ }
475
+ try {
476
+ const linkStat = await lstat(childAbs);
477
+ entries.push({
478
+ absPath: childAbs,
479
+ relPath: childRel,
480
+ size: linkStat.size,
481
+ mtime: linkStat.mtime.toISOString(),
482
+ ctime: (
483
+ linkStat.birthtime ??
484
+ linkStat.ctime ??
485
+ linkStat.mtime
486
+ ).toISOString(),
487
+ });
488
+ } catch {
489
+ skipped.push({
490
+ absPath: childAbs,
491
+ relPath: childRel,
492
+ reason: "SOURCE_AVAILABILITY_UNKNOWN",
493
+ unprovenPrefix: true,
494
+ message: "Symlink metadata changed during local traversal",
495
+ });
496
+ }
497
+ continue;
498
+ }
499
+
500
+ if (dirent.isDirectory()) {
501
+ if (matchesCollectionSubtreeExclusion(childRel, config.exclude)) {
502
+ skipped.push({
503
+ absPath: childAbs,
504
+ relPath: childRel,
505
+ reason: "EXCLUDED",
506
+ });
507
+ continue;
508
+ }
509
+ const classified = await classifier.classify(childAbs);
510
+ if (isUnprovenDirectoryResult(classified)) {
511
+ pushUnprovenDirectorySkip(skipped, childAbs, childRel, classified);
512
+ continue;
513
+ }
514
+ queue.push({ absPath: childAbs, relPath: childRel });
515
+ continue;
516
+ }
517
+
518
+ if (!dirent.isFile()) {
519
+ continue;
520
+ }
521
+
522
+ const safePath = await safeRelPath(rootReal, childAbs);
523
+ if (safePath === null) {
524
+ continue;
525
+ }
526
+ const { absPath, relPath } = safePath;
527
+
528
+ if (!matchesWalkPath(relPath, config)) {
529
+ skipped.push({
530
+ absPath,
531
+ relPath,
532
+ reason: "EXCLUDED",
533
+ });
534
+ continue;
535
+ }
536
+
537
+ const file = Bun.file(absPath);
538
+ let fileStat: {
539
+ size: number;
540
+ mtime: Date;
541
+ ctime?: Date;
542
+ birthtime?: Date;
543
+ };
544
+ try {
545
+ fileStat = await file.stat();
546
+ } catch {
547
+ continue;
548
+ }
549
+
550
+ if (fileStat.size > config.maxBytes) {
551
+ skipped.push({
552
+ absPath,
553
+ relPath,
554
+ reason: "TOO_LARGE",
555
+ size: fileStat.size,
556
+ });
557
+ continue;
558
+ }
559
+
560
+ entries.push({
561
+ absPath,
562
+ relPath,
563
+ size: fileStat.size,
564
+ mtime: fileStat.mtime.toISOString(),
565
+ ctime: (
566
+ fileStat.birthtime ??
567
+ fileStat.ctime ??
568
+ fileStat.mtime
569
+ ).toISOString(),
570
+ });
571
+ }
572
+ }
573
+
574
+ entries.sort((a, b) => a.relPath.localeCompare(b.relPath));
575
+ return { entries, skipped };
576
+ }
319
577
  }
320
578
 
321
579
  /**