@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,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
+ }