@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,208 @@
1
+ /**
2
+ * Per-token classification for reference-safe file refactor planning.
3
+ *
4
+ * @module src/core/file-refactor-plan-classify
5
+ */
6
+
7
+ import type {
8
+ FileRefactorDestinationSpan,
9
+ FileRefactorExaminedReference,
10
+ FileRefactorReasonCode,
11
+ } from "./file-refactor-contract";
12
+ import type { FileRefactorPlannerDocument } from "./file-refactor-planner-types";
13
+ import type { FileRefactorCatalogDocument } from "./file-refactor-resolve";
14
+ import type { LinkInventoryToken } from "./link-inventory-types";
15
+
16
+ import {
17
+ computeMarkdownReplacementDestination,
18
+ computeWikiReplacementDestination,
19
+ wikiDestinationUnchangedAcceptable,
20
+ } from "./file-refactor-destination";
21
+ import {
22
+ normalizeInventoryMarkdownTarget,
23
+ resolveMarkdownTarget,
24
+ resolveWikiTarget,
25
+ } from "./file-refactor-resolve";
26
+
27
+ function spanFromToken(
28
+ token: LinkInventoryToken,
29
+ replacement: string
30
+ ): FileRefactorDestinationSpan {
31
+ return {
32
+ coordinateSpace: "utf16_code_units",
33
+ startOffset: token.destinationStart,
34
+ endOffset: token.destinationEnd,
35
+ originalDestination: token.originalDestination,
36
+ replacementDestination: replacement,
37
+ };
38
+ }
39
+
40
+ export function examinedFromToken(
41
+ doc: FileRefactorPlannerDocument,
42
+ token: LinkInventoryToken,
43
+ overrides: Partial<FileRefactorExaminedReference> = {}
44
+ ): FileRefactorExaminedReference {
45
+ return {
46
+ documentUri: doc.uri,
47
+ documentRelPath: doc.relPath,
48
+ kind: token.kind,
49
+ classification: token.classification ?? "unchanged",
50
+ reasonCode: token.reasonCode,
51
+ originalDestination: token.originalDestination,
52
+ proposedDestination:
53
+ overrides.proposedDestination ?? token.originalDestination,
54
+ startLine: token.startLine,
55
+ startCol: token.startCol,
56
+ endLine: token.endLine,
57
+ endCol: token.endCol,
58
+ ...overrides,
59
+ };
60
+ }
61
+
62
+ function capabilityDenied(
63
+ doc: FileRefactorPlannerDocument,
64
+ token: LinkInventoryToken
65
+ ): FileRefactorExaminedReference {
66
+ return examinedFromToken(doc, token, {
67
+ classification: "unsupported",
68
+ reasonCode:
69
+ doc.editableReason === "read_only_document"
70
+ ? "read_only_document"
71
+ : "capability_denied",
72
+ proposedDestination: token.originalDestination,
73
+ edit: undefined,
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Classify one inventory token against the moved source. Returns null when the
79
+ * token uniquely resolves elsewhere (not about this refactor).
80
+ */
81
+ export function classifyResolvableToken(input: {
82
+ token: LinkInventoryToken;
83
+ doc: FileRefactorPlannerDocument;
84
+ sourceUri: string;
85
+ sourceRelPath: string;
86
+ sourceTitle: string | null | undefined;
87
+ targetRelPath: string;
88
+ targetTitle: string | null | undefined;
89
+ catalog: FileRefactorCatalogDocument[];
90
+ }): FileRefactorExaminedReference | null {
91
+ const { token, doc } = input;
92
+
93
+ if (token.classification) {
94
+ return examinedFromToken(doc, token, {
95
+ classification: token.classification,
96
+ reasonCode: token.reasonCode,
97
+ });
98
+ }
99
+
100
+ if (token.targetCollection && token.targetCollection !== doc.collection) {
101
+ return examinedFromToken(doc, token, {
102
+ classification: "unsupported",
103
+ reasonCode: "cross_collection_unsupported",
104
+ });
105
+ }
106
+
107
+ if (token.kind === "wiki") {
108
+ const resolution = resolveWikiTarget({
109
+ targetRef: token.targetRef,
110
+ targetCollection: token.targetCollection ?? doc.collection,
111
+ sourceUri: input.sourceUri,
112
+ catalog: input.catalog,
113
+ });
114
+ if (
115
+ resolution.status === "elsewhere" ||
116
+ resolution.status === "unresolved"
117
+ ) {
118
+ return null;
119
+ }
120
+ if (resolution.status === "ambiguous") {
121
+ return examinedFromToken(doc, token, {
122
+ classification: "ambiguous",
123
+ reasonCode: resolution.reasonCode ?? "ambiguous_resolution",
124
+ });
125
+ }
126
+ if (doc.editable === false) {
127
+ return capabilityDenied(doc, token);
128
+ }
129
+ const replacement = computeWikiReplacementDestination({
130
+ originalDestination: token.originalDestination,
131
+ sourceRelPath: input.sourceRelPath,
132
+ sourceTitle: input.sourceTitle,
133
+ targetRelPath: input.targetRelPath,
134
+ targetTitle: input.targetTitle,
135
+ });
136
+ if (
137
+ wikiDestinationUnchangedAcceptable({
138
+ originalDestination: token.originalDestination,
139
+ replacementDestination: replacement,
140
+ })
141
+ ) {
142
+ return examinedFromToken(doc, token, {
143
+ classification: "unchanged",
144
+ reasonCode: "destination_unchanged",
145
+ proposedDestination: token.originalDestination,
146
+ });
147
+ }
148
+ return examinedFromToken(doc, token, {
149
+ classification: "rewriteable",
150
+ proposedDestination: replacement,
151
+ edit: spanFromToken(token, replacement),
152
+ });
153
+ }
154
+
155
+ const normalized = normalizeInventoryMarkdownTarget(
156
+ token.targetRef,
157
+ doc.relPath
158
+ );
159
+ if (!normalized) {
160
+ return examinedFromToken(doc, token, {
161
+ classification: "invalid",
162
+ reasonCode: "unsafe_target",
163
+ });
164
+ }
165
+ const resolution = resolveMarkdownTarget({
166
+ targetRefNorm: normalized,
167
+ targetCollection: doc.collection,
168
+ sourceUri: input.sourceUri,
169
+ catalog: input.catalog,
170
+ });
171
+ if (resolution.status === "elsewhere" || resolution.status === "unresolved") {
172
+ return null;
173
+ }
174
+ if (resolution.status === "ambiguous") {
175
+ return examinedFromToken(doc, token, {
176
+ classification: "ambiguous",
177
+ reasonCode: resolution.reasonCode ?? "ambiguous_resolution",
178
+ });
179
+ }
180
+ if (doc.editable === false) {
181
+ return capabilityDenied(doc, token);
182
+ }
183
+
184
+ const replacement = computeMarkdownReplacementDestination({
185
+ token,
186
+ referringRelPath: doc.relPath,
187
+ targetRelPath: input.targetRelPath,
188
+ });
189
+ const reasonCode: FileRefactorReasonCode =
190
+ token.kind === "markdown_definition"
191
+ ? "reference_definition_site"
192
+ : "relative_path_recalculated";
193
+
194
+ if (replacement === token.originalDestination) {
195
+ return examinedFromToken(doc, token, {
196
+ classification: "unchanged",
197
+ reasonCode: "destination_unchanged",
198
+ proposedDestination: token.originalDestination,
199
+ });
200
+ }
201
+
202
+ return examinedFromToken(doc, token, {
203
+ classification: "rewriteable",
204
+ reasonCode,
205
+ proposedDestination: replacement,
206
+ edit: spanFromToken(token, replacement),
207
+ });
208
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Input validation for reference-safe file refactor planning.
3
+ *
4
+ * Fail closed with stable diagnostics — never throw after a partial plan.
5
+ *
6
+ * @module src/core/file-refactor-plan-validate
7
+ */
8
+
9
+ // node:path/posix — no Bun path utils
10
+ import { posix as pathPosix } from "node:path";
11
+
12
+ import type {
13
+ FileRefactorExaminedReference,
14
+ FileRefactorOperation,
15
+ FileRefactorReasonCode,
16
+ } from "./file-refactor-contract";
17
+
18
+ import { parseUri } from "../app/constants";
19
+
20
+ export interface FileRefactorPlanValidationFailure {
21
+ canApply: false;
22
+ reasonCode: FileRefactorReasonCode;
23
+ diagnostic: string;
24
+ examined: FileRefactorExaminedReference[];
25
+ }
26
+
27
+ function pathBasename(relPath: string): string {
28
+ const idx = relPath.lastIndexOf("/");
29
+ return idx >= 0 ? relPath.slice(idx + 1) : relPath;
30
+ }
31
+
32
+ function pathDirname(relPath: string): string {
33
+ const idx = relPath.lastIndexOf("/");
34
+ return idx >= 0 ? relPath.slice(0, idx) : "";
35
+ }
36
+
37
+ function isUnsafeRelPath(relPath: string): string | null {
38
+ if (!relPath || !relPath.trim()) return "empty_path";
39
+ if (relPath.includes("\0")) return "null_byte";
40
+ if (relPath.includes("\\")) return "backslash_path";
41
+ if (pathPosix.isAbsolute(relPath) || relPath.startsWith("/")) {
42
+ return "absolute_path";
43
+ }
44
+ if (relPath.endsWith("/")) return "non_file_path";
45
+ const segments = relPath.split("/");
46
+ if (segments.some((segment) => segment.length === 0)) {
47
+ return "non_canonical_path";
48
+ }
49
+ if (segments.some((segment) => segment === ".")) {
50
+ return "dot_component";
51
+ }
52
+ if (segments.some((segment) => segment === "..")) {
53
+ return "path_traversal";
54
+ }
55
+ const normalized = pathPosix.normalize(relPath);
56
+ if (normalized !== relPath) return "non_canonical_path";
57
+ if (normalized.startsWith("..") || normalized.split("/").includes("..")) {
58
+ return "path_traversal";
59
+ }
60
+ const base = pathBasename(relPath);
61
+ if (!base || base === "." || base === "..") return "non_file_path";
62
+ return null;
63
+ }
64
+
65
+ function uriMatches(uri: string, collection: string, relPath: string): boolean {
66
+ const parsed = parseUri(uri);
67
+ if (!parsed) return false;
68
+ return parsed.collection === collection && parsed.path === relPath;
69
+ }
70
+
71
+ /**
72
+ * Validate source/target coherence before computing replacements.
73
+ * Returns a failure descriptor, or null when inputs are safe to plan.
74
+ */
75
+ export function validateFileRefactorPlanInputs(input: {
76
+ operation: FileRefactorOperation;
77
+ source: {
78
+ uri: string;
79
+ relPath: string;
80
+ collection: string;
81
+ };
82
+ target: {
83
+ uri: string;
84
+ relPath: string;
85
+ collection: string;
86
+ };
87
+ }): FileRefactorPlanValidationFailure | null {
88
+ const sourceUnsafe = isUnsafeRelPath(input.source.relPath);
89
+ if (sourceUnsafe) {
90
+ return failure(
91
+ input,
92
+ "unsafe_target",
93
+ `invalid_source_path:${sourceUnsafe}`
94
+ );
95
+ }
96
+ const targetUnsafe = isUnsafeRelPath(input.target.relPath);
97
+ if (targetUnsafe) {
98
+ return failure(
99
+ input,
100
+ "unsafe_target",
101
+ `invalid_target_path:${targetUnsafe}`
102
+ );
103
+ }
104
+
105
+ if (input.source.collection !== input.target.collection) {
106
+ return failure(
107
+ input,
108
+ "cross_collection_unsupported",
109
+ "cross_collection_target"
110
+ );
111
+ }
112
+
113
+ if (
114
+ input.source.relPath === input.target.relPath ||
115
+ input.source.uri === input.target.uri
116
+ ) {
117
+ return failure(input, "unsafe_target", "source_matches_target");
118
+ }
119
+
120
+ if (
121
+ !uriMatches(input.source.uri, input.source.collection, input.source.relPath)
122
+ ) {
123
+ return failure(input, "unsafe_target", "source_uri_path_mismatch");
124
+ }
125
+ if (
126
+ !uriMatches(input.target.uri, input.target.collection, input.target.relPath)
127
+ ) {
128
+ return failure(input, "unsafe_target", "target_uri_path_mismatch");
129
+ }
130
+
131
+ if (input.operation === "rename") {
132
+ if (
133
+ pathDirname(input.source.relPath) !== pathDirname(input.target.relPath)
134
+ ) {
135
+ return failure(input, "unsafe_target", "rename_directory_changed");
136
+ }
137
+ }
138
+
139
+ return null;
140
+ }
141
+
142
+ function failure(
143
+ input: {
144
+ source: { uri: string; relPath: string };
145
+ },
146
+ reasonCode: FileRefactorReasonCode,
147
+ diagnostic: string
148
+ ): FileRefactorPlanValidationFailure {
149
+ return {
150
+ canApply: false,
151
+ reasonCode,
152
+ diagnostic,
153
+ examined: [
154
+ {
155
+ documentUri: input.source.uri,
156
+ documentRelPath: input.source.relPath,
157
+ kind: "opaque",
158
+ classification:
159
+ reasonCode === "cross_collection_unsupported"
160
+ ? "unsupported"
161
+ : "invalid",
162
+ reasonCode,
163
+ originalDestination: diagnostic,
164
+ },
165
+ ],
166
+ };
167
+ }
168
+
169
+ export { pathBasename, pathDirname };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Shared planner document / input types for reference-safe refactors.
3
+ *
4
+ * @module src/core/file-refactor-planner-types
5
+ */
6
+
7
+ import type { FileRefactorOperation } from "./file-refactor-contract";
8
+
9
+ export const FILE_REFACTOR_PLANNER_CAPS = {
10
+ maxCatalogDocuments: 5_000,
11
+ maxContentDocuments: 5_000,
12
+ maxExaminedReferences: 10_000,
13
+ maxContentCharsPerDocument: 1_000_000,
14
+ /** Aggregate UTF-16 content budget across inventoried documents. */
15
+ maxTotalContentChars: 20_000_000,
16
+ } as const;
17
+
18
+ export interface FileRefactorPlannerDocument {
19
+ id: number;
20
+ uri: string;
21
+ relPath: string;
22
+ collection: string;
23
+ title: string | null;
24
+ /** Full UTF-8 markdown body when this document should be inventoried. */
25
+ content?: string | null;
26
+ /** True when a potentially relevant doc lacked mirror content. */
27
+ contentMissing?: boolean;
28
+ /** False for read-only / logical-record referrers. Defaults to true. */
29
+ editable?: boolean;
30
+ /** Optional capability denial reason for diagnostics. */
31
+ editableReason?: string;
32
+ active?: boolean;
33
+ }
34
+
35
+ export interface PlanFileRefactorImpactInput {
36
+ operation: FileRefactorOperation;
37
+ source: {
38
+ uri: string;
39
+ relPath: string;
40
+ collection: string;
41
+ title?: string | null;
42
+ content: string;
43
+ editable: boolean;
44
+ };
45
+ target: {
46
+ uri: string;
47
+ relPath: string;
48
+ collection: string;
49
+ title?: string | null;
50
+ };
51
+ /** Active documents used for resolution + optional content inventory. */
52
+ documents: FileRefactorPlannerDocument[];
53
+ /** Occupancy check: true when target path already exists as another doc. */
54
+ targetOccupied: boolean;
55
+ /** Optional content-free occupancy fingerprint seed (defaults to target path). */
56
+ targetPathFingerprintSeed?: string;
57
+ /**
58
+ * Explicit truncation / completeness reasons from the store read seam.
59
+ * Callers must not drop these — they force canApply=false.
60
+ */
61
+ truncationReasons?: readonly string[];
62
+ }