dotmd-cli 0.71.3 → 0.71.4
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/package.json +1 -1
- package/src/reference-planner.mjs +118 -16
package/package.json
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import { realpathSync } from 'node:fs';
|
|
1
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
|
|
5
5
|
function slash(value) { return value.split(path.sep).join('/'); }
|
|
6
6
|
|
|
7
|
-
function canonicalExisting(filePath) {
|
|
7
|
+
function canonicalExisting(filePath, memo = null) {
|
|
8
|
+
const key = memo ? path.resolve(filePath) : null;
|
|
9
|
+
if (memo) {
|
|
10
|
+
const hit = memo.get(key);
|
|
11
|
+
if (hit !== undefined) return hit;
|
|
12
|
+
}
|
|
13
|
+
const identity = resolveCanonical(filePath);
|
|
14
|
+
if (memo) memo.set(key, identity);
|
|
15
|
+
return identity;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveCanonical(filePath) {
|
|
8
19
|
try { return realpathSync(filePath); }
|
|
9
20
|
catch {
|
|
10
21
|
const suffix = [];
|
|
@@ -36,31 +47,118 @@ export function configuredReferenceFields(config) {
|
|
|
36
47
|
])];
|
|
37
48
|
}
|
|
38
49
|
|
|
50
|
+
// Marks a case fold shared by two corpus documents. On a case-sensitive
|
|
51
|
+
// filesystem those are genuinely two files, and nothing may collapse them.
|
|
52
|
+
const AMBIGUOUS_FOLD = Symbol('ambiguous case fold');
|
|
53
|
+
|
|
54
|
+
// The set carries four indexes beside the identities themselves:
|
|
55
|
+
// canonical — memoized path resolution. Resolving one reference costs two
|
|
56
|
+
// `canonicalExisting` calls, and the repository-relative spelling usually
|
|
57
|
+
// does NOT exist, which sends it up the tree doing a realpath per ancestor.
|
|
58
|
+
// Over a corpus-wide sweep that is tens of thousands of syscalls across a
|
|
59
|
+
// few hundred distinct paths, and it dominated the reference rewrite. The
|
|
60
|
+
// memo lives here so it is scoped to one sweep and cannot outlive a
|
|
61
|
+
// mutation — `dotmd bulk` builds a fresh set per move. Within a sweep it is
|
|
62
|
+
// also more consistent than re-resolving per token, which could observe the
|
|
63
|
+
// filesystem changing midway.
|
|
64
|
+
// paths — identity to the spelling the corpus used.
|
|
65
|
+
// names — identity to every basename it answers to (symlink aliases).
|
|
66
|
+
// folded — case fold to identity, for filesystems that fold case.
|
|
39
67
|
export function createReferenceIdentitySet(filePaths) {
|
|
40
68
|
const identities = new Set();
|
|
41
69
|
identities.paths = new Map();
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
70
|
+
identities.canonical = new Map();
|
|
71
|
+
identities.names = new Map();
|
|
72
|
+
identities.folded = new Map();
|
|
73
|
+
identities.symlinked = false;
|
|
74
|
+
for (const filePath of filePaths) registerIdentity(identities, filePath);
|
|
47
75
|
return identities;
|
|
48
76
|
}
|
|
49
77
|
|
|
78
|
+
function registerIdentity(identities, filePath) {
|
|
79
|
+
const resolved = path.resolve(filePath);
|
|
80
|
+
const identity = canonicalExisting(filePath, identities.canonical);
|
|
81
|
+
// A corpus path whose realpath differs is a symlink, so one document can be
|
|
82
|
+
// reachable under more than one name. `names` records every one of them —
|
|
83
|
+
// and `symlinked` warns the candidate prefilter that names are not a
|
|
84
|
+
// reliable signal in this repo at all.
|
|
85
|
+
if (identity !== resolved) identities.symlinked = true;
|
|
86
|
+
identities.add(identity);
|
|
87
|
+
identities.paths.set(identity, resolved);
|
|
88
|
+
let names = identities.names.get(identity);
|
|
89
|
+
if (!names) identities.names.set(identity, names = new Set());
|
|
90
|
+
names.add(path.basename(resolved).toLowerCase());
|
|
91
|
+
const key = identity.toLowerCase();
|
|
92
|
+
const seen = identities.folded.get(key);
|
|
93
|
+
identities.folded.set(key, seen === undefined || seen === identity ? identity : AMBIGUOUS_FOLD);
|
|
94
|
+
return identity;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Can this document possibly hold a reference to `oldIdentity`? Every token the
|
|
98
|
+
// rewriter will touch has to survive `/\.md$/i` and then resolve to the moved
|
|
99
|
+
// file, so the document must spell one of that file's names somewhere. Checking
|
|
100
|
+
// that first skips the fence-aware walk for the ~99% of a corpus that never
|
|
101
|
+
// mentions the file — the measured difference on a 2,000-doc repo is 950ms of
|
|
102
|
+
// rewriting versus 120ms.
|
|
103
|
+
//
|
|
104
|
+
// The comparison mirrors the resolver: case-folded (a case-insensitive
|
|
105
|
+
// filesystem resolves `FOO.MD` to `foo.md`, and folding can only over-include),
|
|
106
|
+
// and with the same backslash escapes unwound, so `my\ plan.md` still matches
|
|
107
|
+
// `my plan.md`. Percent-encoding needs no handling — the resolver does not
|
|
108
|
+
// decode it either, so `foo%20bar.md` never resolves in the first place.
|
|
109
|
+
//
|
|
110
|
+
// The boundary: a symlink whose name differs from its target's. Aliases inside
|
|
111
|
+
// the corpus are covered by `names`; a symlink that is NOT itself a collected
|
|
112
|
+
// doc is not, so any repo that symlinks docs at all fails open to the full walk.
|
|
113
|
+
function mayReferenceIdentity(content, oldIdentity, oldPath, identities) {
|
|
114
|
+
if (!identities?.names || identities.symlinked) return true;
|
|
115
|
+
const names = identities.names.get(oldIdentity);
|
|
116
|
+
const haystack = (content.includes('\\') ? content.replace(/\\([\s()[\]<>])/g, '$1') : content).toLowerCase();
|
|
117
|
+
if (haystack.includes(path.basename(oldPath).toLowerCase())) return true;
|
|
118
|
+
if (names) for (const name of names) if (haystack.includes(name)) return true;
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
50
122
|
// Both interpretations are evaluated. A local document wins only when the
|
|
51
123
|
// repo-relative spelling is absent or names the same identity; disagreement is
|
|
52
124
|
// rejected rather than guessed.
|
|
53
125
|
export function resolveReferenceIdentity(token, documentPath, repoRoot, identities) {
|
|
54
126
|
if (!token || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(token)) return null;
|
|
55
127
|
const clean = token.replace(/[?#].*$/, '').replace(/\\([\s()[\]<>])/g, '$1');
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
if (localExists && repositoryExists && local !== repository) {
|
|
128
|
+
const memo = identities?.canonical ?? null;
|
|
129
|
+
const local = matchIdentity(canonicalExisting(path.resolve(path.dirname(documentPath), clean), memo), identities);
|
|
130
|
+
const repository = matchIdentity(canonicalExisting(path.resolve(repoRoot, clean.replace(/^\/+/, '')), memo), identities);
|
|
131
|
+
if (local && repository && local !== repository) {
|
|
61
132
|
throw new AmbiguousReferenceError(token, documentPath, local, repository);
|
|
62
133
|
}
|
|
63
|
-
return
|
|
134
|
+
return local ?? repository ?? null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// `realpath` resolves symlinks but NOT case: on a case-insensitive filesystem
|
|
138
|
+
// `realpath("CASING.MD")` hands back the caller's spelling, so an exact compare
|
|
139
|
+
// misses a link that names a real document. Validation disagreed — it resolves
|
|
140
|
+
// with `existsSync`, which does not care about case — so `dotmd check` called
|
|
141
|
+
// such a link fine, a move silently left it pointing at the old path, and only
|
|
142
|
+
// THEN did check call it broken.
|
|
143
|
+
//
|
|
144
|
+
// The tie-break is the inode, not a guess about the filesystem: same device and
|
|
145
|
+
// inode means the two spellings are one file, which is only ever true where the
|
|
146
|
+
// filesystem itself folds case. On a case-sensitive filesystem `Foo.md` and
|
|
147
|
+
// `foo.md` are separate inodes and stay separate here, and two corpus documents
|
|
148
|
+
// that differ only by case poison their shared fold so neither is guessed at.
|
|
149
|
+
function matchIdentity(candidate, identities) {
|
|
150
|
+
if (identities.has(candidate)) return candidate;
|
|
151
|
+
const folded = identities.folded?.get(candidate.toLowerCase());
|
|
152
|
+
if (folded === undefined || folded === AMBIGUOUS_FOLD) return null;
|
|
153
|
+
return sameFileOnDisk(candidate, folded) ? folded : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sameFileOnDisk(left, right) {
|
|
157
|
+
try {
|
|
158
|
+
const a = statSync(left);
|
|
159
|
+
const b = statSync(right);
|
|
160
|
+
return a.dev === b.dev && a.ino === b.ino && a.ino !== 0;
|
|
161
|
+
} catch { return false; }
|
|
64
162
|
}
|
|
65
163
|
|
|
66
164
|
function rewriteToken(token, sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll, format = 'plain') {
|
|
@@ -264,7 +362,10 @@ export function rewriteDocumentReferences(content, {
|
|
|
264
362
|
}) {
|
|
265
363
|
const { frontmatter, body } = extractFrontmatter(content);
|
|
266
364
|
if (!frontmatter) return content;
|
|
267
|
-
const oldIdentity = canonicalExisting(oldPath);
|
|
365
|
+
const oldIdentity = canonicalExisting(oldPath, identities?.canonical ?? null);
|
|
366
|
+
// `rebaseAll` rewrites every reference the document holds because the
|
|
367
|
+
// document itself moved, so no single name can gate it.
|
|
368
|
+
if (!rebaseAll && !mayReferenceIdentity(content, oldIdentity, oldPath, identities)) return content;
|
|
268
369
|
const args = [sourcePath, outputPath, repoRoot, identities, oldIdentity, newPath, rebaseAll];
|
|
269
370
|
const nextFrontmatter = rewriteFrontmatter(frontmatter, referenceFields, args);
|
|
270
371
|
const nextBody = rewriteMarkdown(body, args);
|
|
@@ -273,8 +374,9 @@ export function rewriteDocumentReferences(content, {
|
|
|
273
374
|
|
|
274
375
|
export function planReferenceMove({ documents, oldPath, newPath, repoRoot, referenceFields = [] }) {
|
|
275
376
|
const identities = createReferenceIdentitySet(documents.map(document => document.path));
|
|
276
|
-
|
|
277
|
-
|
|
377
|
+
// Register rather than bare-add: the case fold and name index have to know
|
|
378
|
+
// about the moved document too, or a differently-cased link to it misses.
|
|
379
|
+
const oldIdentity = registerIdentity(identities, oldPath);
|
|
278
380
|
const source = documents.find(document => path.resolve(document.path) === path.resolve(oldPath));
|
|
279
381
|
if (!source) throw new Error(`Reference move plan is missing source content: ${oldPath}`);
|
|
280
382
|
const movedContent = rewriteDocumentReferences(source.content, {
|