@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
@@ -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
+ }
@@ -0,0 +1,300 @@
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
+ unprovenSubtrees: Set<string>;
39
+ }
40
+
41
+ export function cloneDirectoryMaps(
42
+ source: WatcherSnapshot
43
+ ): MutableSnapshotMaps {
44
+ const directories = new Map<string, Map<string, SnapshotEntryFingerprint>>();
45
+ for (const [dir, entries] of source.directories) {
46
+ directories.set(dir, new Map(entries));
47
+ }
48
+ return {
49
+ directories,
50
+ entryCount: source.entryCount,
51
+ unprovenSubtrees: new Set(source.unprovenSubtrees ?? []),
52
+ };
53
+ }
54
+
55
+ export function freezeSnapshot(state: MutableSnapshotMaps): WatcherSnapshot {
56
+ const frozen = new Map<
57
+ string,
58
+ ReadonlyMap<string, SnapshotEntryFingerprint>
59
+ >();
60
+ for (const [dir, entries] of state.directories) {
61
+ frozen.set(dir, entries);
62
+ }
63
+ return {
64
+ directories: frozen,
65
+ entryCount: state.entryCount,
66
+ unprovenSubtrees: new Set(state.unprovenSubtrees),
67
+ };
68
+ }
69
+
70
+ /** Replace one directory's entry map; O(1) entryCount update. */
71
+ export function setDirectoryEntries(
72
+ state: MutableSnapshotMaps,
73
+ dirRel: string,
74
+ entries: Map<string, SnapshotEntryFingerprint>
75
+ ): void {
76
+ const previous = state.directories.get(dirRel);
77
+ const previousSize = previous?.size ?? 0;
78
+ state.directories.set(dirRel, entries);
79
+ state.entryCount += entries.size - previousSize;
80
+ }
81
+
82
+ /**
83
+ * Hierarchical O(subtree-size) removal using stored child relationships.
84
+ * Single pass: collect non-directory paths and delete maps — no full-map
85
+ * prefix scan and no separate collect-then-remove phases.
86
+ */
87
+ export function removeSubtreeFromMaps(
88
+ state: MutableSnapshotMaps,
89
+ dirRel: string,
90
+ hooks?: SnapshotMapHooks
91
+ ): string[] {
92
+ const removedCandidates: string[] = [];
93
+ const stack: string[] = [dirRel];
94
+
95
+ while (stack.length > 0) {
96
+ const dir = stack.pop() as string;
97
+ hooks?.onDirectoryMapVisit?.();
98
+ const entries = state.directories.get(dir);
99
+ if (!entries) {
100
+ continue;
101
+ }
102
+ for (const [name, fingerprint] of entries) {
103
+ const childRel = joinWatcherRelPath(dir, name);
104
+ if (fingerprint.kind === "directory") {
105
+ stack.push(childRel);
106
+ } else if (isWatcherSourceKind(fingerprint.kind)) {
107
+ // Only file/symlink sources are indexable; ignore FIFO/socket/device.
108
+ removedCandidates.push(childRel);
109
+ }
110
+ }
111
+ state.entryCount -= entries.size;
112
+ state.directories.delete(dir);
113
+ }
114
+
115
+ return removedCandidates;
116
+ }
117
+
118
+ /**
119
+ * Hierarchical collect of file/symlink source paths under a stored directory.
120
+ * O(subtree-size) via child relationships — not a full-map prefix scan.
121
+ * Special `other` entries are never collected (never indexed as sources).
122
+ */
123
+ export function collectSnapshotFilesUnder(
124
+ directories: ReadonlyMap<
125
+ string,
126
+ ReadonlyMap<string, SnapshotEntryFingerprint>
127
+ >,
128
+ dirRel: string,
129
+ hooks?: SnapshotMapHooks
130
+ ): string[] {
131
+ const out: string[] = [];
132
+ const stack: string[] = [dirRel];
133
+ while (stack.length > 0) {
134
+ const dir = stack.pop() as string;
135
+ hooks?.onDirectoryMapVisit?.();
136
+ const entries = directories.get(dir);
137
+ if (!entries) {
138
+ continue;
139
+ }
140
+ for (const [name, fingerprint] of entries) {
141
+ const childRel = joinWatcherRelPath(dir, name);
142
+ if (fingerprint.kind === "directory") {
143
+ stack.push(childRel);
144
+ } else if (isWatcherSourceKind(fingerprint.kind)) {
145
+ out.push(childRel);
146
+ }
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+
152
+ /**
153
+ * Open a collection-relative directory by walking components from the root
154
+ * handle. Never opens a full joined path that could traverse intermediate
155
+ * symlinks.
156
+ */
157
+ export async function openDirByRel(
158
+ rootAbs: string,
159
+ dirRel: string,
160
+ fs: WatcherSnapshotFs
161
+ ): Promise<
162
+ | { status: "ok"; handle: WatcherDirHandle }
163
+ | { status: "missing" }
164
+ | ScanFailure
165
+ > {
166
+ if (!fs.supportsAnchoredHandles) {
167
+ return {
168
+ status: "scan_failed",
169
+ cause: new Error(
170
+ "Anchored no-follow directory handles unavailable; refusing path-based scan"
171
+ ),
172
+ };
173
+ }
174
+
175
+ let handle: WatcherDirHandle;
176
+ try {
177
+ handle = await fs.openDir(rootAbs);
178
+ } catch (cause) {
179
+ if (isMissingFsError(cause)) {
180
+ return { status: "missing" };
181
+ }
182
+ return { status: "scan_failed", cause };
183
+ }
184
+
185
+ if (dirRel === "") {
186
+ return { status: "ok", handle };
187
+ }
188
+
189
+ const segments = dirRel.split("/");
190
+ for (const segment of segments) {
191
+ let child: WatcherDirHandle;
192
+ try {
193
+ // Reject non-directory / symlink components by requiring openChildDir.
194
+ child = await fs.openChildDir(handle, segment);
195
+ } catch (cause) {
196
+ await fs.closeDir(handle);
197
+ if (isMissingFsError(cause)) {
198
+ return { status: "missing" };
199
+ }
200
+ return { status: "scan_failed", cause };
201
+ }
202
+ await fs.closeDir(handle);
203
+ handle = child;
204
+ }
205
+ return { status: "ok", handle };
206
+ }
207
+
208
+ /**
209
+ * Enumerate direct children via an anchored directory handle.
210
+ * Child metadata is resolved relative to the handle — a path swap after open
211
+ * cannot redirect lstat outside the pinned directory.
212
+ *
213
+ * `maxEntries` is the remaining entry budget for this directory map. Enumeration
214
+ * and stats stop after observing `maxEntries + 1` children so overflow is proven
215
+ * without materializing an unbounded name/stat map.
216
+ */
217
+ export async function readDirectChildren(
218
+ rootAbs: string,
219
+ dirRel: string,
220
+ fs: WatcherSnapshotFs,
221
+ maxEntries: number
222
+ ): Promise<
223
+ | { status: "present"; entries: Map<string, SnapshotEntryFingerprint> }
224
+ | { status: "missing" }
225
+ | ScanFailure
226
+ > {
227
+ if (!fs.supportsAnchoredHandles) {
228
+ return {
229
+ status: "scan_failed",
230
+ cause: new Error(
231
+ "Anchored no-follow directory handles unavailable; refusing path-based scan"
232
+ ),
233
+ };
234
+ }
235
+ if (!Number.isInteger(maxEntries) || maxEntries < 0) {
236
+ return {
237
+ status: "scan_failed",
238
+ cause: new Error("maxEntries must be a non-negative integer"),
239
+ };
240
+ }
241
+
242
+ const opened = await openDirByRel(rootAbs, dirRel, fs);
243
+ if (opened.status !== "ok") {
244
+ return opened;
245
+ }
246
+ const { handle } = opened;
247
+
248
+ try {
249
+ let listed;
250
+ try {
251
+ // Cap names at remaining budget; maxEntries+1th name → overflow.
252
+ listed = await fs.readDir(handle, maxEntries);
253
+ } catch (cause) {
254
+ if (isMissingFsError(cause)) {
255
+ return { status: "missing" };
256
+ }
257
+ return { status: "scan_failed", cause };
258
+ }
259
+ if (listed.status === "overflow") {
260
+ return { status: "overflow" };
261
+ }
262
+
263
+ const names = listed.names;
264
+ const entries = new Map<string, SnapshotEntryFingerprint>();
265
+ // Stable order keeps overflow selection deterministic across platforms.
266
+ names.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
267
+ for (const name of names) {
268
+ if (name === "" || name === "." || name === "..") {
269
+ continue;
270
+ }
271
+ if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
272
+ return {
273
+ status: "scan_failed",
274
+ cause: new Error(`Invalid directory entry name: ${name}`),
275
+ };
276
+ }
277
+ // Defense in depth: never stat/map more than the remaining budget.
278
+ if (entries.size >= maxEntries) {
279
+ return { status: "overflow" };
280
+ }
281
+ let stat: WatcherSnapshotStat;
282
+ try {
283
+ stat = await fs.lstatChild(handle, name);
284
+ } catch (cause) {
285
+ // Observed-then-missing (ENOENT/ENOTDIR after readdir listed the name)
286
+ // must fail closed: silently skipping would accept a partial directory
287
+ // image and can prove false removals against the previous snapshot.
288
+ return { status: "scan_failed", cause };
289
+ }
290
+ const fingerprinted = fingerprintFromStat(stat);
291
+ if (!fingerprinted.ok) {
292
+ return { status: "unreliable_metadata" };
293
+ }
294
+ entries.set(name, fingerprinted.fingerprint);
295
+ }
296
+ return { status: "present", entries };
297
+ } finally {
298
+ await fs.closeDir(handle);
299
+ }
300
+ }