@gmickel/gno 1.31.0 → 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 (53) hide show
  1. package/README.md +5 -4
  2. package/assets/skill/SKILL.md +23 -0
  3. package/browser-extension/artifacts/{gno-browser-clipper-v1.31.0.zip → gno-browser-clipper-v1.32.0.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +1 -1
  7. package/spec/cli.md +19 -0
  8. package/spec/db/schema.sql +55 -0
  9. package/spec/mcp.md +114 -11
  10. package/spec/output-schemas/file-refactor-apply-result.schema.json +305 -0
  11. package/spec/output-schemas/file-refactor-preview.schema.json +393 -0
  12. package/src/core/document-capabilities.ts +13 -0
  13. package/src/core/file-ops.ts +129 -1
  14. package/src/core/file-refactor-adapter.ts +329 -0
  15. package/src/core/file-refactor-apply-edits.ts +61 -0
  16. package/src/core/file-refactor-apply-fs.ts +512 -0
  17. package/src/core/file-refactor-apply-safety.ts +340 -0
  18. package/src/core/file-refactor-apply-validate.ts +401 -0
  19. package/src/core/file-refactor-contract.ts +486 -0
  20. package/src/core/file-refactor-destination.ts +123 -0
  21. package/src/core/file-refactor-from-snapshot.ts +148 -0
  22. package/src/core/file-refactor-journal-port.ts +150 -0
  23. package/src/core/file-refactor-journal.ts +347 -0
  24. package/src/core/file-refactor-paths.ts +60 -0
  25. package/src/core/file-refactor-plan-classify.ts +208 -0
  26. package/src/core/file-refactor-plan-validate.ts +169 -0
  27. package/src/core/file-refactor-planner-types.ts +62 -0
  28. package/src/core/file-refactor-planner.ts +423 -0
  29. package/src/core/file-refactor-resolve.ts +280 -0
  30. package/src/core/file-refactor-service.ts +468 -0
  31. package/src/core/file-refactors.ts +84 -56
  32. package/src/core/link-destination-parse.ts +275 -0
  33. package/src/core/link-inventory-markdown.ts +454 -0
  34. package/src/core/link-inventory-opaque.ts +244 -0
  35. package/src/core/link-inventory-types.ts +47 -0
  36. package/src/core/link-inventory.ts +182 -0
  37. package/src/core/link-relevance.ts +150 -0
  38. package/src/mcp/tools/index.ts +18 -17
  39. package/src/mcp/tools/workspace-write.ts +215 -97
  40. package/src/sdk/client.ts +167 -115
  41. package/src/sdk/index.ts +7 -0
  42. package/src/sdk/types.ts +32 -2
  43. package/src/serve/file-refactor-http.ts +239 -0
  44. package/src/serve/public/components/RefactorImpactPreview.tsx +227 -0
  45. package/src/serve/public/globals.built.css +1 -1
  46. package/src/serve/public/pages/DocView.tsx +176 -41
  47. package/src/serve/routes/api.ts +191 -104
  48. package/src/store/migrations/026-file-refactor-recovery-journal.ts +72 -0
  49. package/src/store/migrations/index.ts +2 -0
  50. package/src/store/sqlite/adapter.ts +452 -0
  51. package/src/store/sqlite/file-refactor-journal-store.ts +275 -0
  52. package/src/store/types.ts +84 -0
  53. package/browser-extension/artifacts/gno-browser-clipper-v1.31.0.zip.sha256 +0 -1
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared inventory token types and caps for reference-safe refactors.
3
+ *
4
+ * @module src/core/link-inventory-types
5
+ */
6
+
7
+ import type {
8
+ FileRefactorReasonCode,
9
+ FileRefactorReferenceClassification,
10
+ FileRefactorReferenceKind,
11
+ } from "./file-refactor-contract";
12
+ import type { LinkEncodingStyle } from "./link-destination-parse";
13
+
14
+ /** Hard caps — callers must fail closed when truncated. */
15
+ export const LINK_INVENTORY_CAPS = {
16
+ maxContentChars: 1_000_000,
17
+ maxTokensPerDocument: 2_000,
18
+ } as const;
19
+
20
+ export interface LinkInventoryToken {
21
+ kind: FileRefactorReferenceKind;
22
+ classification?: FileRefactorReferenceClassification;
23
+ reasonCode?: FileRefactorReasonCode;
24
+ raw: string;
25
+ originalDestination: string;
26
+ destinationStart: number;
27
+ destinationEnd: number;
28
+ startOffset: number;
29
+ endOffset: number;
30
+ startLine: number;
31
+ startCol: number;
32
+ endLine: number;
33
+ endCol: number;
34
+ targetRef: string;
35
+ targetAnchor?: string;
36
+ targetCollection?: string;
37
+ targetQuery?: string;
38
+ hadLeadingDotSlash: boolean;
39
+ encodingStyle: LinkEncodingStyle;
40
+ }
41
+
42
+ export interface LinkInventoryResult {
43
+ tokens: LinkInventoryToken[];
44
+ truncated: boolean;
45
+ /** True when destination spans overlap after inventory. */
46
+ overlapping: boolean;
47
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Parser-backed link inventory for reference-safe file refactors.
3
+ *
4
+ * Produces exact UTF-16 destination token spans plus conservative opaque /
5
+ * malformed detections. Does not mutate disk or change parseLinks behavior.
6
+ *
7
+ * @module src/core/link-inventory
8
+ */
9
+
10
+ import type { ExcludedRange } from "../ingestion/strip";
11
+ import type {
12
+ LinkInventoryResult,
13
+ LinkInventoryToken,
14
+ } from "./link-inventory-types";
15
+
16
+ import { getExcludedRanges } from "../ingestion/strip";
17
+ import {
18
+ inventoryInlineMarkdown,
19
+ inventoryReferenceDefinitions,
20
+ inventoryWikiLinks,
21
+ } from "./link-inventory-markdown";
22
+ import {
23
+ buildLineOffsets,
24
+ inventoryEmbeds,
25
+ inventoryHtmlHrefs,
26
+ inventoryMalformedWiki,
27
+ } from "./link-inventory-opaque";
28
+ import { LINK_INVENTORY_CAPS } from "./link-inventory-types";
29
+
30
+ export {
31
+ LINK_INVENTORY_CAPS,
32
+ type LinkInventoryResult,
33
+ type LinkInventoryToken,
34
+ } from "./link-inventory-types";
35
+ export {
36
+ buildContentPrefilterNeedles,
37
+ buildSourceRelevanceKeys,
38
+ isRelevantDestination,
39
+ } from "./link-relevance";
40
+
41
+ /**
42
+ * Destination identity for inventory dedupe.
43
+ * Kind is intentionally omitted so identical spans from overlapping scanners
44
+ * collapse to the first (scanner-precedence) token.
45
+ */
46
+ export function inventoryDestinationKey(token: {
47
+ destinationStart: number;
48
+ destinationEnd: number;
49
+ originalDestination: string;
50
+ }): string {
51
+ return `${token.destinationStart}:${token.destinationEnd}:${token.originalDestination}`;
52
+ }
53
+
54
+ /**
55
+ * Dedupe identical destination spans (regardless of scanner kind) and detect
56
+ * true partial overlaps. First token wins — callers should push in scanner order.
57
+ */
58
+ export function dedupeInventoryDestinationTokens(
59
+ tokens: LinkInventoryToken[]
60
+ ): {
61
+ tokens: LinkInventoryToken[];
62
+ overlapping: boolean;
63
+ } {
64
+ const seen = new Set<string>();
65
+ const unique: LinkInventoryToken[] = [];
66
+ for (const token of tokens) {
67
+ const key = inventoryDestinationKey(token);
68
+ if (seen.has(key)) continue;
69
+ seen.add(key);
70
+ unique.push(token);
71
+ }
72
+ unique.sort((a, b) => {
73
+ if (a.destinationStart !== b.destinationStart) {
74
+ return a.destinationStart - b.destinationStart;
75
+ }
76
+ return a.destinationEnd - b.destinationEnd;
77
+ });
78
+ let overlapping = false;
79
+ for (let i = 1; i < unique.length; i += 1) {
80
+ const prev = unique[i - 1]!;
81
+ const cur = unique[i]!;
82
+ if (cur.destinationStart < prev.destinationEnd) {
83
+ overlapping = true;
84
+ break;
85
+ }
86
+ }
87
+ unique.sort((a, b) => {
88
+ if (a.startLine !== b.startLine) return a.startLine - b.startLine;
89
+ if (a.startCol !== b.startCol) return a.startCol - b.startCol;
90
+ return a.destinationStart - b.destinationStart;
91
+ });
92
+ return { tokens: unique, overlapping };
93
+ }
94
+
95
+ /**
96
+ * Inventory all relevant rewrite / opaque reference tokens in one document.
97
+ */
98
+ export function inventoryDocumentLinks(
99
+ markdown: string,
100
+ options: {
101
+ sourceKeys: ReadonlySet<string>;
102
+ excludedRanges?: ExcludedRange[];
103
+ }
104
+ ): LinkInventoryResult {
105
+ const truncated = { value: false };
106
+ if (markdown.length > LINK_INVENTORY_CAPS.maxContentChars) {
107
+ return { tokens: [], truncated: true, overlapping: false };
108
+ }
109
+
110
+ const excluded = options.excludedRanges ?? getExcludedRanges(markdown);
111
+ const lineOffsets = buildLineOffsets(markdown);
112
+ const tokens: LinkInventoryToken[] = [];
113
+ const consumed = new Set<number>();
114
+ const sourceKeys = options.sourceKeys;
115
+
116
+ const ok =
117
+ inventoryEmbeds(
118
+ markdown,
119
+ lineOffsets,
120
+ sourceKeys,
121
+ excluded,
122
+ tokens,
123
+ truncated,
124
+ consumed
125
+ ) &&
126
+ inventoryHtmlHrefs(
127
+ markdown,
128
+ lineOffsets,
129
+ sourceKeys,
130
+ excluded,
131
+ tokens,
132
+ truncated,
133
+ consumed
134
+ ) &&
135
+ inventoryMalformedWiki(
136
+ markdown,
137
+ lineOffsets,
138
+ sourceKeys,
139
+ excluded,
140
+ tokens,
141
+ truncated,
142
+ consumed
143
+ ) &&
144
+ inventoryWikiLinks(
145
+ markdown,
146
+ lineOffsets,
147
+ sourceKeys,
148
+ excluded,
149
+ tokens,
150
+ truncated,
151
+ consumed
152
+ ) &&
153
+ inventoryReferenceDefinitions(
154
+ markdown,
155
+ lineOffsets,
156
+ sourceKeys,
157
+ excluded,
158
+ tokens,
159
+ truncated,
160
+ consumed
161
+ ) &&
162
+ inventoryInlineMarkdown(
163
+ markdown,
164
+ lineOffsets,
165
+ sourceKeys,
166
+ excluded,
167
+ tokens,
168
+ truncated,
169
+ consumed
170
+ );
171
+
172
+ if (!ok) {
173
+ return { tokens, truncated: true, overlapping: false };
174
+ }
175
+
176
+ const finalized = dedupeInventoryDestinationTokens(tokens);
177
+ return {
178
+ tokens: finalized.tokens,
179
+ truncated: truncated.value,
180
+ overlapping: finalized.overlapping,
181
+ };
182
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Conservative destination relevance for reference-safe file refactors.
3
+ *
4
+ * Uses exact token/path/basename equality after stripping query/fragment,
5
+ * safe decoding, NFC normalization, and syntax delimiters. No fuzzy
6
+ * substring matching — unrelated `other-note.md` must not match `note.md`.
7
+ *
8
+ * @module src/core/link-relevance
9
+ */
10
+
11
+ // node:path/posix — no Bun path utils
12
+ import { posix as pathPosix } from "node:path";
13
+
14
+ import {
15
+ stripAngleBracketDestination,
16
+ unescapeCommonMarkDestination,
17
+ } from "./link-destination-parse";
18
+ import { normalizeWikiName, stripWikiMdExt } from "./links";
19
+
20
+ function safeDecodeForRelevance(value: string): string {
21
+ return value
22
+ .replaceAll("%20", " ")
23
+ .replaceAll("%28", "(")
24
+ .replaceAll("%29", ")");
25
+ }
26
+
27
+ function stripQueryAndFragment(value: string): string {
28
+ const hash = value.indexOf("#");
29
+ const withoutHash = hash >= 0 ? value.slice(0, hash) : value;
30
+ const query = withoutHash.indexOf("?");
31
+ return query >= 0 ? withoutHash.slice(0, query) : withoutHash;
32
+ }
33
+
34
+ function addKey(keys: Set<string>, value: string): void {
35
+ const trimmed = value.trim();
36
+ if (!trimmed) return;
37
+ keys.add(normalizeWikiName(trimmed));
38
+ keys.add(normalizeWikiName(stripWikiMdExt(trimmed)));
39
+ keys.add(trimmed.toLowerCase());
40
+ keys.add(stripWikiMdExt(trimmed).toLowerCase());
41
+ const base = pathPosix.basename(trimmed);
42
+ keys.add(normalizeWikiName(base));
43
+ keys.add(normalizeWikiName(stripWikiMdExt(base)));
44
+ keys.add(base.toLowerCase());
45
+ keys.add(stripWikiMdExt(base).toLowerCase());
46
+ }
47
+
48
+ /** Build lookup keys used to decide whether a destination is about the source. */
49
+ export function buildSourceRelevanceKeys(input: {
50
+ relPath: string;
51
+ title: string | null | undefined;
52
+ }): Set<string> {
53
+ const keys = new Set<string>();
54
+ addKey(keys, input.relPath);
55
+ addKey(keys, input.relPath.normalize("NFC"));
56
+ addKey(keys, input.relPath.normalize("NFD"));
57
+ if (input.title) {
58
+ addKey(keys, input.title);
59
+ addKey(keys, input.title.normalize("NFC"));
60
+ addKey(keys, input.title.normalize("NFD"));
61
+ }
62
+ return keys;
63
+ }
64
+
65
+ /**
66
+ * Normalize a destination token into candidate equality keys (exact only).
67
+ */
68
+ export function destinationRelevanceCandidates(destination: string): string[] {
69
+ const trimmed = destination.trim();
70
+ if (!trimmed) return [];
71
+
72
+ const { path: withoutAngles } = stripAngleBracketDestination(trimmed);
73
+ const unescaped = unescapeCommonMarkDestination(withoutAngles);
74
+ const withoutQueryFrag = stripQueryAndFragment(unescaped);
75
+ const decoded = safeDecodeForRelevance(withoutQueryFrag);
76
+
77
+ const forms = [
78
+ trimmed,
79
+ withoutAngles,
80
+ unescaped,
81
+ withoutQueryFrag,
82
+ decoded,
83
+ withoutQueryFrag.normalize("NFC"),
84
+ withoutQueryFrag.normalize("NFD"),
85
+ decoded.normalize("NFC"),
86
+ decoded.normalize("NFD"),
87
+ ];
88
+
89
+ const keys = new Set<string>();
90
+ for (const form of forms) {
91
+ addKey(keys, form);
92
+ }
93
+ return [...keys];
94
+ }
95
+
96
+ /**
97
+ * Exact-token relevance: true only when a normalized destination key equals a
98
+ * source key. Malformed prefixes may still match when the prefix token itself
99
+ * equals a source key (bounded exact equality, not substring).
100
+ */
101
+ export function isRelevantDestination(
102
+ destination: string,
103
+ sourceKeys: ReadonlySet<string>
104
+ ): boolean {
105
+ const candidates = destinationRelevanceCandidates(destination);
106
+ return candidates.some((key) => sourceKeys.has(key));
107
+ }
108
+
109
+ /**
110
+ * Content-prefilter needle strings for SQL LIKE (caller escapes wildcards).
111
+ * Conservative: source path/title identities and common escape encodings.
112
+ */
113
+ export function buildContentPrefilterNeedles(input: {
114
+ relPath: string;
115
+ title: string | null | undefined;
116
+ }): string[] {
117
+ const needles = new Set<string>();
118
+ const add = (value: string): void => {
119
+ const trimmed = value.trim();
120
+ if (!trimmed || trimmed.length < 2) return;
121
+ needles.add(trimmed);
122
+ needles.add(trimmed.normalize("NFC"));
123
+ needles.add(trimmed.normalize("NFD"));
124
+ needles.add(trimmed.replaceAll(" ", "%20"));
125
+ needles.add(
126
+ trimmed
127
+ .replaceAll(" ", "%20")
128
+ .replaceAll("(", "%28")
129
+ .replaceAll(")", "%29")
130
+ );
131
+ needles.add(trimmed.replaceAll("(", "%28").replaceAll(")", "%29"));
132
+ needles.add(
133
+ trimmed
134
+ .replaceAll(" ", "\\ ")
135
+ .replaceAll("(", "\\(")
136
+ .replaceAll(")", "\\)")
137
+ );
138
+ needles.add(trimmed.replaceAll("(", "\\(").replaceAll(")", "\\)"));
139
+ };
140
+
141
+ add(input.relPath);
142
+ add(pathPosix.basename(input.relPath));
143
+ add(stripWikiMdExt(pathPosix.basename(input.relPath)));
144
+ add(stripWikiMdExt(input.relPath));
145
+ if (input.title) {
146
+ add(input.title);
147
+ add(`${input.title}.md`);
148
+ }
149
+ return [...needles].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
150
+ }
@@ -95,6 +95,10 @@ import {
95
95
  handleDuplicateNote,
96
96
  handleMoveNote,
97
97
  handleRenameNote,
98
+ MOVE_NOTE_MCP_ANNOTATIONS,
99
+ moveNoteInputSchema,
100
+ RENAME_NOTE_MCP_ANNOTATIONS,
101
+ renameNoteInputSchema,
98
102
  } from "./workspace-write";
99
103
 
100
104
  // ─────────────────────────────────────────────────────────────────────────────
@@ -398,17 +402,6 @@ const createFolderInputSchema = z.object({
398
402
  parentPath: z.string().optional(),
399
403
  });
400
404
 
401
- const renameNoteInputSchema = z.object({
402
- ref: z.string().min(1, "ref cannot be empty"),
403
- name: z.string().min(1, "name cannot be empty"),
404
- });
405
-
406
- const moveNoteInputSchema = z.object({
407
- ref: z.string().min(1, "ref cannot be empty"),
408
- folderPath: z.string().min(1, "folderPath cannot be empty"),
409
- name: z.string().optional(),
410
- });
411
-
412
405
  const duplicateNoteInputSchema = z.object({
413
406
  ref: z.string().min(1, "ref cannot be empty"),
414
407
  folderPath: z.string().optional(),
@@ -1351,17 +1344,25 @@ export function registerTools(server: McpServer, ctx: ToolContext): void {
1351
1344
  (args) => handleCreateFolder(args, ctx)
1352
1345
  );
1353
1346
 
1354
- server.tool(
1347
+ server.registerTool(
1355
1348
  "gno_rename_note",
1356
- "Rename an editable note in place.",
1357
- renameNoteInputSchema.shape,
1349
+ {
1350
+ description:
1351
+ 'Preview or apply a reference-safe rename of an editable note. Use action=preview first, then action=apply with the exact planDigest, confirmation="apply", confirm=true, and schemaVersion="1.0". Annotations are hints only; --enable-write remains authoritative.',
1352
+ inputSchema: renameNoteInputSchema,
1353
+ annotations: RENAME_NOTE_MCP_ANNOTATIONS,
1354
+ },
1358
1355
  (args) => handleRenameNote(args, ctx)
1359
1356
  );
1360
1357
 
1361
- server.tool(
1358
+ server.registerTool(
1362
1359
  "gno_move_note",
1363
- "Move an editable note to another folder in the same collection.",
1364
- moveNoteInputSchema.shape,
1360
+ {
1361
+ description:
1362
+ 'Preview or apply a reference-safe same-collection move of an editable note. Use action=preview first, then action=apply with the exact planDigest, confirmation="apply", confirm=true, and schemaVersion="1.0". Annotations are hints only; --enable-write remains authoritative.',
1363
+ inputSchema: moveNoteInputSchema,
1364
+ annotations: MOVE_NOTE_MCP_ANNOTATIONS,
1365
+ },
1365
1366
  (args) => handleMoveNote(args, ctx)
1366
1367
  );
1367
1368