hypomnema 1.7.3 → 1.7.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.
@@ -0,0 +1,181 @@
1
+ // scripts/lib/slug-resolver.mjs — shared slug resolution, extracted from
2
+ // lint.mjs and rename.mjs so a future consumer does not grow its own copy.
3
+ //
4
+ // Exposes TWO modes, deliberately not folded into one:
5
+ //
6
+ // - existence-check (lint) — buildSlugMap: a Set, membership only.
7
+ // Fast, but cannot say WHICH page owns a form or whether it is shared.
8
+ // - collision-aware owner (rename) — buildFormIndex + classifyTarget +
9
+ // newTargetFor: a form → Set<rel> map, so a caller can tell an
10
+ // unambiguous resolution from a shared one and refuse to auto-rewrite
11
+ // the latter.
12
+ //
13
+ // Folding these into one structure would be a regression: lint only needs to
14
+ // know a link resolves to SOMETHING, while rename must refuse to touch a link
15
+ // whose form is shared by more than one page. Collectors stay separate too —
16
+ // lint's target universe is pages/projects/journal plus root .md and
17
+ // sources/* (collectLinkTargets, still in lint.mjs); rename scans the whole
18
+ // vault root and treats journal/session-log/weekly/archive/postmortems and
19
+ // sources/* as immutable link SOURCES (preservationClass, below).
20
+ //
21
+ // Masking, link-body parsing, and the immutability/realpath-containment rules
22
+ // travel with owner mode because rename's rewrite pipeline needs all of them
23
+ // together: mask non-wikilink regions, parse a link body, classify it against
24
+ // the form index, and refuse a target that resolves outside the vault.
25
+
26
+ import { lstatSync, realpathSync } from 'fs';
27
+ import { dirname, sep } from 'path';
28
+ import { slugForms } from './wikilink.mjs';
29
+
30
+ // ── existence-check slug map (lint mode) ────────────────────────────────────
31
+ // `extraTargets` are link-target-only slugs (root *.md, sources/*) that
32
+ // resolve wikilinks but are not themselves linted — added verbatim, with NO
33
+ // derived basename/dir-relative aliases, so they can't mask an unrelated
34
+ // broken link.
35
+ export function buildSlugMap(pages, extraTargets = []) {
36
+ const map = new Set();
37
+ for (const { rel } of pages) {
38
+ const noExt = rel.replace(/\.md$/, '').replace(/\\/g, '/');
39
+ const { full, bare, dirRel } = slugForms(noExt);
40
+ map.add(full);
41
+ map.add(bare);
42
+ if (dirRel) map.add(dirRel);
43
+ }
44
+ for (const t of extraTargets) map.add(t);
45
+ return map;
46
+ }
47
+
48
+ // ── collision-aware form index (owner mode) ─────────────────────────────────
49
+ export const dirRelForm = (slug) => slugForms(slug).dirRel;
50
+
51
+ // Unlike buildSlugMap above (a Set that silently dedups collisions), owner
52
+ // mode needs to KNOW when a form is shared, so it maps each form to the SET
53
+ // of page rels that expose it. Precedence forms per page: full noExt slug,
54
+ // bare basename, dir-relative (drop the leading scan-dir segment).
55
+ export function buildFormIndex(pages) {
56
+ const index = new Map(); // form → Set<rel>
57
+ const add = (form, rel) => {
58
+ if (!form) return;
59
+ if (!index.has(form)) index.set(form, new Set());
60
+ index.get(form).add(rel);
61
+ };
62
+ for (const p of pages) {
63
+ add(p.slug, p.rel);
64
+ // sources/* are full-slug-only link targets, exactly as lint's
65
+ // collectLinkTargets treats them: a bare `[[name]]` must NOT resolve to a
66
+ // source file. Adding their bare/dir-relative aliases here would make a
67
+ // real page's bare link look ambiguous and skip a legitimate rewrite.
68
+ if (/(^|\/)sources(\/|$)/.test(p.rel)) continue;
69
+ add(p.bare, p.rel);
70
+ add(dirRelForm(p.slug), p.rel);
71
+ }
72
+ return index;
73
+ }
74
+
75
+ // Classify a link target against the from-page. Returns the form KIND when
76
+ // the target points at from-page, plus whether that form is ambiguous
77
+ // (shared with another page → unsafe to auto-rewrite).
78
+ export function classifyTarget(target, fromPage, formIndex) {
79
+ const owners = formIndex.get(target);
80
+ if (!owners || !owners.has(fromPage.rel)) return { kind: null, ambiguous: false };
81
+ const ambiguous = owners.size > 1;
82
+ let kind = null;
83
+ if (target === fromPage.slug) kind = 'full';
84
+ else if (target === dirRelForm(fromPage.slug)) kind = 'dirrel';
85
+ else if (target === fromPage.bare) kind = 'bare';
86
+ return { kind, ambiguous };
87
+ }
88
+
89
+ // The new target string for a matched form kind — same kind, new page.
90
+ export function newTargetFor(kind, toPage) {
91
+ if (kind === 'full') return toPage.slug;
92
+ if (kind === 'dirrel') return dirRelForm(toPage.slug) ?? toPage.bare;
93
+ return toPage.bare; // bare
94
+ }
95
+
96
+ // ── wikilink masking (owner mode) ───────────────────────────────────────────
97
+ // Blank out fenced code, inline code, and HTML comments WITHOUT changing
98
+ // length, so a [[ref]] match index in the mask aligns with the same index in
99
+ // the source. Rewriting then edits the source at those exact spans, never
100
+ // touching a link that only appears inside a code sample.
101
+ export function maskNonWikilinkRegions(content) {
102
+ let out = content;
103
+ out = out.replace(/^[ \t]{0,3}```[\s\S]*?^[ \t]{0,3}```/gm, (m) => m.replace(/[^\n]/g, ' '));
104
+ out = out.replace(/^[ \t]{0,3}~~~[\s\S]*?^[ \t]{0,3}~~~/gm, (m) => m.replace(/[^\n]/g, ' '));
105
+ out = out.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
106
+ out = out.replace(/``[^`\n]*``/g, (m) => ' '.repeat(m.length));
107
+ out = out.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
108
+ return out;
109
+ }
110
+
111
+ // Parse the inside of a `[[ ... ]]` into { target, suffix } where suffix is
112
+ // the alias/anchor tail to preserve verbatim (including a table-escaped
113
+ // `\|`). The target capture stops before an optional `\` preceding the
114
+ // `|`/`#` delimiter, matching lint's extractor exactly.
115
+ export function splitLinkBody(body) {
116
+ const m = body.match(/^([^|#\\]+?)(\\?[|#][\s\S]*)?$/);
117
+ if (!m) return null;
118
+ return { target: m[1].trim(), suffix: m[2] || '' };
119
+ }
120
+
121
+ // ── preservation class of a link-source path (owner mode) ──────────────────
122
+ // Two distinct reasons a file is normally skipped as a link SOURCE, kept
123
+ // separate because directory-mode renames treat them differently:
124
+ //
125
+ // 'timerecord' — append-only snapshots (journal / session-log / weekly /
126
+ // archive / postmortems + root log.md). Rewriting a [[old]] inside a
127
+ // past entry would falsify that moment.
128
+ // 'sources' — sources/* immutable CAPTURED material. Never rewritten,
129
+ // not even inside a moved subtree.
130
+ //
131
+ // Matches a path SEGMENT so `pages/journal/x.md` and
132
+ // `projects/p/session-log/y.md` both qualify. Returns null for ordinary live
133
+ // pages.
134
+ export function preservationClass(rel) {
135
+ const p = rel.replace(/\\/g, '/');
136
+ if (/(^|\/)sources(\/|$)/.test(p)) return 'sources';
137
+ if (p === 'log.md') return 'timerecord';
138
+ if (/(^|\/)(journal|session-log|weekly|archive|postmortems)(\/|$)/.test(p)) return 'timerecord';
139
+ return null;
140
+ }
141
+
142
+ // A rename elsewhere in the vault must never churn a frozen snapshot or a
143
+ // source — both preservation classes count as a preserved link SOURCE.
144
+ export function isPreservedSource(rel) {
145
+ return preservationClass(rel) !== null;
146
+ }
147
+
148
+ // ── realpath containment (owner mode) ───────────────────────────────────────
149
+ // Verify a path resolves, following any symlink ANCESTOR, to a location
150
+ // inside the real vault root. A lexical ../-check cannot catch this: a
151
+ // symlinked directory that is lexically in-vault can still resolve outside
152
+ // it. The destination may not exist, so the deepest existing prefix is
153
+ // resolved (its realpath is where a write would actually land). Fail-closed:
154
+ // returns false on any resolution error.
155
+ //
156
+ // The walk uses lstat (NOT existsSync) so a DANGLING symlink prefix is
157
+ // detected as present-but-unresolvable rather than skipped as absent —
158
+ // otherwise the walk would step past it to an in-vault parent and wrongly
159
+ // report containment.
160
+ export function realContainedInVault(absPath, realRoot) {
161
+ let probe = absPath;
162
+ for (;;) {
163
+ let exists = true;
164
+ try {
165
+ lstatSync(probe);
166
+ } catch {
167
+ exists = false;
168
+ }
169
+ if (exists) break;
170
+ const parent = dirname(probe);
171
+ if (parent === probe) return false;
172
+ probe = parent;
173
+ }
174
+ let real;
175
+ try {
176
+ real = realpathSync(probe); // follows links; throws on a dangling symlink
177
+ } catch {
178
+ return false;
179
+ }
180
+ return real === realRoot || real.startsWith(realRoot + sep);
181
+ }
package/scripts/lint.mjs CHANGED
@@ -30,8 +30,9 @@ import {
30
30
  import { findDesignHistoryStale } from './lib/design-history-stale.mjs';
31
31
  import { FEEDBACK_SCOPE_RE } from './lib/feedback-scope.mjs';
32
32
  import { FAILURE_TYPE_ENUM } from './lib/failure-type.mjs';
33
- import { collectPagesLint, collectPagesLinkable, slugForms } from './lib/wikilink.mjs';
33
+ import { collectPagesLint, collectPagesLinkable } from './lib/wikilink.mjs';
34
34
  import { parseFrontmatter, SEQUENCE_ENTRY_RE } from './lib/frontmatter.mjs';
35
+ import { buildSlugMap } from './lib/slug-resolver.mjs';
35
36
 
36
37
  // ── arg parsing ──────────────────────────────────────────────────────────────
37
38
 
@@ -176,28 +177,9 @@ const TYPE_ENUM_FIELDS = {
176
177
  };
177
178
 
178
179
  // ── page collector ────────────────────────────────────────────────────────────
179
-
180
- // ── slug map ─────────────────────────────────────────────────────────────────
181
-
182
- // `extraTargets` are link-target-only slugs (root *.md, sources/*) that resolve
183
- // wikilinks but are not themselves linted — added verbatim, with NO derived
184
- // basename/dir-relative aliases, so they can't mask an unrelated broken link.
185
- function buildSlugMap(pages, extraTargets = []) {
186
- const map = new Set();
187
- for (const { rel } of pages) {
188
- const noExt = rel.replace(/\.md$/, '').replace(/\\/g, '/');
189
- // full slug + bare basename + dir-relative alias (drop the leading scan-dir
190
- // segment so the convention link [[learnings/foo]] resolves to
191
- // pages/learnings/foo.md). slugForms returns dirRel=null when the slug has no
192
- // `/` (a page directly under a scan dir has no extra segment to drop).
193
- const { full, bare, dirRel } = slugForms(noExt);
194
- map.add(full);
195
- map.add(bare);
196
- if (dirRel) map.add(dirRel);
197
- }
198
- for (const t of extraTargets) map.add(t);
199
- return map;
200
- }
180
+ //
181
+ // buildSlugMap (existence-check mode) now lives in ./lib/slug-resolver.mjs,
182
+ // shared with rename.mjs's collision-aware owner mode.
201
183
 
202
184
  // Link-target-only slugs: files that are valid wikilink destinations but are
203
185
  // NOT linted themselves. Root-level *.md (hot.md / log.md / hypo-guide.md /
@@ -308,6 +290,10 @@ const issues = [];
308
290
  // W3 missing-updated → excluded (auto-repaired by --fix).
309
291
  // W8 design-history-stale → excluded (hypo-personal-check handles it; would
310
292
  // double-gate).
293
+ // W14 design-history-missing → excluded, same reason as W8, and deliberately
294
+ // a different id so hypo-shared.mjs's W8-only
295
+ // blocker filter never sees it (stays warn, never
296
+ // hard-blocks PreCompact).
311
297
  // NOTE: no gate currently passes --strict (npm run lint / CI / release.yml /
312
298
  // crystallize / the close-gate all run plain lint), so promotion is
313
299
  // forward-looking: these surface as warnings today. The deliverable is
@@ -602,7 +588,25 @@ for (const rel of closeRootTargets) {
602
588
  // project (not per page) — runs outside the page loop. POSIX-separated path
603
589
  // literal (not path.join) so consumers can rely on `file.split('/')` shape
604
590
  // regardless of host OS — `hooks/hypo-personal-check.mjs:246` depends on this.
591
+ //
592
+ // W14: design-history.md does not exist at all, but session-log carries a
593
+ // design-relevant entry with nowhere to land. Distinct id from W8 on purpose —
594
+ // hypo-shared.mjs's PreCompact gate filters strictly on `w.id === 'W8'` to
595
+ // hard-block an active project on staleness, and W14 must NOT enter that path
596
+ // (a bootstrapping gap on 14 of 16 live projects would block every one of
597
+ // them). W14 stays a plain warn: never added to STRICT_PROMOTE_IDS, never
598
+ // matched by that W8-only filter.
605
599
  for (const s of findDesignHistoryStale(args.hypoDir)) {
600
+ if (s.kind === 'missing') {
601
+ issue(
602
+ 'warn',
603
+ `projects/${s.project}/design-history.md`,
604
+ `design-history missing: session-log 설계-관련 최신=${s.lastSession}인데 projects/${s.project}/design-history.md가 없습니다 — 파일을 새로 만들어 설계 변경 사항을 append 하는 것을 권고합니다`,
605
+ null,
606
+ 'W14',
607
+ );
608
+ continue;
609
+ }
606
610
  const gap = s.diffDays != null ? ` (${s.diffDays}일 차이)` : '';
607
611
  issue(
608
612
  'warn',
@@ -48,11 +48,22 @@ import {
48
48
  chmodSync,
49
49
  rmSync,
50
50
  } from 'fs';
51
- import { join, basename, dirname, normalize, isAbsolute, sep } from 'path';
51
+ import { join, basename, dirname, normalize, isAbsolute } from 'path';
52
52
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
53
53
  import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
54
- import { collectPagesRename, slugForms } from './lib/wikilink.mjs';
54
+ import { collectPagesRename } from './lib/wikilink.mjs';
55
55
  import { RENAME_MARKER_REL, renameMarkerPath, readRenameMarker } from './lib/rename-marker.mjs';
56
+ import {
57
+ dirRelForm,
58
+ buildFormIndex,
59
+ classifyTarget,
60
+ newTargetFor,
61
+ maskNonWikilinkRegions,
62
+ splitLinkBody,
63
+ preservationClass,
64
+ isPreservedSource,
65
+ realContainedInVault,
66
+ } from './lib/slug-resolver.mjs';
56
67
 
57
68
  // ── arg parsing ───────────────────────────────────────────────────────────────
58
69
 
@@ -69,108 +80,16 @@ function parseArgs(argv) {
69
80
  return args;
70
81
  }
71
82
 
72
- // ── preservation class of a link-source path ───────────────────────────────────
73
- // Two distinct reasons a file is normally skipped as a link SOURCE, kept separate
74
- // because directory mode treats them differently (codex design BLOCKER):
75
- //
76
- // 'timerecord' — append-only snapshots (journal / session-log / weekly / archive
77
- // / postmortems + root log.md). Rewriting a [[old]] inside a past entry would
78
- // falsify that moment. BUT a directory move relocates the whole subtree, so a
79
- // time-record INSIDE the moved subtree that links a moving sibling must update
80
- // that intra-subtree path label (the page genuinely moved); see runDirectory.
81
- //
82
- // 'sources' — sources/* immutable CAPTURED material. Never rewritten, not even
83
- // inside a moved subtree: a directory move must not claim ownership over the
84
- // bytes of an external source we transcribed verbatim.
85
- //
86
- // Matches a path SEGMENT so `pages/journal/x.md` and `projects/p/session-log/y.md`
87
- // both qualify. Returns null for ordinary live pages.
88
- function preservationClass(rel) {
89
- const p = rel.replace(/\\/g, '/');
90
- if (/(^|\/)sources(\/|$)/.test(p)) return 'sources';
91
- if (p === 'log.md') return 'timerecord';
92
- if (/(^|\/)(journal|session-log|weekly|archive|postmortems)(\/|$)/.test(p)) return 'timerecord';
93
- return null;
94
- }
95
-
96
- // Single-page mode preserves BOTH classes as link sources (unchanged behavior): a
97
- // rename elsewhere in the vault must never churn a frozen snapshot or a source.
98
- function isPreservedSource(rel) {
99
- return preservationClass(rel) !== null;
100
- }
101
-
102
- // ── slug-form index (resolution with collision detection) ──────────────────────
103
- // Unlike lint's buildSlugMap (a Set that silently dedups collisions), rename
104
- // needs to KNOW when a form is shared, so it maps each form → the set of page
105
- // rels that expose it. precedence forms per page: full noExt slug, bare
106
- // basename, dir-relative (drop the leading scan-dir segment).
107
- const dirRelForm = (slug) => slugForms(slug).dirRel;
108
-
109
- function buildFormIndex(pages) {
110
- const index = new Map(); // form → Set<rel>
111
- const add = (form, rel) => {
112
- if (!form) return;
113
- if (!index.has(form)) index.set(form, new Set());
114
- index.get(form).add(rel);
115
- };
116
- for (const p of pages) {
117
- add(p.slug, p.rel);
118
- // sources/* are full-slug-only link targets, exactly as lint's
119
- // collectLinkTargets treats them: a bare `[[name]]` must NOT resolve to a
120
- // source file. Adding their bare/dir-relative aliases here would make a real
121
- // page's bare link look ambiguous and skip a legitimate rewrite.
122
- if (/(^|\/)sources(\/|$)/.test(p.rel)) continue;
123
- add(p.bare, p.rel);
124
- add(dirRelForm(p.slug), p.rel);
125
- }
126
- return index;
127
- }
128
-
129
- // Classify a link target against the from-page. Returns the form KIND when the
130
- // target points at from-page, plus whether that form is ambiguous (shared with
131
- // another page → unsafe to auto-rewrite).
132
- function classifyTarget(target, fromPage, formIndex) {
133
- const owners = formIndex.get(target);
134
- if (!owners || !owners.has(fromPage.rel)) return { kind: null, ambiguous: false };
135
- const ambiguous = owners.size > 1;
136
- let kind = null;
137
- if (target === fromPage.slug) kind = 'full';
138
- else if (target === dirRelForm(fromPage.slug)) kind = 'dirrel';
139
- else if (target === fromPage.bare) kind = 'bare';
140
- return { kind, ambiguous };
141
- }
142
-
143
- // The new target string for a matched form kind — same kind, new page.
144
- function newTargetFor(kind, toPage) {
145
- if (kind === 'full') return toPage.slug;
146
- if (kind === 'dirrel') return dirRelForm(toPage.slug) ?? toPage.bare;
147
- return toPage.bare; // bare
148
- }
149
-
150
- // ── wikilink masking (mirror lint.mjs stripNonWikilinkRegions) ──────────────────
151
- // Blank out fenced code, inline code, and HTML comments WITHOUT changing length,
152
- // so a [[ref]] match index in the mask aligns with the same index in the source.
153
- // Rewriting then edits the source at those exact spans, never touching a link
154
- // that only appears inside a code sample.
155
- function maskNonWikilinkRegions(content) {
156
- let out = content;
157
- out = out.replace(/^[ \t]{0,3}```[\s\S]*?^[ \t]{0,3}```/gm, (m) => m.replace(/[^\n]/g, ' '));
158
- out = out.replace(/^[ \t]{0,3}~~~[\s\S]*?^[ \t]{0,3}~~~/gm, (m) => m.replace(/[^\n]/g, ' '));
159
- out = out.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
160
- out = out.replace(/``[^`\n]*``/g, (m) => ' '.repeat(m.length));
161
- out = out.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
162
- return out;
163
- }
164
-
165
- // Parse the inside of a `[[ ... ]]` into { target, suffix } where suffix is the
166
- // alias/anchor tail to preserve verbatim (including a table-escaped `\|`). The
167
- // target capture stops before an optional `\` preceding the `|`/`#` delimiter,
168
- // matching lint's extractor exactly.
169
- function splitLinkBody(body) {
170
- const m = body.match(/^([^|#\\]+?)(\\?[|#][\s\S]*)?$/);
171
- if (!m) return null;
172
- return { target: m[1].trim(), suffix: m[2] || '' };
173
- }
83
+ // ── preservation class of a link-source path, form index, masking, and link-body
84
+ // parsing (preservationClass / isPreservedSource / dirRelForm / buildFormIndex /
85
+ // classifyTarget / newTargetFor / maskNonWikilinkRegions / splitLinkBody) now
86
+ // live in ./lib/slug-resolver.mjs, shared with lint.mjs's existence-check mode.
87
+ // Directory mode's own notes on the two preservation classes: 'timerecord' is
88
+ // skipped as a link SOURCE outside the moved subtree, but a time-record INSIDE
89
+ // the moved subtree that links a moving sibling must update that intra-subtree
90
+ // path label (the page genuinely moved); see runDirectory. 'sources' is never
91
+ // rewritten even inside a moved subtree: a directory move must not claim
92
+ // ownership over the bytes of an external source transcribed verbatim.
174
93
 
175
94
  // Rewrite every inbound reference to fromPage in `content`. Returns
176
95
  // { content, rewrites, ambiguous } where rewrites/ambiguous list the links
@@ -316,7 +235,9 @@ function guardExistingMarker(args, thisMarker) {
316
235
  );
317
236
  }
318
237
  const same =
319
- marker.mode === thisMarker.mode && marker.from === thisMarker.from && marker.to === thisMarker.to;
238
+ marker.mode === thisMarker.mode &&
239
+ marker.from === thisMarker.from &&
240
+ marker.to === thisMarker.to;
320
241
  if (!same) {
321
242
  fail(
322
243
  args,
@@ -468,40 +389,10 @@ function rewriteContentDir(content, movedByRel, formIndex, aliasPreserve) {
468
389
  return { content: out, rewrites, ambiguous };
469
390
  }
470
391
 
471
- // Verify a path resolves — following any symlink ANCESTOR — to a location inside
472
- // the real vault root. A lexical ../-check cannot catch this: `projects/link/new`
473
- // where `projects/link` → /tmp/outside is lexically in-vault, yet renameSync would
474
- // follow the symlink and write across the vault boundary. The destination may not
475
- // exist, so the deepest existing prefix is resolved (its realpath is where a write
476
- // would actually land). Fail-closed: returns false on any resolution error.
477
- //
478
- // The walk uses lstat (NOT existsSync) so a DANGLING symlink prefix is detected as
479
- // present-but-unresolvable rather than skipped as absent — otherwise the walk would
480
- // step past it to an in-vault parent and wrongly report containment, letting an
481
- // external rewrite land before renameSync crashes on the dangling target.
482
- function realContainedInVault(absPath, realRoot) {
483
- let probe = absPath;
484
- // Walk up to the deepest path component that exists as a link-or-real entry.
485
- for (;;) {
486
- let exists = true;
487
- try {
488
- lstatSync(probe);
489
- } catch {
490
- exists = false;
491
- }
492
- if (exists) break;
493
- const parent = dirname(probe);
494
- if (parent === probe) return false;
495
- probe = parent;
496
- }
497
- let real;
498
- try {
499
- real = realpathSync(probe); // follows links; throws on a dangling symlink
500
- } catch {
501
- return false;
502
- }
503
- return real === realRoot || real.startsWith(realRoot + sep);
504
- }
392
+ // realContainedInVault now lives in ./lib/slug-resolver.mjs (imported above),
393
+ // reused as-is: `projects/link/new` where `projects/link` → /tmp/outside is
394
+ // lexically in-vault, yet renameSync would follow the symlink and write
395
+ // across the vault boundary, which is exactly what it guards against below.
505
396
 
506
397
  // The destination's deepest existing ancestor must be a directory. Otherwise
507
398
  // mkdirSync(dirname, {recursive}) fails with ENOTDIR — but only AFTER the inbound
@@ -760,7 +651,10 @@ function runDirectory(args, fromDirRel, ignorePatterns) {
760
651
  // instead of an unsafe write-then-remove fallback; every write above is
761
652
  // already atomic, so a re-run picks up cleanly.
762
653
  if (err.code !== 'EXDEV') throw err;
763
- fail(args, `--from and --to ended up on different filesystems mid-run — refusing an unsafe fallback move.`);
654
+ fail(
655
+ args,
656
+ `--from and --to ended up on different filesystems mid-run — refusing an unsafe fallback move.`,
657
+ );
764
658
  }
765
659
  clearRenameMarker(args);
766
660
  moved = true;
@@ -974,7 +868,10 @@ function run(args) {
974
868
  // removes. fromPage.path still holds the valid (possibly rewritten)
975
869
  // body, so a re-run picks up cleanly once the device issue is gone.
976
870
  if (err.code !== 'EXDEV') throw err;
977
- fail(args, `--from and --to ended up on different filesystems mid-run — refusing an unsafe fallback move.`);
871
+ fail(
872
+ args,
873
+ `--from and --to ended up on different filesystems mid-run — refusing an unsafe fallback move.`,
874
+ );
978
875
  }
979
876
  }
980
877
  clearRenameMarker(args);