@gmickel/gno 1.30.7 → 1.32.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 (71) hide show
  1. package/README.md +6 -5
  2. package/assets/skill/SKILL.md +25 -0
  3. package/assets/skill/mcp-reference.md +6 -0
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.30.7.zip → gno-browser-clipper-v1.32.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +1 -1
  8. package/spec/cli.md +19 -0
  9. package/spec/db/schema.sql +55 -0
  10. package/spec/mcp.md +176 -11
  11. package/spec/output-schemas/file-refactor-apply-result.schema.json +305 -0
  12. package/spec/output-schemas/file-refactor-preview.schema.json +393 -0
  13. package/spec/output-schemas/section-target-create-result.schema.json +20 -0
  14. package/spec/output-schemas/section-target-resolve-result.schema.json +194 -0
  15. package/spec/output-schemas/section-target.schema.json +118 -0
  16. package/spec/output-schemas/section.schema.json +113 -0
  17. package/src/core/document-capabilities.ts +13 -0
  18. package/src/core/file-ops.ts +129 -1
  19. package/src/core/file-refactor-adapter.ts +329 -0
  20. package/src/core/file-refactor-apply-edits.ts +61 -0
  21. package/src/core/file-refactor-apply-fs.ts +512 -0
  22. package/src/core/file-refactor-apply-safety.ts +340 -0
  23. package/src/core/file-refactor-apply-validate.ts +401 -0
  24. package/src/core/file-refactor-contract.ts +486 -0
  25. package/src/core/file-refactor-destination.ts +123 -0
  26. package/src/core/file-refactor-from-snapshot.ts +148 -0
  27. package/src/core/file-refactor-journal-port.ts +150 -0
  28. package/src/core/file-refactor-journal.ts +347 -0
  29. package/src/core/file-refactor-paths.ts +60 -0
  30. package/src/core/file-refactor-plan-classify.ts +208 -0
  31. package/src/core/file-refactor-plan-validate.ts +169 -0
  32. package/src/core/file-refactor-planner-types.ts +62 -0
  33. package/src/core/file-refactor-planner.ts +423 -0
  34. package/src/core/file-refactor-resolve.ts +280 -0
  35. package/src/core/file-refactor-service.ts +468 -0
  36. package/src/core/file-refactors.ts +84 -56
  37. package/src/core/link-destination-parse.ts +275 -0
  38. package/src/core/link-inventory-markdown.ts +454 -0
  39. package/src/core/link-inventory-opaque.ts +244 -0
  40. package/src/core/link-inventory-types.ts +47 -0
  41. package/src/core/link-inventory.ts +182 -0
  42. package/src/core/link-relevance.ts +150 -0
  43. package/src/core/section-parse.ts +187 -0
  44. package/src/core/section-target-link.ts +154 -0
  45. package/src/core/section-target-resolve.ts +351 -0
  46. package/src/core/section-target-transport.ts +519 -0
  47. package/src/core/section-target.ts +263 -0
  48. package/src/core/sections.ts +60 -115
  49. package/src/mcp/AGENTS.md +1 -0
  50. package/src/mcp/CLAUDE.md +1 -0
  51. package/src/mcp/http-egress.ts +1 -0
  52. package/src/mcp/tools/index.ts +37 -17
  53. package/src/mcp/tools/sections.ts +512 -0
  54. package/src/mcp/tools/workspace-write.ts +215 -97
  55. package/src/sdk/client.ts +238 -116
  56. package/src/sdk/index.ts +12 -0
  57. package/src/sdk/types.ts +61 -3
  58. package/src/serve/file-refactor-http.ts +239 -0
  59. package/src/serve/public/components/RefactorImpactPreview.tsx +227 -0
  60. package/src/serve/public/globals.built.css +1 -1
  61. package/src/serve/public/lib/section-links.ts +189 -0
  62. package/src/serve/public/pages/DocView.tsx +395 -77
  63. package/src/serve/routes/api.ts +191 -104
  64. package/src/serve/routes/section-targets.ts +221 -0
  65. package/src/serve/server.ts +34 -0
  66. package/src/store/migrations/026-file-refactor-recovery-journal.ts +72 -0
  67. package/src/store/migrations/index.ts +2 -0
  68. package/src/store/sqlite/adapter.ts +452 -0
  69. package/src/store/sqlite/file-refactor-journal-store.ts +275 -0
  70. package/src/store/types.ts +84 -0
  71. package/browser-extension/artifacts/gno-browser-clipper-v1.30.7.zip.sha256 +0 -1
