@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,148 @@
1
+ /**
2
+ * Safe adapter from store resolution snapshot → planner input.
3
+ * Preserves truncation reasons so callers cannot drop them.
4
+ *
5
+ * @module src/core/file-refactor-from-snapshot
6
+ */
7
+
8
+ import type { FileRefactorOperation } from "./file-refactor-contract";
9
+ import type {
10
+ FileRefactorPlannerDocument,
11
+ PlanFileRefactorImpactInput,
12
+ } from "./file-refactor-planner-types";
13
+
14
+ import { planFileRefactorImpact } from "./file-refactor-planner";
15
+
16
+ /** Store-seam snapshot shape (structurally matches FileRefactorResolutionSnapshot). */
17
+ export interface FileRefactorSnapshotLike {
18
+ source: {
19
+ id: number;
20
+ uri: string;
21
+ relPath: string;
22
+ collection: string;
23
+ title: string | null;
24
+ content: string | null;
25
+ contentTruncated: boolean;
26
+ editable: boolean;
27
+ editableReason?: string;
28
+ };
29
+ catalog: Array<{
30
+ id: number;
31
+ uri: string;
32
+ relPath: string;
33
+ collection: string;
34
+ title: string | null;
35
+ }>;
36
+ referrers: Array<{
37
+ id: number;
38
+ uri: string;
39
+ relPath: string;
40
+ collection: string;
41
+ title: string | null;
42
+ content: string | null;
43
+ contentTruncated: boolean;
44
+ contentMissing: boolean;
45
+ editable: boolean;
46
+ editableReason?: string;
47
+ }>;
48
+ truncated: boolean;
49
+ truncationReasons: string[];
50
+ }
51
+
52
+ export function planInputFromResolutionSnapshot(input: {
53
+ operation: FileRefactorOperation;
54
+ snapshot: FileRefactorSnapshotLike;
55
+ target: {
56
+ uri: string;
57
+ relPath: string;
58
+ collection: string;
59
+ title?: string | null;
60
+ };
61
+ targetOccupied: boolean;
62
+ sourceEditable?: boolean;
63
+ }): PlanFileRefactorImpactInput {
64
+ const { snapshot } = input;
65
+ const truncationReasons = [...snapshot.truncationReasons];
66
+ if (snapshot.truncated && truncationReasons.length === 0) {
67
+ truncationReasons.push("snapshot_truncated");
68
+ }
69
+ // Defensive: legacy/custom snapshots may omit the reason while content is null.
70
+ if (snapshot.source.content === null) {
71
+ truncationReasons.push("source_content_missing");
72
+ }
73
+ if (snapshot.source.contentTruncated) {
74
+ truncationReasons.push("source_content_truncated");
75
+ }
76
+
77
+ const documents: FileRefactorPlannerDocument[] = snapshot.catalog.map(
78
+ (doc) => ({
79
+ id: doc.id,
80
+ uri: doc.uri,
81
+ relPath: doc.relPath,
82
+ collection: doc.collection,
83
+ title: doc.title,
84
+ active: true,
85
+ })
86
+ );
87
+
88
+ const byUri = new Map(documents.map((doc) => [doc.uri, doc]));
89
+ for (const referrer of snapshot.referrers) {
90
+ let doc = byUri.get(referrer.uri);
91
+ if (!doc) {
92
+ doc = {
93
+ id: referrer.id,
94
+ uri: referrer.uri,
95
+ relPath: referrer.relPath,
96
+ collection: referrer.collection,
97
+ title: referrer.title,
98
+ active: true,
99
+ };
100
+ documents.push(doc);
101
+ byUri.set(doc.uri, doc);
102
+ }
103
+ doc.editable = referrer.editable;
104
+ doc.editableReason = referrer.editableReason;
105
+ if (referrer.contentMissing) {
106
+ doc.contentMissing = true;
107
+ doc.content = null;
108
+ truncationReasons.push("referrer_content_missing");
109
+ } else {
110
+ doc.content = referrer.content ?? null;
111
+ if (referrer.contentTruncated) {
112
+ truncationReasons.push("referrer_content_truncated");
113
+ }
114
+ }
115
+ }
116
+
117
+ return {
118
+ operation: input.operation,
119
+ source: {
120
+ uri: snapshot.source.uri,
121
+ relPath: snapshot.source.relPath,
122
+ collection: snapshot.source.collection,
123
+ title: snapshot.source.title,
124
+ content: snapshot.source.content ?? "",
125
+ editable: input.sourceEditable ?? snapshot.source.editable,
126
+ },
127
+ target: input.target,
128
+ documents,
129
+ targetOccupied: input.targetOccupied,
130
+ truncationReasons: [...new Set(truncationReasons)].sort(),
131
+ };
132
+ }
133
+
134
+ /** Plan impact directly from a store snapshot without dropping truncation. */
135
+ export async function planFileRefactorImpactFromSnapshot(input: {
136
+ operation: FileRefactorOperation;
137
+ snapshot: FileRefactorSnapshotLike;
138
+ target: {
139
+ uri: string;
140
+ relPath: string;
141
+ collection: string;
142
+ title?: string | null;
143
+ };
144
+ targetOccupied: boolean;
145
+ sourceEditable?: boolean;
146
+ }) {
147
+ return planFileRefactorImpact(planInputFromResolutionSnapshot(input));
148
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Journal ports and helpers for reference-safe refactor apply.
3
+ *
4
+ * @module src/core/file-refactor-journal-port
5
+ */
6
+
7
+ import type {
8
+ FileRefactorJournalPort,
9
+ FileRefactorRecoveryReceipt,
10
+ } from "./file-refactor-journal";
11
+
12
+ import {
13
+ assertAllowedJournalPhaseTransition,
14
+ FILE_REFACTOR_JOURNAL_MAX_RECEIPTS,
15
+ FILE_REFACTOR_JOURNAL_PHASE_ORDINAL,
16
+ isPrunableJournalPhase,
17
+ } from "./file-refactor-journal";
18
+
19
+ function pruneOldestTerminalReceipts(
20
+ byId: Map<string, FileRefactorRecoveryReceipt>
21
+ ): void {
22
+ while (byId.size >= FILE_REFACTOR_JOURNAL_MAX_RECEIPTS) {
23
+ const prunable = [...byId.values()]
24
+ .filter((row) => isPrunableJournalPhase(row.phase))
25
+ .sort((left, right) => {
26
+ if (left.updatedAtMs !== right.updatedAtMs) {
27
+ return left.updatedAtMs - right.updatedAtMs;
28
+ }
29
+ return left.journalId < right.journalId
30
+ ? -1
31
+ : left.journalId > right.journalId
32
+ ? 1
33
+ : 0;
34
+ });
35
+ const victim = prunable[0];
36
+ if (!victim) {
37
+ throw new Error(
38
+ "File refactor journal at capacity with no prunable terminal receipts"
39
+ );
40
+ }
41
+ byId.delete(victim.journalId);
42
+ }
43
+ }
44
+
45
+ /** In-memory journal port for focused unit tests. */
46
+ export function createMemoryFileRefactorJournal(): FileRefactorJournalPort {
47
+ const byId = new Map<string, FileRefactorRecoveryReceipt>();
48
+ return {
49
+ async createPreparedReceipt(draft) {
50
+ pruneOldestTerminalReceipts(byId);
51
+ const receipt: FileRefactorRecoveryReceipt = {
52
+ ...draft,
53
+ phase: "prepared",
54
+ phaseOrdinal: FILE_REFACTOR_JOURNAL_PHASE_ORDINAL.prepared,
55
+ filesystemState: "unchanged",
56
+ indexState: "not_attempted",
57
+ updatedAtMs: draft.createdAtMs,
58
+ };
59
+ byId.set(receipt.journalId, receipt);
60
+ return receipt;
61
+ },
62
+ async advanceReceipt(journalId, update) {
63
+ const current = byId.get(journalId);
64
+ if (!current) throw new Error(`Missing journal ${journalId}`);
65
+ assertAllowedJournalPhaseTransition(current.phase, update.phase);
66
+ if (update.updatedAtMs < current.updatedAtMs) {
67
+ throw new Error("Journal updatedAtMs must be monotonic");
68
+ }
69
+ const next: FileRefactorRecoveryReceipt = {
70
+ ...current,
71
+ phase: update.phase,
72
+ phaseOrdinal: FILE_REFACTOR_JOURNAL_PHASE_ORDINAL[update.phase],
73
+ filesystemState: update.filesystemState ?? current.filesystemState,
74
+ indexState: update.indexState ?? current.indexState,
75
+ fileEntries: update.fileEntries ?? current.fileEntries,
76
+ updatedAtMs: update.updatedAtMs,
77
+ };
78
+ byId.set(journalId, next);
79
+ return next;
80
+ },
81
+ async getReceiptById(journalId) {
82
+ return byId.get(journalId) ?? null;
83
+ },
84
+ async getLatestReceiptByPlanDigest(planDigest) {
85
+ const matches = [...byId.values()].filter(
86
+ (row) => row.planDigest === planDigest
87
+ );
88
+ matches.sort((left, right) => {
89
+ if (left.updatedAtMs !== right.updatedAtMs) {
90
+ return right.updatedAtMs - left.updatedAtMs;
91
+ }
92
+ return right.journalId < left.journalId
93
+ ? -1
94
+ : right.journalId > left.journalId
95
+ ? 1
96
+ : 0;
97
+ });
98
+ return matches[0] ?? null;
99
+ },
100
+ };
101
+ }
102
+
103
+ /** Adapter: wrap StorePort journal methods as FileRefactorJournalPort. */
104
+ export function journalPortFromStore(store: {
105
+ createFileRefactorPreparedReceipt: (
106
+ draft: Parameters<FileRefactorJournalPort["createPreparedReceipt"]>[0]
107
+ ) => Promise<
108
+ { ok: true; value: FileRefactorRecoveryReceipt } | { ok: false }
109
+ >;
110
+ advanceFileRefactorReceipt: (
111
+ journalId: string,
112
+ update: Parameters<FileRefactorJournalPort["advanceReceipt"]>[1]
113
+ ) => Promise<
114
+ { ok: true; value: FileRefactorRecoveryReceipt } | { ok: false }
115
+ >;
116
+ getFileRefactorReceiptById: (
117
+ journalId: string
118
+ ) => Promise<
119
+ { ok: true; value: FileRefactorRecoveryReceipt | null } | { ok: false }
120
+ >;
121
+ getLatestFileRefactorReceiptByPlanDigest: (
122
+ planDigest: string
123
+ ) => Promise<
124
+ { ok: true; value: FileRefactorRecoveryReceipt | null } | { ok: false }
125
+ >;
126
+ }): FileRefactorJournalPort {
127
+ return {
128
+ async createPreparedReceipt(draft) {
129
+ const result = await store.createFileRefactorPreparedReceipt(draft);
130
+ if (!result.ok) throw new Error("Failed to create refactor receipt");
131
+ return result.value;
132
+ },
133
+ async advanceReceipt(journalId, update) {
134
+ const result = await store.advanceFileRefactorReceipt(journalId, update);
135
+ if (!result.ok) throw new Error("Failed to advance refactor receipt");
136
+ return result.value;
137
+ },
138
+ async getReceiptById(journalId) {
139
+ const result = await store.getFileRefactorReceiptById(journalId);
140
+ if (!result.ok) throw new Error("Failed to load refactor receipt");
141
+ return result.value;
142
+ },
143
+ async getLatestReceiptByPlanDigest(planDigest) {
144
+ const result =
145
+ await store.getLatestFileRefactorReceiptByPlanDigest(planDigest);
146
+ if (!result.ok) throw new Error("Failed to lookup refactor receipt");
147
+ return result.value;
148
+ },
149
+ };
150
+ }
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Content-free recovery journal types for reference-safe file refactors.
3
+ *
4
+ * Receipts never embed note bodies or replacement content — only IDs,
5
+ * digests, paths, phases, fingerprints, and status.
6
+ *
7
+ * Bounded globally: oldest terminal receipts are pruned on create
8
+ * (see FILE_REFACTOR_JOURNAL_MAX_RECEIPTS = 256). Uncertain/recovery-required
9
+ * receipts are never pruned.
10
+ *
11
+ * @module src/core/file-refactor-journal
12
+ */
13
+
14
+ import type { FileRefactorOperation } from "./file-refactor-contract";
15
+
16
+ import { tryValidateRefactorArtifactRelPath } from "./file-refactor-apply-safety";
17
+
18
+ /**
19
+ * Ordered phases. Numeric ordinals make interruption state detectable:
20
+ * prepared < staging < committing < committed < sync_pending < converged.
21
+ * Rollback branch: committing → rolling_back → rolled_back | recovery_required.
22
+ * Abort before commit: prepared|staging → aborted.
23
+ */
24
+ export const FILE_REFACTOR_JOURNAL_PHASES = [
25
+ "prepared",
26
+ "staging",
27
+ "committing",
28
+ "committed",
29
+ "sync_pending",
30
+ "converged",
31
+ "rolling_back",
32
+ "rolled_back",
33
+ "recovery_required",
34
+ "aborted",
35
+ ] as const;
36
+
37
+ export type FileRefactorJournalPhase =
38
+ (typeof FILE_REFACTOR_JOURNAL_PHASES)[number];
39
+
40
+ export const FILE_REFACTOR_JOURNAL_PHASE_ORDINAL: Record<
41
+ FileRefactorJournalPhase,
42
+ number
43
+ > = {
44
+ prepared: 10,
45
+ staging: 20,
46
+ committing: 30,
47
+ committed: 40,
48
+ sync_pending: 50,
49
+ converged: 60,
50
+ rolling_back: 35,
51
+ rolled_back: 70,
52
+ recovery_required: 80,
53
+ aborted: 5,
54
+ };
55
+
56
+ /** Hard cap: prune oldest terminal receipts only during create. */
57
+ export const FILE_REFACTOR_JOURNAL_MAX_RECEIPTS = 256;
58
+
59
+ export type FileRefactorJournalFileRole = "source" | "target" | "affected";
60
+
61
+ export type FileRefactorJournalFileStatus =
62
+ | "pending"
63
+ | "staged"
64
+ | "committed"
65
+ | "restored"
66
+ | "absent"
67
+ | "failed";
68
+
69
+ /** Per-file metadata only — fingerprints/status/artifact paths, never bodies. */
70
+ export interface FileRefactorJournalFileEntry {
71
+ role: FileRefactorJournalFileRole;
72
+ relPath: string;
73
+ /** Collection-relative stage artifact path (content-free locator). */
74
+ stageRelPath?: string;
75
+ /** Collection-relative backup artifact path (content-free locator). */
76
+ backupRelPath?: string;
77
+ originalFingerprint?: string;
78
+ expectedFingerprint?: string;
79
+ status: FileRefactorJournalFileStatus;
80
+ }
81
+
82
+ export type FileRefactorJournalFilesystemState =
83
+ | "unchanged"
84
+ | "committed"
85
+ | "rolled_back"
86
+ | "recovery_required";
87
+
88
+ export type FileRefactorJournalIndexState =
89
+ | "not_attempted"
90
+ | "pending"
91
+ | "converged"
92
+ | "skipped";
93
+
94
+ /** Durable, content-free recovery receipt. */
95
+ export interface FileRefactorRecoveryReceipt {
96
+ journalId: string;
97
+ planDigest: string;
98
+ collection: string;
99
+ operation: FileRefactorOperation;
100
+ sourceRelPath: string;
101
+ targetRelPath: string;
102
+ phase: FileRefactorJournalPhase;
103
+ phaseOrdinal: number;
104
+ filesystemState: FileRefactorJournalFilesystemState;
105
+ indexState: FileRefactorJournalIndexState;
106
+ fileEntries: FileRefactorJournalFileEntry[];
107
+ createdAtMs: number;
108
+ updatedAtMs: number;
109
+ }
110
+
111
+ export interface FileRefactorRecoveryReceiptDraft {
112
+ journalId: string;
113
+ planDigest: string;
114
+ collection: string;
115
+ operation: FileRefactorOperation;
116
+ sourceRelPath: string;
117
+ targetRelPath: string;
118
+ fileEntries: FileRefactorJournalFileEntry[];
119
+ createdAtMs: number;
120
+ }
121
+
122
+ export interface FileRefactorJournalAdvance {
123
+ phase: FileRefactorJournalPhase;
124
+ filesystemState?: FileRefactorJournalFilesystemState;
125
+ indexState?: FileRefactorJournalIndexState;
126
+ fileEntries?: FileRefactorJournalFileEntry[];
127
+ updatedAtMs: number;
128
+ }
129
+
130
+ /**
131
+ * Small explicit port for durable recovery receipts.
132
+ * Prefer injecting this over a full StorePort in the apply service.
133
+ */
134
+ export interface FileRefactorJournalPort {
135
+ createPreparedReceipt(
136
+ draft: FileRefactorRecoveryReceiptDraft
137
+ ): Promise<FileRefactorRecoveryReceipt>;
138
+ advanceReceipt(
139
+ journalId: string,
140
+ update: FileRefactorJournalAdvance
141
+ ): Promise<FileRefactorRecoveryReceipt>;
142
+ getReceiptById(
143
+ journalId: string
144
+ ): Promise<FileRefactorRecoveryReceipt | null>;
145
+ /**
146
+ * Latest receipt for a plan digest (highest updatedAtMs, then journalId).
147
+ * Stable deterministic lookup for idempotent retry.
148
+ */
149
+ getLatestReceiptByPlanDigest(
150
+ planDigest: string
151
+ ): Promise<FileRefactorRecoveryReceipt | null>;
152
+ }
153
+
154
+ /**
155
+ * Allowed phase transitions (monotonic / documented rollback branch).
156
+ * Same-phase advances persist per-file status during staging/commit/rollback.
157
+ */
158
+ export const FILE_REFACTOR_JOURNAL_ALLOWED_TRANSITIONS: Record<
159
+ FileRefactorJournalPhase,
160
+ readonly FileRefactorJournalPhase[]
161
+ > = {
162
+ prepared: ["prepared", "staging", "aborted", "recovery_required"],
163
+ staging: [
164
+ "staging",
165
+ "committing",
166
+ "aborted",
167
+ "rolling_back",
168
+ "recovery_required",
169
+ ],
170
+ committing: ["committing", "committed", "rolling_back", "recovery_required"],
171
+ committed: ["sync_pending", "converged"],
172
+ sync_pending: ["converged", "sync_pending"],
173
+ converged: [],
174
+ rolling_back: ["rolling_back", "rolled_back", "recovery_required"],
175
+ rolled_back: [],
176
+ recovery_required: [],
177
+ aborted: [],
178
+ };
179
+
180
+ export function assertAllowedJournalPhaseTransition(
181
+ from: FileRefactorJournalPhase,
182
+ to: FileRefactorJournalPhase
183
+ ): void {
184
+ const allowed = FILE_REFACTOR_JOURNAL_ALLOWED_TRANSITIONS[from];
185
+ if (!allowed.includes(to)) {
186
+ throw new Error(`Invalid journal phase transition ${from} -> ${to}`);
187
+ }
188
+ }
189
+
190
+ export function isTerminalJournalPhase(
191
+ phase: FileRefactorJournalPhase
192
+ ): boolean {
193
+ return (
194
+ phase === "converged" ||
195
+ phase === "rolled_back" ||
196
+ phase === "recovery_required" ||
197
+ phase === "aborted"
198
+ );
199
+ }
200
+
201
+ /** Terminal phases safe to prune under the global retention cap. */
202
+ export function isPrunableJournalPhase(
203
+ phase: FileRefactorJournalPhase
204
+ ): boolean {
205
+ return (
206
+ phase === "converged" || phase === "rolled_back" || phase === "aborted"
207
+ );
208
+ }
209
+
210
+ export function isUncertainJournalPhase(
211
+ phase: FileRefactorJournalPhase
212
+ ): boolean {
213
+ return (
214
+ phase === "prepared" ||
215
+ phase === "staging" ||
216
+ phase === "committing" ||
217
+ phase === "rolling_back" ||
218
+ phase === "recovery_required"
219
+ );
220
+ }
221
+
222
+ export function isCommittedFilesystemPhase(
223
+ phase: FileRefactorJournalPhase
224
+ ): boolean {
225
+ return (
226
+ phase === "committed" || phase === "sync_pending" || phase === "converged"
227
+ );
228
+ }
229
+
230
+ const FILE_ENTRY_ROLES = new Set(["source", "target", "affected"]);
231
+ const FILE_ENTRY_STATUSES = new Set([
232
+ "pending",
233
+ "staged",
234
+ "committed",
235
+ "restored",
236
+ "absent",
237
+ "failed",
238
+ ]);
239
+
240
+ function isOptionalString(value: unknown): value is string | undefined {
241
+ return value === undefined || typeof value === "string";
242
+ }
243
+
244
+ function assertOptionalFingerprint(
245
+ value: unknown,
246
+ field: string
247
+ ): asserts value is string | undefined {
248
+ if (value === undefined) return;
249
+ if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
250
+ throw new Error(`Corrupt file_entries_json: invalid ${field}`);
251
+ }
252
+ }
253
+
254
+ function assertOptionalArtifactRelPath(
255
+ value: unknown,
256
+ kind: "stage" | "backup"
257
+ ): asserts value is string | undefined {
258
+ if (value === undefined) return;
259
+ if (typeof value !== "string") {
260
+ throw new Error(`Corrupt file_entries_json: invalid ${kind}RelPath`);
261
+ }
262
+ if (!tryValidateRefactorArtifactRelPath(value, kind)) {
263
+ throw new Error(`Corrupt file_entries_json: invalid ${kind}RelPath`);
264
+ }
265
+ }
266
+
267
+ /** Defensive parse of file_entries_json — rejects corrupt shapes. */
268
+ export function parseFileRefactorJournalFileEntries(
269
+ raw: unknown
270
+ ): FileRefactorJournalFileEntry[] {
271
+ if (typeof raw === "string") {
272
+ let parsed: unknown;
273
+ try {
274
+ parsed = JSON.parse(raw);
275
+ } catch {
276
+ throw new Error("Corrupt file_entries_json: invalid JSON");
277
+ }
278
+ return parseFileRefactorJournalFileEntries(parsed);
279
+ }
280
+ if (!Array.isArray(raw)) {
281
+ throw new Error("Corrupt file_entries_json: expected array");
282
+ }
283
+ const entries: FileRefactorJournalFileEntry[] = [];
284
+ const seenRelPaths = new Set<string>();
285
+ const seenRoles = new Set<string>();
286
+ for (const item of raw) {
287
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
288
+ throw new Error("Corrupt file_entries_json: entry must be object");
289
+ }
290
+ const row = item as Record<string, unknown>;
291
+ if (typeof row.role !== "string" || !FILE_ENTRY_ROLES.has(row.role)) {
292
+ throw new Error("Corrupt file_entries_json: invalid role");
293
+ }
294
+ if (typeof row.relPath !== "string" || row.relPath.length === 0) {
295
+ throw new Error("Corrupt file_entries_json: invalid relPath");
296
+ }
297
+ if (
298
+ typeof row.status !== "string" ||
299
+ !FILE_ENTRY_STATUSES.has(row.status)
300
+ ) {
301
+ throw new Error("Corrupt file_entries_json: invalid status");
302
+ }
303
+ if (
304
+ !isOptionalString(row.stageRelPath) ||
305
+ !isOptionalString(row.backupRelPath) ||
306
+ !isOptionalString(row.originalFingerprint) ||
307
+ !isOptionalString(row.expectedFingerprint)
308
+ ) {
309
+ throw new Error("Corrupt file_entries_json: invalid optional fields");
310
+ }
311
+ assertOptionalFingerprint(row.originalFingerprint, "originalFingerprint");
312
+ assertOptionalFingerprint(row.expectedFingerprint, "expectedFingerprint");
313
+ assertOptionalArtifactRelPath(row.stageRelPath, "stage");
314
+ assertOptionalArtifactRelPath(row.backupRelPath, "backup");
315
+ if (seenRelPaths.has(row.relPath)) {
316
+ throw new Error("Corrupt file_entries_json: duplicate relPath");
317
+ }
318
+ seenRelPaths.add(row.relPath);
319
+ const roleKey = `${row.role}:${row.relPath}`;
320
+ if (seenRoles.has(roleKey)) {
321
+ throw new Error("Corrupt file_entries_json: duplicate role/relPath");
322
+ }
323
+ seenRoles.add(roleKey);
324
+ if (
325
+ "content" in row ||
326
+ "body" in row ||
327
+ "replacement" in row ||
328
+ "originalDestination" in row ||
329
+ "finalContent" in row
330
+ ) {
331
+ throw new Error("File refactor journal must not store note content");
332
+ }
333
+ entries.push({
334
+ role: row.role as FileRefactorJournalFileEntry["role"],
335
+ relPath: row.relPath,
336
+ stageRelPath: row.stageRelPath,
337
+ backupRelPath: row.backupRelPath,
338
+ originalFingerprint: row.originalFingerprint,
339
+ expectedFingerprint: row.expectedFingerprint,
340
+ status: row.status as FileRefactorJournalFileEntry["status"],
341
+ });
342
+ }
343
+ return entries;
344
+ }
345
+
346
+ export const FILE_REFACTOR_SYNC_PENDING_INSTRUCTION =
347
+ "Filesystem refactor committed; retry apply with the same plan digest to finish index convergence without repeating file mutations.";
@@ -0,0 +1,60 @@
1
+ /** Browser-safe path target planning for canonical rename and move. */
2
+
3
+ // node:path/posix — no Bun path utils
4
+ import { posix as pathPosix } from "node:path";
5
+
6
+ import { validateRelPath } from "./validation";
7
+
8
+ export interface RenamePlan {
9
+ nextRelPath: string;
10
+ nextUri: string;
11
+ }
12
+
13
+ export interface MovePlan {
14
+ nextRelPath: string;
15
+ nextUri: string;
16
+ }
17
+
18
+ export function planRenameRefactor(input: {
19
+ collection: string;
20
+ currentRelPath: string;
21
+ nextName: string;
22
+ }): RenamePlan {
23
+ const current = validateRelPath(input.currentRelPath);
24
+ const directory = pathPosix.dirname(current);
25
+ const currentExt = pathPosix.extname(current);
26
+ const nextFilename = pathPosix.extname(input.nextName)
27
+ ? input.nextName
28
+ : `${input.nextName}${currentExt}`;
29
+ const nextRelPath =
30
+ directory === "."
31
+ ? validateRelPath(nextFilename)
32
+ : validateRelPath(`${directory}/${nextFilename}`);
33
+
34
+ return {
35
+ nextRelPath,
36
+ nextUri: `gno://${input.collection}/${nextRelPath}`,
37
+ };
38
+ }
39
+
40
+ export function planMoveRefactor(input: {
41
+ collection: string;
42
+ currentRelPath: string;
43
+ folderPath: string;
44
+ nextName?: string;
45
+ }): MovePlan {
46
+ const current = validateRelPath(input.currentRelPath);
47
+ const safeFolder = validateRelPath(input.folderPath).replace(
48
+ /^\.\/|\/+$/g,
49
+ ""
50
+ );
51
+ const filename = input.nextName?.trim() || pathPosix.basename(current);
52
+ const nextRelPath = safeFolder
53
+ ? validateRelPath(`${safeFolder}/${filename}`)
54
+ : validateRelPath(filename);
55
+
56
+ return {
57
+ nextRelPath,
58
+ nextUri: `gno://${input.collection}/${nextRelPath}`,
59
+ };
60
+ }