-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfix.ts
More file actions
247 lines (226 loc) · 8.04 KB
/
Copy pathfix.ts
File metadata and controls
247 lines (226 loc) · 8.04 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
import { writeFile } from 'node:fs/promises';
import path from 'node:path';
import { globby } from 'globby';
import { GLOB_OPTIONS, loadDocs } from './check.js';
import { findRepoRoot, toPosix } from './repo.js';
import { Resolver, similarity } from './resolve.js';
import { readSumFile, sumKey } from './sumfile.js';
import type { SumEntry } from './sumfile.js';
import type { Ref, Resolution } from './types.js';
export interface FixEdit {
doc: string;
line: number;
/** Link URL as written in the doc. */
oldUrl: string;
newUrl: string;
reason: string;
}
export interface FixReport {
edits: FixEdit[];
/** Broken refs we refused to touch, with why. */
skipped: Array<{ resolution: Resolution; reason: string }>;
}
export interface FixOptions {
cwd?: string;
globs?: string[];
/** Apply edits to disk. Default is dry-run (§7.2). */
write?: boolean;
/** Also rewrite compat-form refs to canonical `#sym:` (SPEC §5.3). */
canonicalize?: boolean;
}
/** Similarity threshold for the rename heuristic (edit distance ≤ 2 on short names). */
const RENAME_CONFIDENCE = 0.75;
export async function fix(options: FixOptions = {}): Promise<FixReport> {
const repoRoot = findRepoRoot(options.cwd ?? process.cwd());
const globs = options.globs?.length ? options.globs : ['**/*.md'];
const docs = await globby(globs, { ...GLOB_OPTIONS, cwd: repoRoot });
docs.sort();
const resolver = new Resolver(repoRoot);
// Sum file presence upgrades rename detection to content-verified (§9.2);
// absence degrades gracefully to heuristics, never blocks.
const sumEntries = await readSumFile(repoRoot);
// Memoize the moved-file candidate search: many broken refs to the same
// moved file must not re-glob the whole repo each time.
const basenameMatches = new Map<string, Promise<string[]>>();
const edits: FixEdit[] = [];
const skipped: FixReport['skipped'] = [];
const loaded = await loadDocs(repoRoot, docs);
for (const { doc: docPath, abs, content, refs } of loaded) {
const docEdits: FixEdit[] = [];
for (const ref of refs) {
const resolution = await resolver.resolve(ref);
if (resolution.status === 'broken' && ref.syntaxError) {
// Malformed refs can't be auto-repaired, but silence would read as
// "nothing wrong here". Surface them with the cause.
skipped.push({
resolution,
reason: `cannot auto-fix: ${ref.syntaxError}`,
});
} else if (resolution.status === 'broken') {
const edit = await proposeFix(
repoRoot,
resolver,
ref,
resolution,
sumEntries,
basenameMatches,
);
if ('edit' in edit) docEdits.push(edit.edit);
else skipped.push({ resolution, reason: edit.reason });
} else if (
options.canonicalize &&
ref.compat &&
resolution.status === 'ok'
) {
docEdits.push({
doc: docPath,
line: ref.line,
oldUrl: `${ref.rawTarget}#${ref.fragment}`,
newUrl: `${ref.rawTarget}#sym:${ref.fragment}`,
reason: 'canonicalize compat form',
});
}
}
if (options.write && docEdits.length > 0) {
await writeFile(abs, applyEdits(content, docEdits), 'utf8');
}
edits.push(...docEdits);
}
return { edits, skipped };
}
type Proposal = { edit: FixEdit } | { reason: string };
async function proposeFix(
repoRoot: string,
resolver: Resolver,
ref: Ref,
resolution: Resolution,
sumEntries: Map<string, SumEntry> | null,
basenameMatches: Map<string, Promise<string[]>>,
): Promise<Proposal> {
const fragment = `#${ref.fragment}`;
// Case 0, wrong casing. The resolver already found the on-disk path;
// rewriting to it is zero-guess.
if (resolution.diskPath) {
return {
edit: {
doc: ref.doc,
line: ref.line,
oldUrl: `${ref.rawTarget}${fragment}`,
newUrl: `${relativeUrl(ref, resolution.diskPath)}${fragment}`,
reason: `path casing corrected to ${resolution.diskPath}`,
},
};
}
// Case 1, moved file. The target path is gone, but exactly one file in
// the repo has the same basename and still contains the symbol (§7.2).
if (resolution.message === 'file not found') {
const basename = path.basename(ref.targetPath);
let matchesPromise = basenameMatches.get(basename);
if (!matchesPromise) {
matchesPromise = globby(`**/${basename}`, {
...GLOB_OPTIONS,
cwd: repoRoot,
});
basenameMatches.set(basename, matchesPromise);
}
const matches = await matchesPromise;
const confirmed: string[] = [];
for (const m of matches) {
const candidate = {
...ref,
targetPath: toPosix(m),
syntaxError: undefined,
};
const res = await resolver.resolve(candidate);
if (res.status === 'ok') confirmed.push(toPosix(m));
}
if (confirmed.length === 1) {
return {
edit: {
doc: ref.doc,
line: ref.line,
oldUrl: `${ref.rawTarget}${fragment}`,
newUrl: `${relativeUrl(ref, confirmed[0]!)}${fragment}`,
reason: `file moved to ${confirmed[0]}`,
},
};
}
return {
reason:
confirmed.length === 0
? 'file not found and no unique relocation candidate'
: `ambiguous: symbol found in ${confirmed.length} files (${confirmed.join(', ')})`,
};
}
// Case 2a, hash-verified rename (§9.2), the near-certain path. The old
// symbol's stamped hash matches exactly one definition in the same file
// under a new name. Content identity is more reliable than
// string-similarity guessing.
if (sumEntries) {
const stamped = sumEntries.get(sumKey(ref.targetPath, ref.dotpath));
if (stamped) {
const defs = await resolver.definitionsForFile(ref.targetPath);
const sameHash = (defs ?? []).filter((d) => d.hash === stamped.hash);
if (sameHash.length === 1) {
const renamed = sameHash[0]!;
const kindPrefix = ref.kind ? `${ref.kind}:` : '';
return {
edit: {
doc: ref.doc,
line: ref.line,
oldUrl: `${ref.rawTarget}${fragment}`,
newUrl: `${ref.rawTarget}#sym:${kindPrefix}${renamed.chain.join('.')}`,
reason: `content-verified rename to ${renamed.chain.join('.')}`,
},
};
}
}
}
// Case 2b, heuristic rename. Single close candidate in the same file.
const wanted = ref.dotpath[ref.dotpath.length - 1] ?? '';
const close = resolution.candidates.filter(
(c) =>
similarity(wanted, c.symbol.split('.').pop() ?? '') >= RENAME_CONFIDENCE,
);
if (close.length === 1) {
const kindPrefix = ref.kind ? `${ref.kind}:` : '';
return {
edit: {
doc: ref.doc,
line: ref.line,
oldUrl: `${ref.rawTarget}${fragment}`,
newUrl: `${ref.rawTarget}#sym:${kindPrefix}${close[0]!.symbol}`,
reason: `symbol renamed to ${close[0]!.symbol}`,
},
};
}
return {
reason:
close.length === 0
? 'no confident rename candidate'
: `multiple rename candidates: ${close.map((c) => c.symbol).join(', ')}`,
};
}
/** Rewrite the target preserving the ref's addressing style (root vs relative). */
function relativeUrl(ref: Ref, newRepoRelPath: string): string {
if (ref.rawTarget.startsWith('/')) return `/${newRepoRelPath}`;
const rel = path.posix.relative(path.posix.dirname(ref.doc), newRepoRelPath);
return rel;
}
/**
* Replace old link URLs with new ones, scoped to the recorded line so an
* identical URL elsewhere in the doc is left alone. split/join instead of
* String.replace: replacement strings must never interpret `$&`/`$$`
* patterns. `$` is legal in symbol names (SPEC §5.1), and identical URLs
* repeated on one line must all be rewritten.
*/
export function applyEdits(content: string, edits: FixEdit[]): string {
const lines = content.split('\n');
for (const edit of edits) {
const i = edit.line - 1;
if (lines[i] !== undefined) {
lines[i] = lines[i]!.split(edit.oldUrl).join(edit.newUrl);
}
}
return lines.join('\n');
}