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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.71.3",
3
+ "version": "0.71.4",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- for (const filePath of filePaths) {
43
- const identity = canonicalExisting(filePath);
44
- identities.add(identity);
45
- identities.paths.set(identity, path.resolve(filePath));
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 local = canonicalExisting(path.resolve(path.dirname(documentPath), clean));
57
- const repository = canonicalExisting(path.resolve(repoRoot, clean.replace(/^\/+/, '')));
58
- const localExists = identities.has(local);
59
- const repositoryExists = identities.has(repository);
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 localExists ? local : (repositoryExists ? repository : null);
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
- const oldIdentity = canonicalExisting(oldPath);
277
- identities.add(oldIdentity);
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, {