@@ -0,0 +1,512 @@
1
+ /**
2
+ * Filesystem stage/commit/rollback for reference-safe refactor apply.
3
+ *
4
+ * @module src/core/file-refactor-apply-fs
5
+ */
6
+
7
+ // node:path dirname/relative — no Bun path utils
8
+ import { dirname, relative } from "node:path";
9
+
10
+ import type { FileRefactorJournalFileStatus } from "./file-refactor-journal";
11
+
12
+ import {
13
+ backupFileToSibling,
14
+ commitStagedFileExclusive,
15
+ commitStagedFileReplace,
16
+ removePathIfExists,
17
+ removePathRequired,
18
+ restoreFileFromBackup,
19
+ siblingRefactorPath,
20
+ writeStagedFileContent,
21
+ } from "./file-ops";
22
+ import {
23
+ assertContainedExistingPath,
24
+ assertContainedLiveFileAndParent,
25
+ classifyContainedNonSymlinkFile,
26
+ classifyPathPresence,
27
+ ensureContainedTargetParents,
28
+ removeOwnedEmptyDirs,
29
+ validateRefactorJournalId,
30
+ type RemoveOwnedEmptyDirsDeps,
31
+ } from "./file-refactor-apply-safety";
32
+ import { fingerprintUtf8Content } from "./file-refactor-contract";
33
+
34
+ export {
35
+ cleanupReceiptArtifacts,
36
+ resolveCollectionAbsPath,
37
+ } from "./file-refactor-apply-safety";
38
+
39
+ export type FileRefactorMutationBoundary =
40
+ | {
41
+ kind:
42
+ | "stage"
43
+ | "after_stage_write"
44
+ | "after_stage_backup"
45
+ | "after_target_dirs"
46
+ | "commit"
47
+ | "after_commit_target"
48
+ | "rollback";
49
+ role: "source" | "affected";
50
+ relPath: string;
51
+ }
52
+ | { kind: "before_commit" }
53
+ | { kind: "after_commit" };
54
+
55
+ export type FileRefactorBoundaryHook = (
56
+ boundary: FileRefactorMutationBoundary
57
+ ) => void | Promise<void>;
58
+
59
+ export interface PreparedRefactorFile {
60
+ role: "source" | "affected";
61
+ /** Live path before commit (source path for moves). */
62
+ sourceAbsPath: string;
63
+ /** Final absolute path after commit. */
64
+ targetAbsPath: string;
65
+ relPath: string;
66
+ /** Rel path of the live source file when role is source. */
67
+ sourceRelPath?: string;
68
+ finalContent: string;
69
+ originalFingerprint: string;
70
+ expectedFingerprint: string;
71
+ /** True when target path differs from source (rename/move). */
72
+ isMove: boolean;
73
+ /** Precomputed durable stage artifact absolute path. */
74
+ stagePath: string;
75
+ /** Precomputed durable backup artifact absolute path. */
76
+ backupPath: string;
77
+ /** Collection-relative stage artifact path (journal metadata). */
78
+ stageRelPath: string;
79
+ /** Collection-relative backup artifact path (journal metadata). */
80
+ backupRelPath: string;
81
+ /** Set after exclusive move target create succeeds. */
82
+ targetCreatedByCommit?: boolean;
83
+ /** Absolute dirs created by this transaction for the move target. */
84
+ createdDirAbsPaths?: string[];
85
+ }
86
+
87
+ export type StagedRefactorFile = PreparedRefactorFile;
88
+
89
+ export type FileRefactorProgressCallback = (update: {
90
+ relPath: string;
91
+ status: FileRefactorJournalFileStatus;
92
+ }) => void | Promise<void>;
93
+
94
+ export interface FileRefactorFsHooks {
95
+ onBoundary?: FileRefactorBoundaryHook;
96
+ onFileProgress?: FileRefactorProgressCallback;
97
+ /** Injectable required removal for failure injection. */
98
+ removePathRequired?: typeof removePathRequired;
99
+ /** Collection root used for containment re-checks at destructive boundaries. */
100
+ collectionRoot?: string;
101
+ /** Injectable rmdir for owned-directory cleanup failure seams. */
102
+ rmdir?: RemoveOwnedEmptyDirsDeps["rmdir"];
103
+ }
104
+
105
+ async function invokeBoundary(
106
+ onBoundary: FileRefactorBoundaryHook | undefined,
107
+ boundary: FileRefactorMutationBoundary
108
+ ): Promise<void> {
109
+ if (onBoundary) await onBoundary(boundary);
110
+ }
111
+
112
+ export function assignRefactorArtifactPaths(
113
+ files: Omit<
114
+ PreparedRefactorFile,
115
+ | "stagePath"
116
+ | "backupPath"
117
+ | "stageRelPath"
118
+ | "backupRelPath"
119
+ | "createdDirAbsPaths"
120
+ | "targetCreatedByCommit"
121
+ >[],
122
+ collectionRoot: string,
123
+ journalId: string
124
+ ): PreparedRefactorFile[] {
125
+ const safeJournalId = validateRefactorJournalId(journalId);
126
+ return files.map((file, index) => {
127
+ const token = `${safeJournalId}.${index}`;
128
+ // Stage beside the live source path so target parents are not needed yet.
129
+ const stagePath = siblingRefactorPath(file.sourceAbsPath, "stage", token);
130
+ const backupPath = siblingRefactorPath(file.sourceAbsPath, "backup", token);
131
+ return {
132
+ ...file,
133
+ stagePath,
134
+ backupPath,
135
+ stageRelPath: relative(collectionRoot, stagePath).split("\\").join("/"),
136
+ backupRelPath: relative(collectionRoot, backupPath).split("\\").join("/"),
137
+ };
138
+ });
139
+ }
140
+
141
+ async function cleanupFileArtifacts(file: PreparedRefactorFile): Promise<void> {
142
+ await removePathIfExists(file.stagePath);
143
+ await removePathIfExists(file.backupPath);
144
+ }
145
+
146
+ export async function stageRefactorFiles(
147
+ files: PreparedRefactorFile[],
148
+ hooks: FileRefactorFsHooks = {},
149
+ stagedOut: StagedRefactorFile[] = []
150
+ ): Promise<StagedRefactorFile[]> {
151
+ const collectionRoot = hooks.collectionRoot;
152
+ if (!collectionRoot) {
153
+ throw new Error("collectionRoot required for stage");
154
+ }
155
+ for (const file of files) {
156
+ await invokeBoundary(hooks.onBoundary, {
157
+ kind: "stage",
158
+ role: file.role,
159
+ relPath: file.relPath,
160
+ });
161
+ try {
162
+ // Parent symlink swap after validation must fail before exclusive create.
163
+ await assertContainedLiveFileAndParent(
164
+ collectionRoot,
165
+ file.sourceAbsPath
166
+ );
167
+ // Exclusive create — pre-existing symlink/file fails closed (EEXIST).
168
+ await writeStagedFileContent(file.stagePath, file.finalContent);
169
+ await invokeBoundary(hooks.onBoundary, {
170
+ kind: "after_stage_write",
171
+ role: file.role,
172
+ relPath: file.relPath,
173
+ });
174
+ await assertContainedLiveFileAndParent(
175
+ collectionRoot,
176
+ file.sourceAbsPath
177
+ );
178
+ await backupFileToSibling(file.sourceAbsPath, file.backupPath);
179
+ await invokeBoundary(hooks.onBoundary, {
180
+ kind: "after_stage_backup",
181
+ role: file.role,
182
+ relPath: file.relPath,
183
+ });
184
+ stagedOut.push(file);
185
+ if (hooks.onFileProgress) {
186
+ await hooks.onFileProgress({
187
+ relPath: file.relPath,
188
+ status: "staged",
189
+ });
190
+ }
191
+ } catch (cause) {
192
+ await cleanupFileArtifacts(file);
193
+ throw cause;
194
+ }
195
+ }
196
+ return stagedOut;
197
+ }
198
+
199
+ async function liveFingerprintEquals(
200
+ absPath: string,
201
+ expected: string
202
+ ): Promise<boolean> {
203
+ if (!(await Bun.file(absPath).exists())) return false;
204
+ const live = await Bun.file(absPath).text();
205
+ return (await fingerprintUtf8Content(live)) === expected;
206
+ }
207
+
208
+ /**
209
+ * Fingerprint only after proving the live entry is a non-symlink file whose
210
+ * path and parent canonicalize inside collectionRoot. Symlink identity with
211
+ * matching outside bytes must never satisfy verification.
212
+ */
213
+ async function verifyContainedLiveFingerprint(
214
+ collectionRoot: string,
215
+ absPath: string,
216
+ expectedFingerprint: string
217
+ ): Promise<boolean> {
218
+ const status = await classifyContainedNonSymlinkFile(collectionRoot, absPath);
219
+ if (status !== "contained") return false;
220
+ const live = await Bun.file(absPath).text();
221
+ return (await fingerprintUtf8Content(live)) === expectedFingerprint;
222
+ }
223
+
224
+ async function verifyBackupFingerprint(
225
+ file: StagedRefactorFile
226
+ ): Promise<void> {
227
+ if (!(await Bun.file(file.backupPath).exists())) {
228
+ throw new Error(`Missing backup artifact for ${file.relPath}`);
229
+ }
230
+ const backup = await Bun.file(file.backupPath).text();
231
+ const fingerprint = await fingerprintUtf8Content(backup);
232
+ if (fingerprint !== file.originalFingerprint) {
233
+ throw new Error(`Backup fingerprint mismatch for ${file.relPath}`);
234
+ }
235
+ }
236
+
237
+ export async function commitRefactorFiles(
238
+ staged: StagedRefactorFile[],
239
+ hooks: FileRefactorFsHooks = {}
240
+ ): Promise<void> {
241
+ const removeRequired = hooks.removePathRequired ?? removePathRequired;
242
+ const collectionRoot = hooks.collectionRoot;
243
+ const affected = staged.filter((file) => file.role === "affected");
244
+ const sources = staged.filter((file) => file.role === "source");
245
+
246
+ if (!collectionRoot) {
247
+ throw new Error("collectionRoot required for commit");
248
+ }
249
+
250
+ for (const file of [...affected, ...sources]) {
251
+ await invokeBoundary(hooks.onBoundary, {
252
+ kind: "commit",
253
+ role: file.role,
254
+ relPath: file.relPath,
255
+ });
256
+
257
+ const livePath = file.isMove ? file.sourceAbsPath : file.targetAbsPath;
258
+ // Re-canonicalize live path + parent before replace/create/unlink.
259
+ await assertContainedLiveFileAndParent(collectionRoot, livePath);
260
+ if (!(await liveFingerprintEquals(livePath, file.originalFingerprint))) {
261
+ throw new Error(`External mutation detected for ${file.relPath}`);
262
+ }
263
+ await verifyBackupFingerprint(file);
264
+
265
+ if (file.isMove) {
266
+ file.createdDirAbsPaths = await ensureContainedTargetParents(
267
+ file.targetAbsPath,
268
+ collectionRoot
269
+ );
270
+ await invokeBoundary(hooks.onBoundary, {
271
+ kind: "after_target_dirs",
272
+ role: file.role,
273
+ relPath: file.relPath,
274
+ });
275
+ // Directory symlink swap after mkdir must fail closed.
276
+ await assertContainedExistingPath(
277
+ collectionRoot,
278
+ dirname(file.targetAbsPath)
279
+ );
280
+ await commitStagedFileExclusive(file.stagePath, file.targetAbsPath);
281
+ file.targetCreatedByCommit = true;
282
+ await invokeBoundary(hooks.onBoundary, {
283
+ kind: "after_commit_target",
284
+ role: file.role,
285
+ relPath: file.relPath,
286
+ });
287
+ // Swapped source parent must not route unlink outside the collection.
288
+ await assertContainedLiveFileAndParent(
289
+ collectionRoot,
290
+ file.sourceAbsPath
291
+ );
292
+ await removeRequired(file.sourceAbsPath);
293
+ } else {
294
+ await commitStagedFileReplace(file.stagePath, file.targetAbsPath);
295
+ await invokeBoundary(hooks.onBoundary, {
296
+ kind: "after_commit_target",
297
+ role: file.role,
298
+ relPath: file.relPath,
299
+ });
300
+ }
301
+
302
+ if (hooks.onFileProgress) {
303
+ await hooks.onFileProgress({
304
+ relPath: file.relPath,
305
+ status: "committed",
306
+ });
307
+ }
308
+ }
309
+
310
+ await verifyCommittedFiles(staged, collectionRoot);
311
+ await invokeBoundary(hooks.onBoundary, { kind: "after_commit" });
312
+ }
313
+
314
+ export async function verifyCommittedFiles(
315
+ staged: StagedRefactorFile[],
316
+ collectionRoot: string
317
+ ): Promise<void> {
318
+ for (const file of staged) {
319
+ if (file.isMove) {
320
+ if ((await classifyPathPresence(file.sourceAbsPath)) !== "missing") {
321
+ throw new Error(`Source still present after commit: ${file.relPath}`);
322
+ }
323
+ if (
324
+ !(await verifyContainedLiveFingerprint(
325
+ collectionRoot,
326
+ file.targetAbsPath,
327
+ file.expectedFingerprint
328
+ ))
329
+ ) {
330
+ throw new Error(`Target fingerprint mismatch: ${file.relPath}`);
331
+ }
332
+ } else if (
333
+ !(await verifyContainedLiveFingerprint(
334
+ collectionRoot,
335
+ file.targetAbsPath,
336
+ file.expectedFingerprint
337
+ ))
338
+ ) {
339
+ throw new Error(`Affected fingerprint mismatch: ${file.relPath}`);
340
+ }
341
+ }
342
+ }
343
+
344
+ export async function rollbackRefactorFiles(
345
+ staged: StagedRefactorFile[],
346
+ commitStarted: boolean,
347
+ hooks: FileRefactorFsHooks = {}
348
+ ): Promise<{ verified: boolean }> {
349
+ const collectionRoot = hooks.collectionRoot;
350
+ if (!collectionRoot) return { verified: false };
351
+
352
+ if (!commitStarted) {
353
+ await cleanupStagingArtifacts(staged);
354
+ const dirsOk = await cleanupOwnedDirs(staged, hooks);
355
+ return { verified: dirsOk };
356
+ }
357
+
358
+ const sources = staged.filter((file) => file.role === "source");
359
+ const affected = staged.filter((file) => file.role === "affected");
360
+
361
+ for (const file of [...sources, ...affected]) {
362
+ try {
363
+ await invokeBoundary(hooks.onBoundary, {
364
+ kind: "rollback",
365
+ role: file.role,
366
+ relPath: file.relPath,
367
+ });
368
+ if (file.isMove) {
369
+ if (file.targetCreatedByCommit) {
370
+ const targetIsOurs = await verifyContainedLiveFingerprint(
371
+ collectionRoot,
372
+ file.targetAbsPath,
373
+ file.expectedFingerprint
374
+ );
375
+ if (targetIsOurs) await removePathIfExists(file.targetAbsPath);
376
+ }
377
+ if (await Bun.file(file.backupPath).exists()) {
378
+ const missing = !(await Bun.file(file.sourceAbsPath).exists());
379
+ const isOriginal = !missing
380
+ ? await liveFingerprintEquals(
381
+ file.sourceAbsPath,
382
+ file.originalFingerprint
383
+ )
384
+ : false;
385
+ // Replace missing paths or non-original entries (incl. swapped symlinks).
386
+ if (missing || !isOriginal) {
387
+ await assertContainedExistingPath(
388
+ collectionRoot,
389
+ dirname(file.sourceAbsPath)
390
+ );
391
+ await restoreFileFromBackup(file.backupPath, file.sourceAbsPath);
392
+ }
393
+ }
394
+ } else if (await Bun.file(file.backupPath).exists()) {
395
+ const missing = !(await Bun.file(file.targetAbsPath).exists());
396
+ const isOriginal = !missing
397
+ ? await liveFingerprintEquals(
398
+ file.targetAbsPath,
399
+ file.originalFingerprint
400
+ )
401
+ : false;
402
+ const isOurs = !missing
403
+ ? await liveFingerprintEquals(
404
+ file.targetAbsPath,
405
+ file.expectedFingerprint
406
+ )
407
+ : false;
408
+ if (missing || isOurs) {
409
+ await assertContainedExistingPath(
410
+ collectionRoot,
411
+ dirname(file.targetAbsPath)
412
+ );
413
+ await restoreFileFromBackup(file.backupPath, file.targetAbsPath);
414
+ } else if (!isOriginal) {
415
+ // External mutation — leave bytes untouched.
416
+ }
417
+ }
418
+ if (hooks.onFileProgress) {
419
+ await hooks.onFileProgress({
420
+ relPath: file.relPath,
421
+ status: "restored",
422
+ });
423
+ }
424
+ } catch {
425
+ if (hooks.onFileProgress) {
426
+ await hooks.onFileProgress({
427
+ relPath: file.relPath,
428
+ status: "failed",
429
+ });
430
+ }
431
+ }
432
+ }
433
+
434
+ const verified = await verifyRollback(staged, collectionRoot);
435
+ if (!verified) return { verified: false };
436
+ await cleanupStagingArtifacts(staged);
437
+ // Owned dirs must be gone; leftover dirs ⇒ recovery_required, not rolled_back.
438
+ const dirsOk = await cleanupOwnedDirs(staged, hooks);
439
+ return { verified: dirsOk };
440
+ }
441
+
442
+ async function verifyRollback(
443
+ staged: StagedRefactorFile[],
444
+ collectionRoot: string
445
+ ): Promise<boolean> {
446
+ if (!collectionRoot) return false;
447
+ for (const file of staged) {
448
+ if (file.isMove) {
449
+ if ((await classifyPathPresence(file.targetAbsPath)) !== "missing") {
450
+ return false;
451
+ }
452
+ if (
453
+ !(await verifyContainedLiveFingerprint(
454
+ collectionRoot,
455
+ file.sourceAbsPath,
456
+ file.originalFingerprint
457
+ ))
458
+ ) {
459
+ return false;
460
+ }
461
+ } else if (
462
+ !(await verifyContainedLiveFingerprint(
463
+ collectionRoot,
464
+ file.targetAbsPath,
465
+ file.originalFingerprint
466
+ ))
467
+ ) {
468
+ return false;
469
+ }
470
+ }
471
+ return true;
472
+ }
473
+
474
+ async function cleanupOwnedDirs(
475
+ staged: StagedRefactorFile[],
476
+ hooks: FileRefactorFsHooks = {}
477
+ ): Promise<boolean> {
478
+ const collectionRoot = hooks.collectionRoot;
479
+ if (!collectionRoot) return false;
480
+ let ok = true;
481
+ for (const file of staged) {
482
+ if (!file.createdDirAbsPaths?.length) continue;
483
+ const result = await removeOwnedEmptyDirs(
484
+ file.createdDirAbsPaths,
485
+ collectionRoot,
486
+ {
487
+ rmdir: hooks.rmdir,
488
+ }
489
+ );
490
+ file.createdDirAbsPaths = result.failed;
491
+ if (!result.ok) ok = false;
492
+ }
493
+ return ok;
494
+ }
495
+
496
+ export async function cleanupStagingArtifacts(
497
+ staged: StagedRefactorFile[]
498
+ ): Promise<void> {
499
+ for (const file of staged) {
500
+ await cleanupFileArtifacts(file);
501
+ }
502
+ }
503
+
504
+ export async function cleanupAfterSuccessfulCommit(
505
+ staged: StagedRefactorFile[]
506
+ ): Promise<void> {
507
+ await cleanupStagingArtifacts(staged);
508
+ // Successful commit keeps target dirs; clear ownership tracking only.
509
+ for (const file of staged) {
510
+ file.createdDirAbsPaths = [];
511
+ }
512
+ }