@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,401 @@
1
+ /**
2
+ * Live filesystem validation before reference-safe refactor mutation.
3
+ *
4
+ * @module src/core/file-refactor-apply-validate
5
+ */
6
+
7
+ // node:fs/promises realpath/lstat — no Bun equivalent for symlink-safe identity
8
+ import { lstat, realpath } from "node:fs/promises";
9
+ // node:path — no Bun path utils
10
+ import { dirname, isAbsolute, normalize } from "node:path";
11
+
12
+ import type { PreparedRefactorFile } from "./file-refactor-apply-fs";
13
+ import type {
14
+ FileRefactorApplyResult,
15
+ FileRefactorPreviewPlan,
16
+ FileRefactorReasonCode,
17
+ } from "./file-refactor-contract";
18
+
19
+ import { parseUri } from "../app/constants";
20
+ import {
21
+ applyDestinationEditsDescending,
22
+ FileRefactorEditError,
23
+ } from "./file-refactor-apply-edits";
24
+ import { resolveCollectionAbsPath } from "./file-refactor-apply-fs";
25
+ import {
26
+ FILE_REFACTOR_SCHEMA_VERSION,
27
+ fingerprintUtf8Content,
28
+ } from "./file-refactor-contract";
29
+ import {
30
+ pathDirname,
31
+ validateFileRefactorPlanInputs,
32
+ } from "./file-refactor-plan-validate";
33
+ import { isCanonicalPathContained, validateRelPath } from "./validation";
34
+
35
+ function baseResult(
36
+ plan: FileRefactorPreviewPlan
37
+ ): Pick<
38
+ FileRefactorApplyResult,
39
+ "schemaVersion" | "planDigest" | "operation" | "source" | "target"
40
+ > {
41
+ return {
42
+ schemaVersion: FILE_REFACTOR_SCHEMA_VERSION,
43
+ planDigest: plan.planDigest,
44
+ operation: plan.operation,
45
+ source: plan.source,
46
+ target: plan.target,
47
+ };
48
+ }
49
+
50
+ export function unchangedResult(
51
+ plan: FileRefactorPreviewPlan,
52
+ status: "conflict" | "stale_plan" | "unsupported",
53
+ reasonCode: FileRefactorReasonCode,
54
+ recoveryJournalId?: string
55
+ ): FileRefactorApplyResult {
56
+ return {
57
+ ...baseResult(plan),
58
+ status,
59
+ reasonCode,
60
+ filesystem: { state: "unchanged", recoveryJournalId },
61
+ indexConvergence: { state: "not_attempted" },
62
+ };
63
+ }
64
+
65
+ async function realpathOrNull(path: string): Promise<string | null> {
66
+ try {
67
+ return await realpath(path);
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ async function nearestExistingParentRealpath(
74
+ absPath: string
75
+ ): Promise<string | null> {
76
+ let candidate = dirname(absPath);
77
+ while (true) {
78
+ const resolved = await realpathOrNull(candidate);
79
+ if (resolved) return resolved;
80
+ const parent = dirname(candidate);
81
+ if (parent === candidate) return null;
82
+ candidate = parent;
83
+ }
84
+ }
85
+
86
+ async function assertExistingPathContained(
87
+ rootReal: string,
88
+ absPath: string
89
+ ): Promise<boolean> {
90
+ const real = await realpathOrNull(absPath);
91
+ if (!real) return false;
92
+ return isCanonicalPathContained(rootReal, real);
93
+ }
94
+
95
+ async function assertTargetPathContained(
96
+ rootReal: string,
97
+ targetAbs: string
98
+ ): Promise<boolean> {
99
+ // Reject final path if it already exists as a symlink escape.
100
+ try {
101
+ const info = await lstat(targetAbs);
102
+ if (info.isSymbolicLink()) return false;
103
+ const real = await realpath(targetAbs);
104
+ return isCanonicalPathContained(rootReal, real);
105
+ } catch {
106
+ // Target must not exist for apply; validate nearest existing parent.
107
+ }
108
+ const parentReal = await nearestExistingParentRealpath(targetAbs);
109
+ if (!parentReal) return false;
110
+ return isCanonicalPathContained(rootReal, parentReal);
111
+ }
112
+
113
+ /**
114
+ * Structural plan integrity independent of live filesystem bytes.
115
+ * Malformed but self-digested canApply=true plans fail closed here.
116
+ */
117
+ export function validateRefactorPlanStructure(
118
+ plan: FileRefactorPreviewPlan
119
+ ): FileRefactorApplyResult | null {
120
+ const inputFailure = validateFileRefactorPlanInputs({
121
+ operation: plan.operation,
122
+ source: plan.source,
123
+ target: plan.target,
124
+ });
125
+ if (inputFailure) {
126
+ return unchangedResult(plan, "unsupported", inputFailure.reasonCode);
127
+ }
128
+
129
+ if (plan.source.collection !== plan.target.collection) {
130
+ return unchangedResult(plan, "unsupported", "cross_collection_unsupported");
131
+ }
132
+
133
+ if (plan.source.uri === plan.target.uri) {
134
+ return unchangedResult(plan, "unsupported", "unsafe_target");
135
+ }
136
+ if (plan.source.relPath === plan.target.relPath) {
137
+ return unchangedResult(plan, "unsupported", "unsafe_target");
138
+ }
139
+
140
+ if (plan.operation === "rename") {
141
+ if (pathDirname(plan.source.relPath) !== pathDirname(plan.target.relPath)) {
142
+ return unchangedResult(plan, "unsupported", "unsafe_target");
143
+ }
144
+ }
145
+
146
+ const sourceParsed = parseUri(plan.source.uri);
147
+ const targetParsed = parseUri(plan.target.uri);
148
+ if (
149
+ !sourceParsed ||
150
+ !targetParsed ||
151
+ sourceParsed.collection !== plan.source.collection ||
152
+ sourceParsed.path !== plan.source.relPath ||
153
+ targetParsed.collection !== plan.target.collection ||
154
+ targetParsed.path !== plan.target.relPath
155
+ ) {
156
+ return unchangedResult(plan, "unsupported", "unsafe_target");
157
+ }
158
+
159
+ const seenUris = new Set<string>();
160
+ const seenRelPaths = new Set<string>();
161
+ for (const doc of plan.affectedDocuments) {
162
+ if (seenUris.has(doc.uri) || seenRelPaths.has(doc.relPath)) {
163
+ return unchangedResult(plan, "unsupported", "unsafe_target");
164
+ }
165
+ seenUris.add(doc.uri);
166
+ seenRelPaths.add(doc.relPath);
167
+
168
+ const parsed = parseUri(doc.uri);
169
+ if (
170
+ !parsed ||
171
+ parsed.path !== doc.relPath ||
172
+ parsed.collection !== plan.source.collection
173
+ ) {
174
+ return unchangedResult(plan, "unsupported", "unsafe_target");
175
+ }
176
+ if (doc.uri !== plan.source.uri && doc.relPath === plan.source.relPath) {
177
+ return unchangedResult(plan, "unsupported", "unsafe_target");
178
+ }
179
+ if (doc.uri === plan.source.uri && doc.relPath !== plan.source.relPath) {
180
+ return unchangedResult(plan, "unsupported", "unsafe_target");
181
+ }
182
+ if (doc.relPath === plan.target.relPath || doc.uri === plan.target.uri) {
183
+ return unchangedResult(plan, "unsupported", "unsafe_target");
184
+ }
185
+ }
186
+
187
+ const fingerprintUris = plan.preconditions.affectedContentFingerprints.map(
188
+ (entry) => entry.uri
189
+ );
190
+ const fingerprintSet = new Set(fingerprintUris);
191
+ if (fingerprintSet.size !== fingerprintUris.length) {
192
+ return unchangedResult(plan, "unsupported", "unsafe_target");
193
+ }
194
+ if (fingerprintSet.size !== plan.affectedDocuments.length) {
195
+ return unchangedResult(plan, "unsupported", "unsafe_target");
196
+ }
197
+ for (const doc of plan.affectedDocuments) {
198
+ if (!fingerprintSet.has(doc.uri)) {
199
+ return unchangedResult(plan, "unsupported", "unsafe_target");
200
+ }
201
+ }
202
+
203
+ return null;
204
+ }
205
+
206
+ type PreparedCore = Omit<
207
+ PreparedRefactorFile,
208
+ "stagePath" | "backupPath" | "stageRelPath" | "backupRelPath"
209
+ >;
210
+
211
+ export async function validateLiveRefactorPlan(
212
+ plan: FileRefactorPreviewPlan,
213
+ collectionRoot: string
214
+ ): Promise<
215
+ | { ok: true; files: PreparedCore[] }
216
+ | { ok: false; result: FileRefactorApplyResult }
217
+ > {
218
+ const structural = validateRefactorPlanStructure(plan);
219
+ if (structural) return { ok: false, result: structural };
220
+
221
+ let sourceRel: string;
222
+ let targetRel: string;
223
+ try {
224
+ sourceRel = validateRelPath(plan.source.relPath);
225
+ targetRel = validateRelPath(plan.target.relPath);
226
+ } catch {
227
+ return {
228
+ ok: false,
229
+ result: unchangedResult(plan, "unsupported", "unsafe_target"),
230
+ };
231
+ }
232
+
233
+ const rootReal = await realpathOrNull(normalize(collectionRoot));
234
+ if (!rootReal || !isAbsolute(rootReal)) {
235
+ return {
236
+ ok: false,
237
+ result: unchangedResult(plan, "unsupported", "unsafe_target"),
238
+ };
239
+ }
240
+
241
+ const sourceAbs = resolveCollectionAbsPath(collectionRoot, sourceRel);
242
+ const targetAbs = resolveCollectionAbsPath(collectionRoot, targetRel);
243
+
244
+ if (!(await assertExistingPathContained(rootReal, sourceAbs))) {
245
+ return {
246
+ ok: false,
247
+ result: unchangedResult(plan, "unsupported", "unsafe_target"),
248
+ };
249
+ }
250
+ if (!(await assertTargetPathContained(rootReal, targetAbs))) {
251
+ return {
252
+ ok: false,
253
+ result: unchangedResult(plan, "unsupported", "unsafe_target"),
254
+ };
255
+ }
256
+
257
+ const sourceContent = await Bun.file(sourceAbs)
258
+ .text()
259
+ .catch(() => null);
260
+ if (sourceContent === null) {
261
+ return {
262
+ ok: false,
263
+ result: unchangedResult(plan, "stale_plan", "stale_plan"),
264
+ };
265
+ }
266
+ const sourceFp = await fingerprintUtf8Content(sourceContent);
267
+ if (sourceFp !== plan.preconditions.sourceContentFingerprint) {
268
+ return {
269
+ ok: false,
270
+ result: unchangedResult(plan, "stale_plan", "stale_plan"),
271
+ };
272
+ }
273
+
274
+ const targetExists = await Bun.file(targetAbs).exists();
275
+ const expectedTargetFp = await fingerprintUtf8Content(
276
+ `${plan.target.collection}:${plan.target.relPath}:${targetExists ? "occupied" : "free"}`
277
+ );
278
+ if (expectedTargetFp !== plan.preconditions.targetPathFingerprint) {
279
+ return {
280
+ ok: false,
281
+ result: unchangedResult(
282
+ plan,
283
+ targetExists ? "conflict" : "stale_plan",
284
+ targetExists ? "occupied_target" : "stale_plan"
285
+ ),
286
+ };
287
+ }
288
+ if (targetExists) {
289
+ return {
290
+ ok: false,
291
+ result: unchangedResult(plan, "conflict", "occupied_target"),
292
+ };
293
+ }
294
+
295
+ const contentByUri = new Map<string, string>();
296
+ contentByUri.set(plan.source.uri, sourceContent);
297
+ const files: PreparedCore[] = [];
298
+ const preconditionByUri = new Map(
299
+ plan.preconditions.affectedContentFingerprints.map((entry) => [
300
+ entry.uri,
301
+ entry.fingerprint,
302
+ ])
303
+ );
304
+
305
+ for (const doc of plan.affectedDocuments) {
306
+ let rel: string;
307
+ try {
308
+ rel = validateRelPath(doc.relPath);
309
+ } catch {
310
+ return {
311
+ ok: false,
312
+ result: unchangedResult(plan, "unsupported", "unsafe_target"),
313
+ };
314
+ }
315
+
316
+ const abs = resolveCollectionAbsPath(collectionRoot, rel);
317
+ if (!(await assertExistingPathContained(rootReal, abs))) {
318
+ return {
319
+ ok: false,
320
+ result: unchangedResult(plan, "unsupported", "unsafe_target"),
321
+ };
322
+ }
323
+
324
+ const isSource = doc.uri === plan.source.uri || rel === sourceRel;
325
+ let content = contentByUri.get(doc.uri);
326
+ if (content === undefined) {
327
+ const live = await Bun.file(abs)
328
+ .text()
329
+ .catch(() => null);
330
+ if (live === null) {
331
+ return {
332
+ ok: false,
333
+ result: unchangedResult(plan, "stale_plan", "stale_plan"),
334
+ };
335
+ }
336
+ content = live;
337
+ contentByUri.set(doc.uri, content);
338
+ }
339
+
340
+ const liveFp = await fingerprintUtf8Content(content);
341
+ if (liveFp !== doc.contentFingerprint) {
342
+ return {
343
+ ok: false,
344
+ result: unchangedResult(plan, "stale_plan", "stale_plan"),
345
+ };
346
+ }
347
+ const precondition = preconditionByUri.get(doc.uri);
348
+ if (precondition !== liveFp) {
349
+ return {
350
+ ok: false,
351
+ result: unchangedResult(plan, "stale_plan", "stale_plan"),
352
+ };
353
+ }
354
+
355
+ let finalContent: string;
356
+ try {
357
+ finalContent = applyDestinationEditsDescending(content, doc.edits);
358
+ } catch (cause) {
359
+ if (cause instanceof FileRefactorEditError) {
360
+ return {
361
+ ok: false,
362
+ result: unchangedResult(plan, "stale_plan", "stale_plan"),
363
+ };
364
+ }
365
+ throw cause;
366
+ }
367
+
368
+ if (isSource) {
369
+ contentByUri.set(plan.source.uri, finalContent);
370
+ continue;
371
+ }
372
+
373
+ files.push({
374
+ role: "affected",
375
+ sourceAbsPath: abs,
376
+ targetAbsPath: abs,
377
+ relPath: rel,
378
+ finalContent,
379
+ originalFingerprint: liveFp,
380
+ expectedFingerprint: await fingerprintUtf8Content(finalContent),
381
+ isMove: false,
382
+ });
383
+ }
384
+
385
+ const finalSourceContent = contentByUri.get(plan.source.uri) ?? sourceContent;
386
+ files.push({
387
+ role: "source",
388
+ sourceAbsPath: sourceAbs,
389
+ targetAbsPath: targetAbs,
390
+ relPath: targetRel,
391
+ sourceRelPath: sourceRel,
392
+ finalContent: finalSourceContent,
393
+ originalFingerprint: sourceFp,
394
+ expectedFingerprint: await fingerprintUtf8Content(finalSourceContent),
395
+ isMove: true,
396
+ });
397
+
398
+ return { ok: true, files };
399
+ }
400
+
401
+ export { baseResult };