@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.
- package/README.md +6 -5
- package/assets/skill/SKILL.md +25 -0
- package/assets/skill/mcp-reference.md +6 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.30.7.zip → gno-browser-clipper-v1.32.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +19 -0
- package/spec/db/schema.sql +55 -0
- package/spec/mcp.md +176 -11
- package/spec/output-schemas/file-refactor-apply-result.schema.json +305 -0
- package/spec/output-schemas/file-refactor-preview.schema.json +393 -0
- package/spec/output-schemas/section-target-create-result.schema.json +20 -0
- package/spec/output-schemas/section-target-resolve-result.schema.json +194 -0
- package/spec/output-schemas/section-target.schema.json +118 -0
- package/spec/output-schemas/section.schema.json +113 -0
- package/src/core/document-capabilities.ts +13 -0
- package/src/core/file-ops.ts +129 -1
- package/src/core/file-refactor-adapter.ts +329 -0
- package/src/core/file-refactor-apply-edits.ts +61 -0
- package/src/core/file-refactor-apply-fs.ts +512 -0
- package/src/core/file-refactor-apply-safety.ts +340 -0
- package/src/core/file-refactor-apply-validate.ts +401 -0
- package/src/core/file-refactor-contract.ts +486 -0
- package/src/core/file-refactor-destination.ts +123 -0
- package/src/core/file-refactor-from-snapshot.ts +148 -0
- package/src/core/file-refactor-journal-port.ts +150 -0
- package/src/core/file-refactor-journal.ts +347 -0
- package/src/core/file-refactor-paths.ts +60 -0
- package/src/core/file-refactor-plan-classify.ts +208 -0
- package/src/core/file-refactor-plan-validate.ts +169 -0
- package/src/core/file-refactor-planner-types.ts +62 -0
- package/src/core/file-refactor-planner.ts +423 -0
- package/src/core/file-refactor-resolve.ts +280 -0
- package/src/core/file-refactor-service.ts +468 -0
- package/src/core/file-refactors.ts +84 -56
- package/src/core/link-destination-parse.ts +275 -0
- package/src/core/link-inventory-markdown.ts +454 -0
- package/src/core/link-inventory-opaque.ts +244 -0
- package/src/core/link-inventory-types.ts +47 -0
- package/src/core/link-inventory.ts +182 -0
- package/src/core/link-relevance.ts +150 -0
- package/src/core/section-parse.ts +187 -0
- package/src/core/section-target-link.ts +154 -0
- package/src/core/section-target-resolve.ts +351 -0
- package/src/core/section-target-transport.ts +519 -0
- package/src/core/section-target.ts +263 -0
- package/src/core/sections.ts +60 -115
- package/src/mcp/AGENTS.md +1 -0
- package/src/mcp/CLAUDE.md +1 -0
- package/src/mcp/http-egress.ts +1 -0
- package/src/mcp/tools/index.ts +37 -17
- package/src/mcp/tools/sections.ts +512 -0
- package/src/mcp/tools/workspace-write.ts +215 -97
- package/src/sdk/client.ts +238 -116
- package/src/sdk/index.ts +12 -0
- package/src/sdk/types.ts +61 -3
- package/src/serve/file-refactor-http.ts +239 -0
- package/src/serve/public/components/RefactorImpactPreview.tsx +227 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/lib/section-links.ts +189 -0
- package/src/serve/public/pages/DocView.tsx +395 -77
- package/src/serve/routes/api.ts +191 -104
- package/src/serve/routes/section-targets.ts +221 -0
- package/src/serve/server.ts +34 -0
- package/src/store/migrations/026-file-refactor-recovery-journal.ts +72 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +452 -0
- package/src/store/sqlite/file-refactor-journal-store.ts +275 -0
- package/src/store/types.ts +84 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.30.7.zip.sha256 +0 -1
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact path, journal-id, and owned-directory safety for refactor apply.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/file-refactor-apply-safety
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// node:fs/promises — mkdir/realpath/lstat/rmdir; no Bun equivalents for structure ops
|
|
8
|
+
import { lstat, mkdir, realpath, rmdir } from "node:fs/promises";
|
|
9
|
+
// node:path — no Bun path utils
|
|
10
|
+
import { dirname, join, normalize } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { removePathRequired } from "./file-ops";
|
|
13
|
+
import { isCanonicalPathContained, validateRelPath } from "./validation";
|
|
14
|
+
|
|
15
|
+
const JOURNAL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
16
|
+
const FINGERPRINT_RE = /^[a-f0-9]{64}$/;
|
|
17
|
+
const STAGE_ARTIFACT_BASE_RE =
|
|
18
|
+
/^.+\.gno-rf-stage\.[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
19
|
+
const BACKUP_ARTIFACT_BASE_RE =
|
|
20
|
+
/^.+\.gno-rf-backup\.[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
21
|
+
|
|
22
|
+
function isEnoent(error: unknown): boolean {
|
|
23
|
+
return (
|
|
24
|
+
error instanceof Error &&
|
|
25
|
+
"code" in error &&
|
|
26
|
+
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isEexist(error: unknown): boolean {
|
|
31
|
+
return (
|
|
32
|
+
error instanceof Error &&
|
|
33
|
+
"code" in error &&
|
|
34
|
+
(error as NodeJS.ErrnoException).code === "EEXIST"
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Bounded token used in sibling artifact filenames (default UUID passes). */
|
|
39
|
+
export function validateRefactorJournalId(journalId: string): string {
|
|
40
|
+
if (!JOURNAL_ID_RE.test(journalId)) {
|
|
41
|
+
throw new Error("Invalid refactor journal id");
|
|
42
|
+
}
|
|
43
|
+
return journalId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isRefactorFingerprint(value: string): boolean {
|
|
47
|
+
return FINGERPRINT_RE.test(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Canonical collection-relative artifact path with the expected marker.
|
|
52
|
+
* Rejects absolute/traversal/corrupt paths without touching the filesystem.
|
|
53
|
+
*/
|
|
54
|
+
export function validateRefactorArtifactRelPath(
|
|
55
|
+
relPath: string,
|
|
56
|
+
kind: "stage" | "backup"
|
|
57
|
+
): string {
|
|
58
|
+
const canonical = validateRelPath(relPath);
|
|
59
|
+
const base = canonical.split("/").pop() ?? "";
|
|
60
|
+
const ok =
|
|
61
|
+
kind === "stage"
|
|
62
|
+
? STAGE_ARTIFACT_BASE_RE.test(base)
|
|
63
|
+
: BACKUP_ARTIFACT_BASE_RE.test(base);
|
|
64
|
+
if (!ok) {
|
|
65
|
+
throw new Error(`Invalid refactor ${kind} artifact relPath`);
|
|
66
|
+
}
|
|
67
|
+
return canonical;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function tryValidateRefactorArtifactRelPath(
|
|
71
|
+
relPath: string,
|
|
72
|
+
kind: "stage" | "backup"
|
|
73
|
+
): string | null {
|
|
74
|
+
try {
|
|
75
|
+
return validateRefactorArtifactRelPath(relPath, kind);
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function realpathOrNull(path: string): Promise<string | null> {
|
|
82
|
+
try {
|
|
83
|
+
return await realpath(path);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Re-check that an absolute path stays inside the collection after realpath.
|
|
91
|
+
* Used after creating target directories and before exclusive target create.
|
|
92
|
+
*/
|
|
93
|
+
export async function assertContainedExistingPath(
|
|
94
|
+
collectionRootAbs: string,
|
|
95
|
+
absPath: string
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const rootReal = await realpathOrNull(normalize(collectionRootAbs));
|
|
98
|
+
if (!rootReal) {
|
|
99
|
+
throw new Error("Collection root is not resolvable");
|
|
100
|
+
}
|
|
101
|
+
const real = await realpathOrNull(absPath);
|
|
102
|
+
if (!real || !isCanonicalPathContained(rootReal, real)) {
|
|
103
|
+
throw new Error("Path escapes collection root");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* TOCTOU recheck: live file and its parent must both realpath inside the root.
|
|
109
|
+
* Parent-directory symlink swaps after initial validation fail closed here.
|
|
110
|
+
*/
|
|
111
|
+
export async function assertContainedLiveFileAndParent(
|
|
112
|
+
collectionRootAbs: string,
|
|
113
|
+
fileAbsPath: string
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
await assertContainedExistingPath(collectionRootAbs, fileAbsPath);
|
|
116
|
+
await assertContainedExistingPath(collectionRootAbs, dirname(fileAbsPath));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Classify a final live file for post-commit/rollback verification.
|
|
121
|
+
* Symlink identity is never accepted: the directory entry must be a real file
|
|
122
|
+
* whose path and parent both canonicalize inside the collection root.
|
|
123
|
+
*/
|
|
124
|
+
export async function classifyContainedNonSymlinkFile(
|
|
125
|
+
collectionRootAbs: string,
|
|
126
|
+
fileAbsPath: string
|
|
127
|
+
): Promise<"missing" | "contained" | "unsafe"> {
|
|
128
|
+
const rootReal = await realpathOrNull(normalize(collectionRootAbs));
|
|
129
|
+
if (!rootReal) return "unsafe";
|
|
130
|
+
try {
|
|
131
|
+
const info = await lstat(fileAbsPath);
|
|
132
|
+
if (info.isSymbolicLink() || !info.isFile()) return "unsafe";
|
|
133
|
+
const real = await realpath(fileAbsPath);
|
|
134
|
+
if (!isCanonicalPathContained(rootReal, real)) return "unsafe";
|
|
135
|
+
const parentReal = await realpath(dirname(fileAbsPath));
|
|
136
|
+
if (!isCanonicalPathContained(rootReal, parentReal)) return "unsafe";
|
|
137
|
+
return "contained";
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (isEnoent(error)) return "missing";
|
|
140
|
+
return "unsafe";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Directory-entry presence without following symlinks.
|
|
146
|
+
* Any existing entry (file/dir/symlink) is present; unprovable errors are unsafe.
|
|
147
|
+
*/
|
|
148
|
+
export async function classifyPathPresence(
|
|
149
|
+
absPath: string
|
|
150
|
+
): Promise<"missing" | "present" | "unsafe"> {
|
|
151
|
+
try {
|
|
152
|
+
await lstat(absPath);
|
|
153
|
+
return "present";
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (isEnoent(error)) return "missing";
|
|
156
|
+
return "unsafe";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Classify whether an existing path is missing, contained, or unprovable/unsafe.
|
|
162
|
+
* Used by receipt cleanup before unlink (parent symlink escape defense).
|
|
163
|
+
*/
|
|
164
|
+
export async function classifyContainedExistingPath(
|
|
165
|
+
collectionRootAbs: string,
|
|
166
|
+
absPath: string
|
|
167
|
+
): Promise<"missing" | "contained" | "unsafe"> {
|
|
168
|
+
const rootReal = await realpathOrNull(normalize(collectionRootAbs));
|
|
169
|
+
if (!rootReal) return "unsafe";
|
|
170
|
+
try {
|
|
171
|
+
const real = await realpath(absPath);
|
|
172
|
+
return isCanonicalPathContained(rootReal, real) ? "contained" : "unsafe";
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (isEnoent(error)) return "missing";
|
|
175
|
+
return "unsafe";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Create missing parents of targetAbsPath. Returns only directories this call
|
|
181
|
+
* created (shallow→deep). Pre-existing directories are never owned/removed.
|
|
182
|
+
*/
|
|
183
|
+
export async function ensureContainedTargetParents(
|
|
184
|
+
targetAbsPath: string,
|
|
185
|
+
collectionRootAbs: string
|
|
186
|
+
): Promise<string[]> {
|
|
187
|
+
const rootReal = await realpathOrNull(normalize(collectionRootAbs));
|
|
188
|
+
if (!rootReal) {
|
|
189
|
+
throw new Error("Collection root is not resolvable");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const targetParent = dirname(targetAbsPath);
|
|
193
|
+
const missing: string[] = [];
|
|
194
|
+
let cursor = targetParent;
|
|
195
|
+
while (true) {
|
|
196
|
+
try {
|
|
197
|
+
await lstat(cursor);
|
|
198
|
+
break;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (!isEnoent(error)) throw error;
|
|
201
|
+
missing.push(cursor);
|
|
202
|
+
const parent = dirname(cursor);
|
|
203
|
+
if (parent === cursor) break;
|
|
204
|
+
cursor = parent;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const existingReal = await realpathOrNull(cursor);
|
|
209
|
+
if (!existingReal || !isCanonicalPathContained(rootReal, existingReal)) {
|
|
210
|
+
throw new Error("Target parent escapes collection root");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const created: string[] = [];
|
|
214
|
+
for (const dir of missing.reverse()) {
|
|
215
|
+
try {
|
|
216
|
+
// Single-level create so ownership is exact (no recursive surprise).
|
|
217
|
+
await mkdir(dir, { recursive: false });
|
|
218
|
+
created.push(dir);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (!isEexist(error)) {
|
|
221
|
+
await removeOwnedEmptyDirs(created, collectionRootAbs);
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
// Race: directory appeared — not owned by this transaction.
|
|
225
|
+
}
|
|
226
|
+
const dirReal = await realpathOrNull(dir);
|
|
227
|
+
if (!dirReal || !isCanonicalPathContained(rootReal, dirReal)) {
|
|
228
|
+
await removeOwnedEmptyDirs(created, collectionRootAbs);
|
|
229
|
+
throw new Error("Target parent escaped collection after create");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await assertContainedExistingPath(collectionRootAbs, targetParent);
|
|
234
|
+
return created;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export type RemoveOwnedEmptyDirsResult = {
|
|
238
|
+
ok: boolean;
|
|
239
|
+
/** Dirs that remain after a non-ENOENT removal failure. */
|
|
240
|
+
failed: string[];
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
export type RemoveOwnedEmptyDirsDeps = {
|
|
244
|
+
/** Injectable rmdir for permission/failure seams in tests. */
|
|
245
|
+
rmdir?: typeof rmdir;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Remove owned dirs deepest-first, and only when empty. Never force-remove.
|
|
250
|
+
* Before each rmdir, the directory and its parent must canonicalize inside
|
|
251
|
+
* collectionRoot and the entry must be a real directory (not a symlink).
|
|
252
|
+
* Unprovable/outside/symlink paths are left untouched and reported failed so
|
|
253
|
+
* callers cannot claim rolled_back after escaping through a swapped parent.
|
|
254
|
+
* ENOENT is success (already gone / external race).
|
|
255
|
+
*/
|
|
256
|
+
export async function removeOwnedEmptyDirs(
|
|
257
|
+
createdDirAbsPaths: string[],
|
|
258
|
+
collectionRootAbs: string,
|
|
259
|
+
deps: RemoveOwnedEmptyDirsDeps = {}
|
|
260
|
+
): Promise<RemoveOwnedEmptyDirsResult> {
|
|
261
|
+
const remove = deps.rmdir ?? rmdir;
|
|
262
|
+
const failed: string[] = [];
|
|
263
|
+
const rootReal = await realpathOrNull(normalize(collectionRootAbs));
|
|
264
|
+
if (!rootReal) {
|
|
265
|
+
return { ok: false, failed: [...createdDirAbsPaths] };
|
|
266
|
+
}
|
|
267
|
+
for (const dir of [...createdDirAbsPaths].reverse()) {
|
|
268
|
+
try {
|
|
269
|
+
const info = await lstat(dir);
|
|
270
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
271
|
+
failed.push(dir);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const dirReal = await realpath(dir);
|
|
275
|
+
const parentReal = await realpath(dirname(dir));
|
|
276
|
+
if (
|
|
277
|
+
!isCanonicalPathContained(rootReal, dirReal) ||
|
|
278
|
+
!isCanonicalPathContained(rootReal, parentReal)
|
|
279
|
+
) {
|
|
280
|
+
failed.push(dir);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
await remove(dir);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (isEnoent(error)) continue;
|
|
286
|
+
failed.push(dir);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { ok: failed.length === 0, failed };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function resolveCollectionAbsPath(
|
|
293
|
+
collectionRoot: string,
|
|
294
|
+
relPath: string
|
|
295
|
+
): string {
|
|
296
|
+
return join(collectionRoot, ...relPath.split("/"));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Required receipt-artifact cleanup. Invalid/traversal paths fail closed
|
|
301
|
+
* without touching outside. Before unlink, the artifact parent must realpath
|
|
302
|
+
* inside the collection (parent symlink escape). Missing parent/path is safe
|
|
303
|
+
* ENOENT. Unprovable/outside parents return false without unlinking.
|
|
304
|
+
*/
|
|
305
|
+
export async function cleanupReceiptArtifacts(
|
|
306
|
+
collectionRoot: string,
|
|
307
|
+
stageRelPaths: Array<string | undefined>,
|
|
308
|
+
backupRelPaths: Array<string | undefined>,
|
|
309
|
+
removeFn: typeof removePathRequired = removePathRequired
|
|
310
|
+
): Promise<boolean> {
|
|
311
|
+
try {
|
|
312
|
+
const removeOne = async (
|
|
313
|
+
rel: string,
|
|
314
|
+
kind: "stage" | "backup"
|
|
315
|
+
): Promise<boolean> => {
|
|
316
|
+
const safe = tryValidateRefactorArtifactRelPath(rel, kind);
|
|
317
|
+
if (!safe) return false;
|
|
318
|
+
const abs = resolveCollectionAbsPath(collectionRoot, safe);
|
|
319
|
+
const parentStatus = await classifyContainedExistingPath(
|
|
320
|
+
collectionRoot,
|
|
321
|
+
dirname(abs)
|
|
322
|
+
);
|
|
323
|
+
if (parentStatus === "missing") return true;
|
|
324
|
+
if (parentStatus === "unsafe") return false;
|
|
325
|
+
await removeFn(abs);
|
|
326
|
+
return true;
|
|
327
|
+
};
|
|
328
|
+
for (const rel of stageRelPaths) {
|
|
329
|
+
if (!rel) continue;
|
|
330
|
+
if (!(await removeOne(rel, "stage"))) return false;
|
|
331
|
+
}
|
|
332
|
+
for (const rel of backupRelPaths) {
|
|
333
|
+
if (!rel) continue;
|
|
334
|
+
if (!(await removeOne(rel, "backup"))) return false;
|
|
335
|
+
}
|
|
336
|
+
return true;
|
|
337
|
+
} catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|