@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.
- package/README.md +5 -4
- package/assets/skill/SKILL.md +23 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.31.0.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 +114 -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/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/mcp/tools/index.ts +18 -17
- package/src/mcp/tools/workspace-write.ts +215 -97
- package/src/sdk/client.ts +167 -115
- package/src/sdk/index.ts +7 -0
- package/src/sdk/types.ts +32 -2
- 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/pages/DocView.tsx +176 -41
- package/src/serve/routes/api.ts +191 -104
- 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.31.0.zip.sha256 +0 -1
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parser-backed reference impact planner for rename / same-collection move.
|
|
3
|
+
*
|
|
4
|
+
* Accepts a bounded document snapshot; never reads or writes user files.
|
|
5
|
+
*
|
|
6
|
+
* @module src/core/file-refactor-planner
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
FileRefactorAffectedDocument,
|
|
11
|
+
FileRefactorDestinationSpan,
|
|
12
|
+
FileRefactorExaminedReference,
|
|
13
|
+
FileRefactorPreviewPlan,
|
|
14
|
+
} from "./file-refactor-contract";
|
|
15
|
+
import type {
|
|
16
|
+
FileRefactorPlannerDocument,
|
|
17
|
+
PlanFileRefactorImpactInput,
|
|
18
|
+
} from "./file-refactor-planner-types";
|
|
19
|
+
import type { FileRefactorCatalogDocument } from "./file-refactor-resolve";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
FILE_REFACTOR_MUTATION_BOUNDARY,
|
|
23
|
+
FILE_REFACTOR_SCHEMA_VERSION,
|
|
24
|
+
computeFileRefactorPlanDigest,
|
|
25
|
+
deriveCanApply,
|
|
26
|
+
fingerprintUtf8Content,
|
|
27
|
+
sortExaminedReferences,
|
|
28
|
+
summarizeReferenceClassifications,
|
|
29
|
+
} from "./file-refactor-contract";
|
|
30
|
+
import { classifyResolvableToken } from "./file-refactor-plan-classify";
|
|
31
|
+
import {
|
|
32
|
+
pathBasename,
|
|
33
|
+
pathDirname,
|
|
34
|
+
validateFileRefactorPlanInputs,
|
|
35
|
+
} from "./file-refactor-plan-validate";
|
|
36
|
+
import { FILE_REFACTOR_PLANNER_CAPS } from "./file-refactor-planner-types";
|
|
37
|
+
import {
|
|
38
|
+
buildSourceRelevanceKeys,
|
|
39
|
+
inventoryDocumentLinks,
|
|
40
|
+
} from "./link-inventory";
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
FILE_REFACTOR_PLANNER_CAPS,
|
|
44
|
+
type FileRefactorPlannerDocument,
|
|
45
|
+
type PlanFileRefactorImpactInput,
|
|
46
|
+
} from "./file-refactor-planner-types";
|
|
47
|
+
|
|
48
|
+
function toCatalog(
|
|
49
|
+
documents: FileRefactorPlannerDocument[]
|
|
50
|
+
): FileRefactorCatalogDocument[] {
|
|
51
|
+
return documents.map((doc) => ({
|
|
52
|
+
id: doc.id,
|
|
53
|
+
uri: doc.uri,
|
|
54
|
+
relPath: doc.relPath,
|
|
55
|
+
collection: doc.collection,
|
|
56
|
+
title: doc.title,
|
|
57
|
+
active: doc.active,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function emptyBlockedPlan(
|
|
62
|
+
input: PlanFileRefactorImpactInput,
|
|
63
|
+
examined: FileRefactorExaminedReference[],
|
|
64
|
+
warnings: string[]
|
|
65
|
+
): Promise<FileRefactorPreviewPlan> {
|
|
66
|
+
const orderedExamined = sortExaminedReferences(examined);
|
|
67
|
+
const safety = summarizeReferenceClassifications(orderedExamined, {
|
|
68
|
+
warnings,
|
|
69
|
+
backlinks: orderedExamined.length,
|
|
70
|
+
});
|
|
71
|
+
const sourceContentFingerprint = await fingerprintUtf8Content(
|
|
72
|
+
input.source.content
|
|
73
|
+
);
|
|
74
|
+
const targetPathFingerprint = await fingerprintUtf8Content(
|
|
75
|
+
input.targetPathFingerprintSeed ??
|
|
76
|
+
`${input.target.collection}:${input.target.relPath}:${input.targetOccupied ? "occupied" : "free"}`
|
|
77
|
+
);
|
|
78
|
+
const withoutDigest: Omit<FileRefactorPreviewPlan, "planDigest"> = {
|
|
79
|
+
schemaVersion: FILE_REFACTOR_SCHEMA_VERSION,
|
|
80
|
+
operation: input.operation,
|
|
81
|
+
conflictPolicy: "fail",
|
|
82
|
+
source: {
|
|
83
|
+
uri: input.source.uri,
|
|
84
|
+
relPath: input.source.relPath,
|
|
85
|
+
collection: input.source.collection,
|
|
86
|
+
},
|
|
87
|
+
target: {
|
|
88
|
+
uri: input.target.uri,
|
|
89
|
+
relPath: input.target.relPath,
|
|
90
|
+
collection: input.target.collection,
|
|
91
|
+
},
|
|
92
|
+
affectedDocuments: [],
|
|
93
|
+
examinedReferences: orderedExamined,
|
|
94
|
+
preconditions: {
|
|
95
|
+
sourceContentFingerprint,
|
|
96
|
+
affectedContentFingerprints: [],
|
|
97
|
+
targetPathFingerprint,
|
|
98
|
+
},
|
|
99
|
+
safety,
|
|
100
|
+
canApply: false,
|
|
101
|
+
mutationBoundary: FILE_REFACTOR_MUTATION_BOUNDARY,
|
|
102
|
+
};
|
|
103
|
+
const planDigest = await computeFileRefactorPlanDigest(withoutDigest);
|
|
104
|
+
return { ...withoutDigest, planDigest };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build a deterministic FileRefactorPreviewPlan from an in-memory snapshot.
|
|
109
|
+
*/
|
|
110
|
+
export async function planFileRefactorImpact(
|
|
111
|
+
input: PlanFileRefactorImpactInput
|
|
112
|
+
): Promise<FileRefactorPreviewPlan> {
|
|
113
|
+
const validation = validateFileRefactorPlanInputs(input);
|
|
114
|
+
if (validation) {
|
|
115
|
+
return emptyBlockedPlan(input, validation.examined, [
|
|
116
|
+
`plan_invalid:${validation.diagnostic}`,
|
|
117
|
+
]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const truncatedReasons = [...new Set(input.truncationReasons ?? [])].sort();
|
|
121
|
+
const catalogDocs = input.documents;
|
|
122
|
+
|
|
123
|
+
if (catalogDocs.length > FILE_REFACTOR_PLANNER_CAPS.maxCatalogDocuments) {
|
|
124
|
+
truncatedReasons.push("catalog_truncated");
|
|
125
|
+
}
|
|
126
|
+
// Inventory + resolution must operate on the bounded catalog slice only.
|
|
127
|
+
const boundedCatalogDocs = catalogDocs.slice(
|
|
128
|
+
0,
|
|
129
|
+
FILE_REFACTOR_PLANNER_CAPS.maxCatalogDocuments
|
|
130
|
+
);
|
|
131
|
+
const catalog = toCatalog(boundedCatalogDocs);
|
|
132
|
+
|
|
133
|
+
const sourceKeys = buildSourceRelevanceKeys({
|
|
134
|
+
relPath: input.source.relPath,
|
|
135
|
+
title: input.source.title,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Inventory source content for self-references, plus every content-bearing
|
|
139
|
+
// same-collection document (including docs whose only hit is opaque syntax).
|
|
140
|
+
const contentDocs: FileRefactorPlannerDocument[] = [];
|
|
141
|
+
const seenContentUris = new Set<string>();
|
|
142
|
+
|
|
143
|
+
const pushContentDoc = (doc: FileRefactorPlannerDocument): boolean => {
|
|
144
|
+
if (seenContentUris.has(doc.uri)) return true;
|
|
145
|
+
if (doc.collection !== input.source.collection) return true;
|
|
146
|
+
if (contentDocs.length >= FILE_REFACTOR_PLANNER_CAPS.maxContentDocuments) {
|
|
147
|
+
truncatedReasons.push("content_documents_truncated");
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
seenContentUris.add(doc.uri);
|
|
151
|
+
contentDocs.push(doc);
|
|
152
|
+
return true;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
pushContentDoc({
|
|
156
|
+
id: -1,
|
|
157
|
+
uri: input.source.uri,
|
|
158
|
+
relPath: input.source.relPath,
|
|
159
|
+
collection: input.source.collection,
|
|
160
|
+
title: input.source.title ?? null,
|
|
161
|
+
content: input.source.content,
|
|
162
|
+
editable: input.source.editable,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
for (const doc of boundedCatalogDocs) {
|
|
166
|
+
if (doc.contentMissing) {
|
|
167
|
+
truncatedReasons.push("referrer_content_missing");
|
|
168
|
+
if (!pushContentDoc(doc)) break;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (doc.content === undefined || doc.content === null) continue;
|
|
172
|
+
if (!pushContentDoc(doc)) break;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let totalContentChars = 0;
|
|
176
|
+
const examined: FileRefactorExaminedReference[] = [];
|
|
177
|
+
const byDocument = new Map<
|
|
178
|
+
string,
|
|
179
|
+
{
|
|
180
|
+
doc: FileRefactorPlannerDocument;
|
|
181
|
+
examined: FileRefactorExaminedReference[];
|
|
182
|
+
edits: FileRefactorDestinationSpan[];
|
|
183
|
+
}
|
|
184
|
+
>();
|
|
185
|
+
|
|
186
|
+
const considerDoc = (
|
|
187
|
+
doc: FileRefactorPlannerDocument,
|
|
188
|
+
content: string
|
|
189
|
+
): void => {
|
|
190
|
+
if (doc.contentMissing) {
|
|
191
|
+
if (examined.length >= FILE_REFACTOR_PLANNER_CAPS.maxExaminedReferences) {
|
|
192
|
+
truncatedReasons.push("examined_truncated");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
examined.push({
|
|
196
|
+
documentUri: doc.uri,
|
|
197
|
+
documentRelPath: doc.relPath,
|
|
198
|
+
kind: "opaque",
|
|
199
|
+
classification: "invalid",
|
|
200
|
+
reasonCode: "unsafe_target",
|
|
201
|
+
originalDestination: "referrer_content_missing",
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (
|
|
206
|
+
content.length > FILE_REFACTOR_PLANNER_CAPS.maxContentCharsPerDocument
|
|
207
|
+
) {
|
|
208
|
+
truncatedReasons.push("content_size_truncated");
|
|
209
|
+
examined.push({
|
|
210
|
+
documentUri: doc.uri,
|
|
211
|
+
documentRelPath: doc.relPath,
|
|
212
|
+
kind: "opaque",
|
|
213
|
+
classification: "invalid",
|
|
214
|
+
reasonCode: "unsafe_target",
|
|
215
|
+
});
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
totalContentChars += content.length;
|
|
219
|
+
if (totalContentChars > FILE_REFACTOR_PLANNER_CAPS.maxTotalContentChars) {
|
|
220
|
+
truncatedReasons.push("total_content_truncated");
|
|
221
|
+
examined.push({
|
|
222
|
+
documentUri: doc.uri,
|
|
223
|
+
documentRelPath: doc.relPath,
|
|
224
|
+
kind: "opaque",
|
|
225
|
+
classification: "invalid",
|
|
226
|
+
reasonCode: "unsafe_target",
|
|
227
|
+
originalDestination: "total_content_truncated",
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const inventory = inventoryDocumentLinks(content, { sourceKeys });
|
|
233
|
+
if (inventory.truncated) {
|
|
234
|
+
truncatedReasons.push("inventory_truncated");
|
|
235
|
+
examined.push({
|
|
236
|
+
documentUri: doc.uri,
|
|
237
|
+
documentRelPath: doc.relPath,
|
|
238
|
+
kind: "opaque",
|
|
239
|
+
classification: "invalid",
|
|
240
|
+
reasonCode: "unsafe_target",
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (inventory.overlapping) {
|
|
244
|
+
truncatedReasons.push("overlapping_destination_spans");
|
|
245
|
+
examined.push({
|
|
246
|
+
documentUri: doc.uri,
|
|
247
|
+
documentRelPath: doc.relPath,
|
|
248
|
+
kind: "opaque",
|
|
249
|
+
classification: "invalid",
|
|
250
|
+
reasonCode: "unsafe_target",
|
|
251
|
+
originalDestination: "overlapping_destination_spans",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const token of inventory.tokens) {
|
|
256
|
+
const row = classifyResolvableToken({
|
|
257
|
+
token,
|
|
258
|
+
doc,
|
|
259
|
+
sourceUri: input.source.uri,
|
|
260
|
+
sourceRelPath: input.source.relPath,
|
|
261
|
+
sourceTitle: input.source.title,
|
|
262
|
+
targetRelPath: input.target.relPath,
|
|
263
|
+
targetTitle: input.target.title,
|
|
264
|
+
catalog,
|
|
265
|
+
});
|
|
266
|
+
if (!row) continue;
|
|
267
|
+
if (examined.length >= FILE_REFACTOR_PLANNER_CAPS.maxExaminedReferences) {
|
|
268
|
+
truncatedReasons.push("examined_truncated");
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
examined.push(row);
|
|
272
|
+
let bucket = byDocument.get(doc.uri);
|
|
273
|
+
if (!bucket) {
|
|
274
|
+
bucket = { doc, examined: [], edits: [] };
|
|
275
|
+
byDocument.set(doc.uri, bucket);
|
|
276
|
+
}
|
|
277
|
+
bucket.examined.push(row);
|
|
278
|
+
if (row.edit) bucket.edits.push(row.edit);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
for (const doc of contentDocs) {
|
|
283
|
+
if (truncatedReasons.includes("examined_truncated")) break;
|
|
284
|
+
if (truncatedReasons.includes("total_content_truncated")) break;
|
|
285
|
+
considerDoc(doc, doc.content ?? "");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const uniqueTruncation = [...new Set(truncatedReasons)].sort();
|
|
289
|
+
const orderedExamined = sortExaminedReferences(examined);
|
|
290
|
+
const affectedDocuments: FileRefactorAffectedDocument[] = [];
|
|
291
|
+
const fingerprintEntries: Array<{ uri: string; fingerprint: string }> = [];
|
|
292
|
+
|
|
293
|
+
const sourceContentFingerprint = await fingerprintUtf8Content(
|
|
294
|
+
input.source.content
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const orderedBuckets = [...byDocument.values()].sort((a, b) =>
|
|
298
|
+
a.doc.relPath < b.doc.relPath ? -1 : a.doc.relPath > b.doc.relPath ? 1 : 0
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
for (const bucket of orderedBuckets) {
|
|
302
|
+
const hasRewrite = bucket.edits.length > 0;
|
|
303
|
+
const hasBlocking = bucket.examined.some(
|
|
304
|
+
(row) =>
|
|
305
|
+
row.classification === "ambiguous" ||
|
|
306
|
+
row.classification === "unsupported" ||
|
|
307
|
+
row.classification === "malformed" ||
|
|
308
|
+
row.classification === "invalid"
|
|
309
|
+
);
|
|
310
|
+
if (!hasRewrite && !hasBlocking && bucket.examined.length === 0) continue;
|
|
311
|
+
|
|
312
|
+
// Source uses the canonical fingerprint from input.source.content once.
|
|
313
|
+
const contentFingerprint =
|
|
314
|
+
bucket.doc.uri === input.source.uri
|
|
315
|
+
? sourceContentFingerprint
|
|
316
|
+
: await fingerprintUtf8Content(bucket.doc.content ?? "");
|
|
317
|
+
fingerprintEntries.push({
|
|
318
|
+
uri: bucket.doc.uri,
|
|
319
|
+
fingerprint: contentFingerprint,
|
|
320
|
+
});
|
|
321
|
+
affectedDocuments.push({
|
|
322
|
+
uri: bucket.doc.uri,
|
|
323
|
+
relPath: bucket.doc.relPath,
|
|
324
|
+
contentFingerprint,
|
|
325
|
+
edits: [...bucket.edits].sort((a, b) =>
|
|
326
|
+
a.startOffset !== b.startOffset
|
|
327
|
+
? a.startOffset - b.startOffset
|
|
328
|
+
: a.endOffset - b.endOffset
|
|
329
|
+
),
|
|
330
|
+
examined: sortExaminedReferences(bucket.examined),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const wikiLinkCount = orderedExamined.filter(
|
|
335
|
+
(row) => row.kind === "wiki"
|
|
336
|
+
).length;
|
|
337
|
+
const markdownLinkCount = orderedExamined.filter(
|
|
338
|
+
(row) => row.kind === "markdown" || row.kind === "markdown_definition"
|
|
339
|
+
).length;
|
|
340
|
+
const filenameChanged =
|
|
341
|
+
pathBasename(input.source.relPath) !== pathBasename(input.target.relPath);
|
|
342
|
+
const folderChanged =
|
|
343
|
+
pathDirname(input.source.relPath) !== pathDirname(input.target.relPath);
|
|
344
|
+
const warnings: string[] = [];
|
|
345
|
+
if (orderedExamined.length > 0) {
|
|
346
|
+
warnings.push(
|
|
347
|
+
`${orderedExamined.length} backlink${orderedExamined.length === 1 ? "" : "s"} may need review after this refactor.`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (filenameChanged && wikiLinkCount > 0) {
|
|
351
|
+
warnings.push(
|
|
352
|
+
`${wikiLinkCount} wiki link${wikiLinkCount === 1 ? "" : "s"} may depend on the current title/path identity.`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if ((filenameChanged || folderChanged) && markdownLinkCount > 0) {
|
|
356
|
+
warnings.push(
|
|
357
|
+
`${markdownLinkCount} markdown link${markdownLinkCount === 1 ? "" : "s"} may require path rewrite or manual review.`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
for (const code of uniqueTruncation) {
|
|
361
|
+
warnings.push(`plan_truncated:${code}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const safety = summarizeReferenceClassifications(orderedExamined, {
|
|
365
|
+
warnings,
|
|
366
|
+
backlinks: orderedExamined.length,
|
|
367
|
+
wikiLinks: wikiLinkCount,
|
|
368
|
+
markdownLinks: markdownLinkCount,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
if (uniqueTruncation.length > 0 && safety.invalidCount === 0) {
|
|
372
|
+
safety.invalidCount = 1;
|
|
373
|
+
if (!safety.blockingReasonCodes.includes("unsafe_target")) {
|
|
374
|
+
safety.blockingReasonCodes = [
|
|
375
|
+
...safety.blockingReasonCodes,
|
|
376
|
+
"unsafe_target" as const,
|
|
377
|
+
].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const targetPathFingerprint = await fingerprintUtf8Content(
|
|
382
|
+
input.targetPathFingerprintSeed ??
|
|
383
|
+
`${input.target.collection}:${input.target.relPath}:${input.targetOccupied ? "occupied" : "free"}`
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const canApply = deriveCanApply({
|
|
387
|
+
safety,
|
|
388
|
+
sourceEditable: input.source.editable,
|
|
389
|
+
targetOccupied: input.targetOccupied,
|
|
390
|
+
sameCollection: true,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const withoutDigest: Omit<FileRefactorPreviewPlan, "planDigest"> = {
|
|
394
|
+
schemaVersion: FILE_REFACTOR_SCHEMA_VERSION,
|
|
395
|
+
operation: input.operation,
|
|
396
|
+
conflictPolicy: "fail",
|
|
397
|
+
source: {
|
|
398
|
+
uri: input.source.uri,
|
|
399
|
+
relPath: input.source.relPath,
|
|
400
|
+
collection: input.source.collection,
|
|
401
|
+
},
|
|
402
|
+
target: {
|
|
403
|
+
uri: input.target.uri,
|
|
404
|
+
relPath: input.target.relPath,
|
|
405
|
+
collection: input.target.collection,
|
|
406
|
+
},
|
|
407
|
+
affectedDocuments,
|
|
408
|
+
examinedReferences: orderedExamined,
|
|
409
|
+
preconditions: {
|
|
410
|
+
sourceContentFingerprint,
|
|
411
|
+
affectedContentFingerprints: fingerprintEntries.sort((a, b) =>
|
|
412
|
+
a.uri < b.uri ? -1 : a.uri > b.uri ? 1 : 0
|
|
413
|
+
),
|
|
414
|
+
targetPathFingerprint,
|
|
415
|
+
},
|
|
416
|
+
safety,
|
|
417
|
+
canApply,
|
|
418
|
+
mutationBoundary: FILE_REFACTOR_MUTATION_BOUNDARY,
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const planDigest = await computeFileRefactorPlanDigest(withoutDigest);
|
|
422
|
+
return { ...withoutDigest, planDigest };
|
|
423
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory wiki/markdown target resolution for reference-safe refactors.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors SqliteAdapter / graph-link-resolver ranking and uniqueness rules:
|
|
5
|
+
* best-rank candidate sets with more than one document are ambiguous.
|
|
6
|
+
*
|
|
7
|
+
* Catalog is already bounded (≤5000). Candidate collection never silently
|
|
8
|
+
* truncates mid-scan — the full bounded catalog is evaluated.
|
|
9
|
+
*
|
|
10
|
+
* @module src/core/file-refactor-resolve
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// node:path/posix — no Bun path utils
|
|
14
|
+
import { posix as pathPosix } from "node:path";
|
|
15
|
+
|
|
16
|
+
import type { FileRefactorReasonCode } from "./file-refactor-contract";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
stripAngleBracketDestination,
|
|
20
|
+
unescapeCommonMarkDestination,
|
|
21
|
+
} from "./link-destination-parse";
|
|
22
|
+
import {
|
|
23
|
+
normalizeMarkdownPath,
|
|
24
|
+
normalizeWikiName,
|
|
25
|
+
stripWikiMdExt,
|
|
26
|
+
} from "./links";
|
|
27
|
+
|
|
28
|
+
export const FILE_REFACTOR_RESOLVE_CAPS = {
|
|
29
|
+
maxCatalogDocuments: 5_000,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export interface FileRefactorCatalogDocument {
|
|
33
|
+
/** Stable numeric id for deterministic tie-breaks (matches documents.id). */
|
|
34
|
+
id: number;
|
|
35
|
+
uri: string;
|
|
36
|
+
relPath: string;
|
|
37
|
+
collection: string;
|
|
38
|
+
title: string | null;
|
|
39
|
+
active?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type FileRefactorResolutionStatus =
|
|
43
|
+
| "unique"
|
|
44
|
+
| "ambiguous"
|
|
45
|
+
| "unresolved"
|
|
46
|
+
| "elsewhere";
|
|
47
|
+
|
|
48
|
+
export interface FileRefactorResolution {
|
|
49
|
+
status: FileRefactorResolutionStatus;
|
|
50
|
+
/** Present when status is unique and matches the expected source. */
|
|
51
|
+
document?: FileRefactorCatalogDocument;
|
|
52
|
+
matchRank?: number;
|
|
53
|
+
matchCount: number;
|
|
54
|
+
reasonCode?: FileRefactorReasonCode;
|
|
55
|
+
candidates: FileRefactorCatalogDocument[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function lowerTrim(value: string): string {
|
|
59
|
+
return value.toLowerCase().trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function endsWithPathSegment(target: string, value: string): boolean {
|
|
63
|
+
if (!value) return false;
|
|
64
|
+
if (target === value) return true;
|
|
65
|
+
return target.endsWith(`/${value}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function wikiMatchRank(
|
|
69
|
+
targetRefNorm: string,
|
|
70
|
+
doc: FileRefactorCatalogDocument
|
|
71
|
+
): number | null {
|
|
72
|
+
const baseRef = stripWikiMdExt(targetRefNorm);
|
|
73
|
+
const baseRefMd = `${baseRef}.md`;
|
|
74
|
+
const title = lowerTrim(doc.title ?? "");
|
|
75
|
+
const rel = doc.relPath.toLowerCase();
|
|
76
|
+
|
|
77
|
+
if (title === baseRef) return 1;
|
|
78
|
+
if (title === baseRefMd) return 2;
|
|
79
|
+
if (endsWithPathSegment(baseRef, title)) return 3;
|
|
80
|
+
if (endsWithPathSegment(baseRefMd, `${title}.md`)) return 4;
|
|
81
|
+
if (rel === baseRef) return 5;
|
|
82
|
+
if (rel === baseRefMd) return 6;
|
|
83
|
+
if (endsWithPathSegment(rel, baseRefMd)) return 7;
|
|
84
|
+
if (endsWithPathSegment(rel, baseRef)) return 8;
|
|
85
|
+
if (endsWithPathSegment(baseRefMd, rel)) return 9;
|
|
86
|
+
if (endsWithPathSegment(baseRef, rel)) return 10;
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function collectWikiCandidates(
|
|
91
|
+
targetRefNorm: string,
|
|
92
|
+
collection: string,
|
|
93
|
+
catalog: readonly FileRefactorCatalogDocument[]
|
|
94
|
+
): Array<{ doc: FileRefactorCatalogDocument; rank: number }> {
|
|
95
|
+
const matches: Array<{ doc: FileRefactorCatalogDocument; rank: number }> = [];
|
|
96
|
+
for (const doc of catalog) {
|
|
97
|
+
if (doc.active === false) continue;
|
|
98
|
+
if (doc.collection !== collection) continue;
|
|
99
|
+
const rank = wikiMatchRank(targetRefNorm, doc);
|
|
100
|
+
if (rank === null) continue;
|
|
101
|
+
matches.push({ doc, rank });
|
|
102
|
+
}
|
|
103
|
+
return matches;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function detectUnicodeAmbiguity(
|
|
107
|
+
targetRefNorm: string,
|
|
108
|
+
collection: string,
|
|
109
|
+
catalog: readonly FileRefactorCatalogDocument[],
|
|
110
|
+
best: FileRefactorCatalogDocument[]
|
|
111
|
+
): boolean {
|
|
112
|
+
const nfcKey = normalizeWikiName(stripWikiMdExt(targetRefNorm));
|
|
113
|
+
if (!nfcKey) return false;
|
|
114
|
+
const nfcMatches: FileRefactorCatalogDocument[] = [];
|
|
115
|
+
for (const doc of catalog) {
|
|
116
|
+
if (doc.active === false) continue;
|
|
117
|
+
if (doc.collection !== collection) continue;
|
|
118
|
+
const titleKey = normalizeWikiName(doc.title ?? "");
|
|
119
|
+
const relKey = normalizeWikiName(stripWikiMdExt(doc.relPath));
|
|
120
|
+
const baseKey = normalizeWikiName(
|
|
121
|
+
stripWikiMdExt(pathPosix.basename(doc.relPath))
|
|
122
|
+
);
|
|
123
|
+
if (titleKey === nfcKey || relKey === nfcKey || baseKey === nfcKey) {
|
|
124
|
+
nfcMatches.push(doc);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (nfcMatches.length <= 1) return false;
|
|
128
|
+
const bestIds = new Set(best.map((doc) => doc.id));
|
|
129
|
+
return nfcMatches.some((doc) => !bestIds.has(doc.id));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve a wiki target against a bounded catalog. Never picks the first
|
|
134
|
+
* candidate and calls it unique when the best-rank set has multiple docs.
|
|
135
|
+
*/
|
|
136
|
+
export function resolveWikiTarget(input: {
|
|
137
|
+
targetRef: string;
|
|
138
|
+
targetCollection: string;
|
|
139
|
+
sourceUri: string;
|
|
140
|
+
catalog: readonly FileRefactorCatalogDocument[];
|
|
141
|
+
}): FileRefactorResolution {
|
|
142
|
+
const targetRefNorm = normalizeWikiName(input.targetRef);
|
|
143
|
+
const matches = collectWikiCandidates(
|
|
144
|
+
targetRefNorm,
|
|
145
|
+
input.targetCollection,
|
|
146
|
+
input.catalog
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (matches.length === 0) {
|
|
150
|
+
return { status: "unresolved", matchCount: 0, candidates: [] };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let bestRank = Number.POSITIVE_INFINITY;
|
|
154
|
+
for (const match of matches) {
|
|
155
|
+
if (match.rank < bestRank) bestRank = match.rank;
|
|
156
|
+
}
|
|
157
|
+
const best = matches
|
|
158
|
+
.filter((match) => match.rank === bestRank)
|
|
159
|
+
.map((match) => match.doc)
|
|
160
|
+
.sort((a, b) => a.id - b.id);
|
|
161
|
+
|
|
162
|
+
const includesSource = best.some((doc) => doc.uri === input.sourceUri);
|
|
163
|
+
const duplicateBasenames = new Set(
|
|
164
|
+
best.map((doc) =>
|
|
165
|
+
normalizeWikiName(stripWikiMdExt(pathPosix.basename(doc.relPath)))
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
if (best.length > 1) {
|
|
170
|
+
const reasonCode: FileRefactorReasonCode =
|
|
171
|
+
duplicateBasenames.size < best.length || best.length > 1
|
|
172
|
+
? "duplicate_basename_ambiguity"
|
|
173
|
+
: "ambiguous_resolution";
|
|
174
|
+
return {
|
|
175
|
+
status: "ambiguous",
|
|
176
|
+
matchRank: bestRank,
|
|
177
|
+
matchCount: best.length,
|
|
178
|
+
reasonCode: includesSource ? reasonCode : "ambiguous_resolution",
|
|
179
|
+
candidates: best,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (
|
|
184
|
+
detectUnicodeAmbiguity(
|
|
185
|
+
targetRefNorm,
|
|
186
|
+
input.targetCollection,
|
|
187
|
+
input.catalog,
|
|
188
|
+
best
|
|
189
|
+
)
|
|
190
|
+
) {
|
|
191
|
+
return {
|
|
192
|
+
status: "ambiguous",
|
|
193
|
+
matchRank: bestRank,
|
|
194
|
+
matchCount: best.length + 1,
|
|
195
|
+
reasonCode: "unicode_normalization_mismatch",
|
|
196
|
+
candidates: best,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const unique = best[0];
|
|
201
|
+
if (!unique) {
|
|
202
|
+
return { status: "unresolved", matchCount: 0, candidates: [] };
|
|
203
|
+
}
|
|
204
|
+
if (unique.uri !== input.sourceUri) {
|
|
205
|
+
return {
|
|
206
|
+
status: "elsewhere",
|
|
207
|
+
document: unique,
|
|
208
|
+
matchRank: bestRank,
|
|
209
|
+
matchCount: 1,
|
|
210
|
+
candidates: best,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
status: "unique",
|
|
215
|
+
document: unique,
|
|
216
|
+
matchRank: bestRank,
|
|
217
|
+
matchCount: 1,
|
|
218
|
+
candidates: best,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Resolve a markdown path target (already normalized to collection-relative).
|
|
224
|
+
*/
|
|
225
|
+
export function resolveMarkdownTarget(input: {
|
|
226
|
+
targetRefNorm: string;
|
|
227
|
+
targetCollection: string;
|
|
228
|
+
sourceUri: string;
|
|
229
|
+
catalog: readonly FileRefactorCatalogDocument[];
|
|
230
|
+
}): FileRefactorResolution {
|
|
231
|
+
const matches = input.catalog
|
|
232
|
+
.filter(
|
|
233
|
+
(doc) =>
|
|
234
|
+
doc.active !== false &&
|
|
235
|
+
doc.collection === input.targetCollection &&
|
|
236
|
+
doc.relPath === input.targetRefNorm
|
|
237
|
+
)
|
|
238
|
+
.sort((a, b) => a.id - b.id);
|
|
239
|
+
|
|
240
|
+
if (matches.length === 0) {
|
|
241
|
+
return { status: "unresolved", matchCount: 0, candidates: [] };
|
|
242
|
+
}
|
|
243
|
+
if (matches.length > 1) {
|
|
244
|
+
return {
|
|
245
|
+
status: "ambiguous",
|
|
246
|
+
matchCount: matches.length,
|
|
247
|
+
reasonCode: "ambiguous_resolution",
|
|
248
|
+
candidates: matches,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const unique = matches[0]!;
|
|
252
|
+
if (unique.uri !== input.sourceUri) {
|
|
253
|
+
return {
|
|
254
|
+
status: "elsewhere",
|
|
255
|
+
document: unique,
|
|
256
|
+
matchCount: 1,
|
|
257
|
+
candidates: matches,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
status: "unique",
|
|
262
|
+
document: unique,
|
|
263
|
+
matchCount: 1,
|
|
264
|
+
candidates: matches,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Normalize a markdown inventory destination relative to the referring doc.
|
|
270
|
+
* Unescapes CommonMark punctuation and strips angle brackets before the shared
|
|
271
|
+
* path normalizer (which rejects raw backslashes).
|
|
272
|
+
*/
|
|
273
|
+
export function normalizeInventoryMarkdownTarget(
|
|
274
|
+
destination: string,
|
|
275
|
+
referringRelPath: string
|
|
276
|
+
): string | null {
|
|
277
|
+
const angled = stripAngleBracketDestination(destination);
|
|
278
|
+
const unescaped = unescapeCommonMarkDestination(angled.path);
|
|
279
|
+
return normalizeMarkdownPath(unescaped, referringRelPath);
|
|
280
|
+
}
|