@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,486 @@
1
+ /**
2
+ * Transport-neutral, versioned reference-safe rename/move contracts.
3
+ *
4
+ * Filesystem atomicity and post-commit index convergence are consecutive,
5
+ * separate states — never one shared transaction. Destination-only edit spans
6
+ * preserve aliases, labels, titles, fragments, queries, escaping, and encoding.
7
+ *
8
+ * @module src/core/file-refactor-contract
9
+ */
10
+
11
+ export const FILE_REFACTOR_SCHEMA_VERSION = "1.0" as const;
12
+
13
+ /** Exact apply confirmation token — never inferred from free text. */
14
+ export const FILE_REFACTOR_APPLY_CONFIRMATION = "apply" as const;
15
+
16
+ export type FileRefactorOperation = "rename" | "move";
17
+
18
+ export type FileRefactorConflictPolicy = "fail";
19
+
20
+ export type FileRefactorReferenceKind =
21
+ | "wiki"
22
+ | "markdown"
23
+ | "markdown_definition"
24
+ | "opaque";
25
+
26
+ /**
27
+ * Per-reference classification from a conservative impact preview.
28
+ * Malformed is distinct from unsupported (recognized but unsafe to rewrite).
29
+ */
30
+ export type FileRefactorReferenceClassification =
31
+ | "rewriteable"
32
+ | "unchanged"
33
+ | "ambiguous"
34
+ | "unsupported"
35
+ | "malformed"
36
+ | "invalid";
37
+
38
+ /**
39
+ * Stable reason taxonomy for preview/apply diagnostics.
40
+ * Codes are closed; surfaces must not invent ad-hoc strings.
41
+ */
42
+ export const FILE_REFACTOR_REASON_CODES = [
43
+ "ambiguous_resolution",
44
+ "unsupported_syntax",
45
+ "malformed_syntax",
46
+ "stale_plan",
47
+ "capability_denied",
48
+ "occupied_target",
49
+ "sync_pending",
50
+ "cross_collection_unsupported",
51
+ "unsafe_target",
52
+ "external_destination",
53
+ "code_fence_context",
54
+ "inline_code_context",
55
+ "html_context",
56
+ "duplicate_basename_ambiguity",
57
+ "unicode_normalization_mismatch",
58
+ "destination_unchanged",
59
+ "read_only_document",
60
+ "relative_path_recalculated",
61
+ "reference_definition_site",
62
+ "filesystem_commit_failed",
63
+ "rollback_recovery_required",
64
+ ] as const;
65
+
66
+ export type FileRefactorReasonCode =
67
+ (typeof FILE_REFACTOR_REASON_CODES)[number];
68
+
69
+ export type FileRefactorApplyStatus =
70
+ | "applied"
71
+ | "applied_with_sync_pending"
72
+ | "conflict"
73
+ | "stale_plan"
74
+ | "unsupported"
75
+ | "failed_rolled_back";
76
+
77
+ export type FileRefactorFilesystemState =
78
+ | "committed"
79
+ | "rolled_back"
80
+ | "unchanged"
81
+ | "recovery_required";
82
+
83
+ export type FileRefactorIndexConvergenceState =
84
+ | "converged"
85
+ | "pending"
86
+ | "not_attempted"
87
+ | "skipped";
88
+
89
+ /**
90
+ * Minimal edit span: ONLY destination/path token content may change.
91
+ * Alias, Markdown label, title, fragment, query, escaping, and encoding
92
+ * outside this span must remain identical (UTF-16 code-unit slices).
93
+ */
94
+ export interface FileRefactorDestinationSpan {
95
+ coordinateSpace: "utf16_code_units";
96
+ /** Inclusive start offset (UTF-16 code units) of destination token. */
97
+ startOffset: number;
98
+ /** Exclusive end offset (UTF-16 code units) of destination token. */
99
+ endOffset: number;
100
+ originalDestination: string;
101
+ replacementDestination: string;
102
+ }
103
+
104
+ export interface FileRefactorDocumentRef {
105
+ uri: string;
106
+ relPath: string;
107
+ collection: string;
108
+ }
109
+
110
+ export interface FileRefactorExaminedReference {
111
+ documentUri: string;
112
+ documentRelPath: string;
113
+ kind: FileRefactorReferenceKind;
114
+ classification: FileRefactorReferenceClassification;
115
+ reasonCode?: FileRefactorReasonCode;
116
+ /** Raw destination text before any rewrite (path portion only when known). */
117
+ originalDestination?: string;
118
+ proposedDestination?: string;
119
+ /** Present only when classification is rewriteable. */
120
+ edit?: FileRefactorDestinationSpan;
121
+ startLine?: number;
122
+ startCol?: number;
123
+ endLine?: number;
124
+ endCol?: number;
125
+ }
126
+
127
+ export interface FileRefactorAffectedDocument {
128
+ uri: string;
129
+ relPath: string;
130
+ /** SHA-256 hex of full document UTF-8 content at plan time. */
131
+ contentFingerprint: string;
132
+ edits: FileRefactorDestinationSpan[];
133
+ examined: FileRefactorExaminedReference[];
134
+ }
135
+
136
+ export interface FileRefactorPreconditions {
137
+ sourceContentFingerprint: string;
138
+ affectedContentFingerprints: Array<{
139
+ uri: string;
140
+ fingerprint: string;
141
+ }>;
142
+ /** Hex digest over the intended target path absence/occupancy check. */
143
+ targetPathFingerprint: string;
144
+ }
145
+
146
+ /**
147
+ * Explicit split between durable filesystem mutation and index refresh.
148
+ * Never claim one transaction spans both.
149
+ */
150
+ export interface FileRefactorMutationBoundary {
151
+ filesystemCommit: "atomic_all_or_rollback";
152
+ indexConvergence: "post_commit_separate";
153
+ syncFailureDoesNotRollbackFilesystem: true;
154
+ }
155
+
156
+ export interface FileRefactorSafetySummary {
157
+ rewriteableCount: number;
158
+ unchangedCount: number;
159
+ ambiguousCount: number;
160
+ unsupportedCount: number;
161
+ malformedCount: number;
162
+ invalidCount: number;
163
+ blockingReasonCodes: FileRefactorReasonCode[];
164
+ warnings: string[];
165
+ backlinkCount: number;
166
+ wikiLinkCount: number;
167
+ markdownLinkCount: number;
168
+ }
169
+
170
+ export interface FileRefactorPreviewPlan {
171
+ schemaVersion: typeof FILE_REFACTOR_SCHEMA_VERSION;
172
+ operation: FileRefactorOperation;
173
+ conflictPolicy: FileRefactorConflictPolicy;
174
+ source: FileRefactorDocumentRef;
175
+ target: FileRefactorDocumentRef;
176
+ affectedDocuments: FileRefactorAffectedDocument[];
177
+ /** Deterministic ordered list of every examined reference (incl. opaque). */
178
+ examinedReferences: FileRefactorExaminedReference[];
179
+ preconditions: FileRefactorPreconditions;
180
+ planDigest: string;
181
+ safety: FileRefactorSafetySummary;
182
+ canApply: boolean;
183
+ mutationBoundary: FileRefactorMutationBoundary;
184
+ }
185
+
186
+ export interface FileRefactorApplyRequest {
187
+ schemaVersion: typeof FILE_REFACTOR_SCHEMA_VERSION;
188
+ planDigest: string;
189
+ confirmation: typeof FILE_REFACTOR_APPLY_CONFIRMATION;
190
+ }
191
+
192
+ interface FileRefactorApplyResultBase {
193
+ schemaVersion: typeof FILE_REFACTOR_SCHEMA_VERSION;
194
+ planDigest: string;
195
+ operation: FileRefactorOperation;
196
+ source: FileRefactorDocumentRef;
197
+ target: FileRefactorDocumentRef;
198
+ }
199
+
200
+ /** Content-free filesystem receipt — never embeds note bodies. */
201
+ export type FileRefactorApplyResult =
202
+ | (FileRefactorApplyResultBase & {
203
+ status: "applied";
204
+ filesystem: { state: "committed"; recoveryJournalId?: string };
205
+ indexConvergence: { state: "converged" };
206
+ })
207
+ | (FileRefactorApplyResultBase & {
208
+ status: "applied_with_sync_pending";
209
+ reasonCode: "sync_pending";
210
+ filesystem: { state: "committed"; recoveryJournalId?: string };
211
+ indexConvergence: {
212
+ state: "pending";
213
+ recoveryInstruction: string;
214
+ };
215
+ })
216
+ | (FileRefactorApplyResultBase & {
217
+ status: "conflict" | "stale_plan" | "unsupported";
218
+ reasonCode: FileRefactorReasonCode;
219
+ filesystem: { state: "unchanged"; recoveryJournalId?: string };
220
+ indexConvergence: { state: "not_attempted" | "skipped" };
221
+ })
222
+ | (FileRefactorApplyResultBase & {
223
+ status: "failed_rolled_back";
224
+ reasonCode: FileRefactorReasonCode;
225
+ filesystem: {
226
+ state: "rolled_back" | "recovery_required";
227
+ recoveryJournalId?: string;
228
+ };
229
+ indexConvergence: { state: "not_attempted" | "skipped" };
230
+ });
231
+
232
+ export const FILE_REFACTOR_MUTATION_BOUNDARY: FileRefactorMutationBoundary = {
233
+ filesystemCommit: "atomic_all_or_rollback",
234
+ indexConvergence: "post_commit_separate",
235
+ syncFailureDoesNotRollbackFilesystem: true,
236
+ };
237
+
238
+ const UTF8 = new TextEncoder();
239
+
240
+ /** SHA-256 hex fingerprint via Web Crypto (browser- and Bun-safe). */
241
+ export async function fingerprintUtf8Content(content: string): Promise<string> {
242
+ const digest = new Uint8Array(
243
+ await crypto.subtle.digest("SHA-256", UTF8.encode(content))
244
+ );
245
+ return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
246
+ }
247
+
248
+ /**
249
+ * Ordinal UTF-16 code-unit comparison — locale/ICU/OS independent.
250
+ * Uses `<`/`>` on JS strings (16-bit code units), not localeCompare.
251
+ */
252
+ export function compareUtf16CodeUnits(left: string, right: string): number {
253
+ if (left === right) return 0;
254
+ return left < right ? -1 : 1;
255
+ }
256
+
257
+ /**
258
+ * Canonical serializer matching JSON transport semantics with sorted keys.
259
+ * - Object keys with `undefined` values are omitted (like JSON.stringify).
260
+ * - Array holes / `undefined` items serialize as `null`.
261
+ * - Non-finite numbers, bigint, symbol, and function values are rejected.
262
+ */
263
+ export function stableStringify(value: unknown): string {
264
+ if (value === undefined) {
265
+ throw new Error("stableStringify cannot serialize top-level undefined");
266
+ }
267
+ if (value === null) return "null";
268
+ const valueType = typeof value;
269
+ if (valueType === "boolean" || valueType === "string") {
270
+ return JSON.stringify(value);
271
+ }
272
+ if (valueType === "number") {
273
+ if (!Number.isFinite(value)) {
274
+ throw new Error("stableStringify rejects non-finite numbers");
275
+ }
276
+ return JSON.stringify(value);
277
+ }
278
+ if (
279
+ valueType === "bigint" ||
280
+ valueType === "symbol" ||
281
+ valueType === "function"
282
+ ) {
283
+ throw new Error(`stableStringify rejects unsupported type: ${valueType}`);
284
+ }
285
+ if (valueType !== "object") {
286
+ throw new Error(`stableStringify rejects unsupported type: ${valueType}`);
287
+ }
288
+ if (Array.isArray(value)) {
289
+ return `[${value
290
+ .map((item) => (item === undefined ? "null" : stableStringify(item)))
291
+ .join(",")}]`;
292
+ }
293
+ const record = value as Record<string, unknown>;
294
+ const keys = Object.keys(record).sort(compareUtf16CodeUnits);
295
+ const parts: string[] = [];
296
+ for (const key of keys) {
297
+ const entry = record[key];
298
+ if (entry === undefined) continue;
299
+ parts.push(`${JSON.stringify(key)}:${stableStringify(entry)}`);
300
+ }
301
+ return `{${parts.join(",")}}`;
302
+ }
303
+
304
+ /**
305
+ * Deterministic plan digest over material preview fields (excludes planDigest).
306
+ * Ordering of examined references and affected documents must already be stable.
307
+ */
308
+ export async function computeFileRefactorPlanDigest(
309
+ plan: Omit<FileRefactorPreviewPlan, "planDigest">
310
+ ): Promise<string> {
311
+ return fingerprintUtf8Content(
312
+ stableStringify({
313
+ schemaVersion: plan.schemaVersion,
314
+ operation: plan.operation,
315
+ conflictPolicy: plan.conflictPolicy,
316
+ source: plan.source,
317
+ target: plan.target,
318
+ affectedDocuments: plan.affectedDocuments,
319
+ examinedReferences: plan.examinedReferences,
320
+ preconditions: plan.preconditions,
321
+ safety: plan.safety,
322
+ canApply: plan.canApply,
323
+ mutationBoundary: plan.mutationBoundary,
324
+ })
325
+ );
326
+ }
327
+
328
+ /** Compare references for deterministic plan ordering. */
329
+ export function compareExaminedReferences(
330
+ left: FileRefactorExaminedReference,
331
+ right: FileRefactorExaminedReference
332
+ ): number {
333
+ const byDoc = compareUtf16CodeUnits(
334
+ left.documentRelPath,
335
+ right.documentRelPath
336
+ );
337
+ if (byDoc !== 0) return byDoc;
338
+ const leftLine = left.startLine ?? 0;
339
+ const rightLine = right.startLine ?? 0;
340
+ if (leftLine !== rightLine) return leftLine - rightLine;
341
+ const leftCol = left.startCol ?? 0;
342
+ const rightCol = right.startCol ?? 0;
343
+ if (leftCol !== rightCol) return leftCol - rightCol;
344
+ return compareUtf16CodeUnits(
345
+ left.originalDestination ?? "",
346
+ right.originalDestination ?? ""
347
+ );
348
+ }
349
+
350
+ export function sortExaminedReferences(
351
+ references: FileRefactorExaminedReference[]
352
+ ): FileRefactorExaminedReference[] {
353
+ return [...references].sort(compareExaminedReferences);
354
+ }
355
+
356
+ /**
357
+ * Apply a destination-only span. Throws when the live slice does not match
358
+ * originalDestination (stale span / wrong coordinate space).
359
+ */
360
+ export function applyDestinationOnlyEdit(
361
+ content: string,
362
+ span: FileRefactorDestinationSpan
363
+ ): string {
364
+ if (span.coordinateSpace !== "utf16_code_units") {
365
+ throw new Error("Unsupported coordinate space for destination edit");
366
+ }
367
+ if (span.endOffset < span.startOffset) {
368
+ throw new Error("Invalid destination span offsets");
369
+ }
370
+ const actual = content.slice(span.startOffset, span.endOffset);
371
+ if (actual !== span.originalDestination) {
372
+ throw new Error(
373
+ "Destination span does not match originalDestination (stale or misaligned)"
374
+ );
375
+ }
376
+ return (
377
+ content.slice(0, span.startOffset) +
378
+ span.replacementDestination +
379
+ content.slice(span.endOffset)
380
+ );
381
+ }
382
+
383
+ /**
384
+ * True when every UTF-16 code unit outside the destination span is unchanged.
385
+ * Full-document UTF-8 fingerprints prove content changed; this proves only the
386
+ * destination token content changed.
387
+ */
388
+ export function isContentPreservedOutsideSpan(
389
+ before: string,
390
+ after: string,
391
+ span: FileRefactorDestinationSpan
392
+ ): boolean {
393
+ const prefix = before.slice(0, span.startOffset);
394
+ const suffix = before.slice(span.endOffset);
395
+ const expected = prefix + span.replacementDestination + suffix;
396
+ if (after !== expected) {
397
+ return false;
398
+ }
399
+ return (
400
+ after.slice(0, span.startOffset) === prefix &&
401
+ after.slice(span.startOffset + span.replacementDestination.length) ===
402
+ suffix
403
+ );
404
+ }
405
+
406
+ /**
407
+ * @deprecated Prefer {@link isContentPreservedOutsideSpan}. Alias retained for
408
+ * import stability; compares UTF-16 code-unit slices, not UTF-8 bytes.
409
+ */
410
+ export const isBytePreservedOutsideSpan = isContentPreservedOutsideSpan;
411
+
412
+ export function summarizeReferenceClassifications(
413
+ references: FileRefactorExaminedReference[],
414
+ warningSnapshot: {
415
+ warnings?: string[];
416
+ backlinks?: number;
417
+ wikiLinks?: number;
418
+ markdownLinks?: number;
419
+ } = {}
420
+ ): FileRefactorSafetySummary {
421
+ const counts = {
422
+ rewriteableCount: 0,
423
+ unchangedCount: 0,
424
+ ambiguousCount: 0,
425
+ unsupportedCount: 0,
426
+ malformedCount: 0,
427
+ invalidCount: 0,
428
+ };
429
+ const blocking = new Set<FileRefactorReasonCode>();
430
+
431
+ for (const reference of references) {
432
+ switch (reference.classification) {
433
+ case "rewriteable":
434
+ counts.rewriteableCount += 1;
435
+ break;
436
+ case "unchanged":
437
+ counts.unchangedCount += 1;
438
+ break;
439
+ case "ambiguous":
440
+ counts.ambiguousCount += 1;
441
+ if (reference.reasonCode) blocking.add(reference.reasonCode);
442
+ else blocking.add("ambiguous_resolution");
443
+ break;
444
+ case "unsupported":
445
+ counts.unsupportedCount += 1;
446
+ if (reference.reasonCode) blocking.add(reference.reasonCode);
447
+ else blocking.add("unsupported_syntax");
448
+ break;
449
+ case "malformed":
450
+ counts.malformedCount += 1;
451
+ if (reference.reasonCode) blocking.add(reference.reasonCode);
452
+ else blocking.add("malformed_syntax");
453
+ break;
454
+ case "invalid":
455
+ counts.invalidCount += 1;
456
+ if (reference.reasonCode) blocking.add(reference.reasonCode);
457
+ break;
458
+ }
459
+ }
460
+
461
+ return {
462
+ ...counts,
463
+ blockingReasonCodes: [...blocking].sort(compareUtf16CodeUnits),
464
+ warnings: warningSnapshot.warnings ?? [],
465
+ backlinkCount: warningSnapshot.backlinks ?? 0,
466
+ wikiLinkCount: warningSnapshot.wikiLinks ?? 0,
467
+ markdownLinkCount: warningSnapshot.markdownLinks ?? 0,
468
+ };
469
+ }
470
+
471
+ export function deriveCanApply(input: {
472
+ safety: FileRefactorSafetySummary;
473
+ sourceEditable: boolean;
474
+ targetOccupied: boolean;
475
+ sameCollection: boolean;
476
+ }): boolean {
477
+ if (!input.sourceEditable) return false;
478
+ if (!input.sameCollection) return false;
479
+ if (input.targetOccupied) return false;
480
+ if (input.safety.blockingReasonCodes.length > 0) return false;
481
+ if (input.safety.ambiguousCount > 0) return false;
482
+ if (input.safety.unsupportedCount > 0) return false;
483
+ if (input.safety.malformedCount > 0) return false;
484
+ if (input.safety.invalidCount > 0) return false;
485
+ return true;
486
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Destination token recomputation for wiki / markdown reference rewrites.
3
+ *
4
+ * @module src/core/file-refactor-destination
5
+ */
6
+
7
+ // node:path/posix — no Bun path utils
8
+ import { posix as pathPosix } from "node:path";
9
+
10
+ import type { LinkInventoryToken } from "./link-inventory-types";
11
+
12
+ import {
13
+ applyDestinationEncodingStyle,
14
+ stripAngleBracketDestination,
15
+ unescapeCommonMarkDestination,
16
+ } from "./link-destination-parse";
17
+ import { stripWikiMdExt } from "./links";
18
+
19
+ function preserveDotSlash(original: string, nextRelative: string): string {
20
+ if (!original.startsWith("./")) return nextRelative;
21
+ if (nextRelative.startsWith("../") || nextRelative.startsWith("./")) {
22
+ return nextRelative;
23
+ }
24
+ return `./${nextRelative}`;
25
+ }
26
+
27
+ /**
28
+ * Compute a wiki destination token for the new target identity, preserving
29
+ * basename-vs-path and optional `.md` extension style when safely possible.
30
+ */
31
+ export function computeWikiReplacementDestination(input: {
32
+ originalDestination: string;
33
+ sourceRelPath: string;
34
+ sourceTitle: string | null | undefined;
35
+ targetRelPath: string;
36
+ targetTitle?: string | null;
37
+ }): string {
38
+ const original = input.originalDestination;
39
+ const hadMd = original.toLowerCase().endsWith(".md");
40
+ const originalBase = stripWikiMdExt(pathPosix.basename(original));
41
+ const sourceBase = stripWikiMdExt(pathPosix.basename(input.sourceRelPath));
42
+ const targetBase = stripWikiMdExt(pathPosix.basename(input.targetRelPath));
43
+ const targetTitle =
44
+ input.targetTitle?.trim() ||
45
+ pathPosix.basename(
46
+ input.targetRelPath,
47
+ pathPosix.extname(input.targetRelPath)
48
+ );
49
+
50
+ const looksLikePath =
51
+ original.includes("/") ||
52
+ original.toLowerCase() === input.sourceRelPath.toLowerCase() ||
53
+ stripWikiMdExt(original).toLowerCase() ===
54
+ stripWikiMdExt(input.sourceRelPath).toLowerCase();
55
+
56
+ if (looksLikePath) {
57
+ let next = input.targetRelPath;
58
+ if (!hadMd) {
59
+ next = stripWikiMdExt(next);
60
+ }
61
+ if (!original.includes("/") && pathPosix.basename(original) === original) {
62
+ next = hadMd ? `${targetBase}.md` : targetBase;
63
+ }
64
+ return next;
65
+ }
66
+
67
+ const sourceTitle = input.sourceTitle?.trim() ?? sourceBase;
68
+ if (
69
+ originalBase.toLowerCase() === sourceTitle.toLowerCase() ||
70
+ originalBase.toLowerCase() === sourceBase.toLowerCase()
71
+ ) {
72
+ const next = targetTitle;
73
+ return hadMd ? `${next}.md` : next;
74
+ }
75
+
76
+ return hadMd ? `${targetBase}.md` : targetBase;
77
+ }
78
+
79
+ /**
80
+ * Recompute a markdown (or reference-definition) relative destination from the
81
+ * referring document directory to the new target path, preserving escape style.
82
+ */
83
+ export function computeMarkdownReplacementDestination(input: {
84
+ token: Pick<
85
+ LinkInventoryToken,
86
+ "originalDestination" | "hadLeadingDotSlash" | "encodingStyle"
87
+ >;
88
+ referringRelPath: string;
89
+ targetRelPath: string;
90
+ }): string {
91
+ const referringDir = pathPosix.dirname(input.referringRelPath);
92
+ let relative = pathPosix.relative(referringDir, input.targetRelPath);
93
+ if (!relative || relative === "") {
94
+ relative = pathPosix.basename(input.targetRelPath);
95
+ }
96
+ const originalForDot = stripAngleBracketDestination(
97
+ unescapeCommonMarkDestination(input.token.originalDestination)
98
+ ).path;
99
+ if (
100
+ input.token.hadLeadingDotSlash ||
101
+ originalForDot.startsWith("./") ||
102
+ input.token.originalDestination.startsWith("./")
103
+ ) {
104
+ relative = preserveDotSlash(
105
+ originalForDot.startsWith("./")
106
+ ? originalForDot
107
+ : input.token.originalDestination,
108
+ relative
109
+ );
110
+ }
111
+ return applyDestinationEncodingStyle(relative, input.token.encodingStyle);
112
+ }
113
+
114
+ /**
115
+ * After computing a wiki replacement, decide whether leaving the destination
116
+ * unchanged is acceptable because the original still uniquely names the target.
117
+ */
118
+ export function wikiDestinationUnchangedAcceptable(input: {
119
+ originalDestination: string;
120
+ replacementDestination: string;
121
+ }): boolean {
122
+ return input.originalDestination === input.replacementDestination;
123
+ }