hypomnema 1.3.1 → 1.3.3
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.ko.md +11 -10
- package/README.md +11 -10
- package/commands/audit.md +1 -1
- package/commands/crystallize.md +11 -11
- package/commands/doctor.md +1 -1
- package/commands/feedback.md +2 -2
- package/commands/graph.md +1 -1
- package/commands/ingest.md +1 -1
- package/commands/init.md +1 -1
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +79 -0
- package/commands/resume.md +2 -2
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +1 -1
- package/commands/upgrade.md +1 -1
- package/commands/verify.md +1 -1
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hypo-auto-commit.mjs +8 -24
- package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
- package/hooks/hypo-compact-guard.mjs +6 -3
- package/hooks/hypo-cwd-change.mjs +1 -1
- package/hooks/hypo-first-prompt.mjs +2 -2
- package/hooks/hypo-hot-rebuild.mjs +14 -1
- package/hooks/hypo-personal-check.mjs +91 -179
- package/hooks/hypo-pre-commit.mjs +1 -1
- package/hooks/hypo-session-end.mjs +1 -1
- package/hooks/hypo-session-start.mjs +7 -7
- package/hooks/hypo-shared.mjs +1082 -89
- package/hooks/hypo-web-fetch-ingest.mjs +2 -2
- package/hooks/version-check-fetch.mjs +2 -8
- package/hooks/version-check.mjs +18 -0
- package/package.json +3 -2
- package/scripts/check-tracker-ids.mjs +329 -0
- package/scripts/crystallize.mjs +322 -110
- package/scripts/doctor.mjs +6 -9
- package/scripts/feedback-sync.mjs +1 -1
- package/scripts/init.mjs +1 -1
- package/scripts/install-git-hooks.mjs +75 -40
- package/scripts/lib/check-tracker-ids.mjs +140 -0
- package/scripts/lib/design-history-stale.mjs +59 -14
- package/scripts/lib/extensions.mjs +4 -4
- package/scripts/lib/fix-manifest.mjs +2 -2
- package/scripts/lib/fix-status-verify.mjs +5 -4
- package/scripts/lib/plugin-detect.mjs +15 -6
- package/scripts/lib/project-create.mjs +1 -1
- package/scripts/lint.mjs +63 -8
- package/scripts/rename.mjs +836 -0
- package/scripts/resume.mjs +75 -19
- package/scripts/uninstall.mjs +1 -1
- package/scripts/upgrade.mjs +26 -25
- package/skills/crystallize/SKILL.md +12 -9
- package/skills/graph/SKILL.md +1 -1
- package/skills/ingest/SKILL.md +1 -1
- package/skills/lint/SKILL.md +1 -1
- package/skills/query/SKILL.md +1 -1
- package/skills/verify/SKILL.md +1 -1
- package/templates/SCHEMA.md +2 -2
- package/templates/hypo-config.md +2 -2
- package/templates/hypo-guide.md +22 -1
- package/templates/hypo-help.md +1 -0
- package/templates/projects/_template/index.md +1 -1
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* rename.mjs — rename a wiki page and rewrite inbound wikilinks.
|
|
4
|
+
*
|
|
5
|
+
* A bare `git mv` leaves every `[[old]]` / `[[old|alias]]` / `[[old#anchor]]` /
|
|
6
|
+
* `[[dir/old]]` pointing at a now-missing target — broken links accumulate on
|
|
7
|
+
* every rename. This helper moves the page AND content-aware rewrites every
|
|
8
|
+
* inbound reference across the vault so a rename never breaks a link.
|
|
9
|
+
*
|
|
10
|
+
* node rename.mjs --hypo-dir=<dir> --from=<slug|rel> --to=<slug|rel> [--apply] [--json]
|
|
11
|
+
*
|
|
12
|
+
* Default is a dry-run (report only); --apply performs the move + rewrites.
|
|
13
|
+
*
|
|
14
|
+
* Two modes, auto-detected from --from:
|
|
15
|
+
* • page mode — --from resolves to a .md page (the original behavior below).
|
|
16
|
+
* • directory mode — --from is an existing directory: the whole subtree is
|
|
17
|
+
* relocated (renameSync, carrying non-.md assets) and inbound full-slug /
|
|
18
|
+
* dir-relative links are rewritten across the vault. See runDirectory.
|
|
19
|
+
*
|
|
20
|
+
* Two design invariants:
|
|
21
|
+
*
|
|
22
|
+
* 1. Resolution, not string-match. A link `[[foo]]` (bare basename) can be
|
|
23
|
+
* shared by two pages; a blind text replace would break the wrong one. Each
|
|
24
|
+
* link target is resolved with the SAME precedence lint uses (full noExt →
|
|
25
|
+
* bare basename → dir-relative drop). A reference is rewritten only when it
|
|
26
|
+
* resolves UNAMBIGUOUSLY to the from-page. An ambiguous bare form (the slug
|
|
27
|
+
* maps to >1 file) is reported, never auto-rewritten — which also makes the
|
|
28
|
+
* ADR-renumber guard free: a `--to` that is not a unique destination is
|
|
29
|
+
* rejected.
|
|
30
|
+
*
|
|
31
|
+
* 2. Preserve append-only time records. journal / session-log / weekly / archive
|
|
32
|
+
* / postmortems (and root log.md) are frozen snapshots — rewriting a `[[old]]`
|
|
33
|
+
* inside a past entry would falsify that moment's record. They are skipped as
|
|
34
|
+
* link SOURCES. sources/* is likewise immutable. This tool's value is "update
|
|
35
|
+
* live links at rename time", not "retroactively churn old snapshots".
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
existsSync,
|
|
40
|
+
readFileSync,
|
|
41
|
+
writeFileSync,
|
|
42
|
+
rmSync,
|
|
43
|
+
mkdirSync,
|
|
44
|
+
readdirSync,
|
|
45
|
+
renameSync,
|
|
46
|
+
statSync,
|
|
47
|
+
lstatSync,
|
|
48
|
+
realpathSync,
|
|
49
|
+
} from 'fs';
|
|
50
|
+
import { join, relative, extname, basename, dirname, normalize, isAbsolute, sep } from 'path';
|
|
51
|
+
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
52
|
+
import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
|
|
53
|
+
|
|
54
|
+
// ── arg parsing ───────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function parseArgs(argv) {
|
|
57
|
+
const args = { hypoDir: null, from: null, to: null, apply: false, json: false };
|
|
58
|
+
for (const arg of argv.slice(2)) {
|
|
59
|
+
if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
60
|
+
else if (arg.startsWith('--from=')) args.from = arg.slice(7);
|
|
61
|
+
else if (arg.startsWith('--to=')) args.to = arg.slice(5);
|
|
62
|
+
else if (arg === '--apply') args.apply = true;
|
|
63
|
+
else if (arg === '--json') args.json = true;
|
|
64
|
+
}
|
|
65
|
+
if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
|
|
66
|
+
return args;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── page collection (mirrors graph.mjs / crystallize.mjs collectPages) ──────────
|
|
70
|
+
|
|
71
|
+
function collectPages(dir, root, pages = [], ignorePatterns = []) {
|
|
72
|
+
if (!existsSync(dir)) return pages;
|
|
73
|
+
for (const entry of readdirSync(dir)) {
|
|
74
|
+
const full = join(dir, entry);
|
|
75
|
+
if (isIgnored(full, root, ignorePatterns)) continue;
|
|
76
|
+
// lstat (never follow): a symlinked dir or file is skipped entirely so the
|
|
77
|
+
// whole-vault scan can never traverse out of the vault via a symlink.
|
|
78
|
+
const st = lstatSync(full);
|
|
79
|
+
if (st.isSymbolicLink()) {
|
|
80
|
+
continue;
|
|
81
|
+
} else if (st.isDirectory()) {
|
|
82
|
+
collectPages(full, root, pages, ignorePatterns);
|
|
83
|
+
} else if (extname(entry) === '.md' && !entry.startsWith('.')) {
|
|
84
|
+
const rel = relative(root, full).replace(/\\/g, '/');
|
|
85
|
+
pages.push({ path: full, rel, slug: rel.replace(/\.md$/, ''), bare: basename(full, '.md') });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return pages;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── preservation class of a link-source path ───────────────────────────────────
|
|
92
|
+
// Two distinct reasons a file is normally skipped as a link SOURCE, kept separate
|
|
93
|
+
// because directory mode treats them differently (codex design BLOCKER):
|
|
94
|
+
//
|
|
95
|
+
// 'timerecord' — append-only snapshots (journal / session-log / weekly / archive
|
|
96
|
+
// / postmortems + root log.md). Rewriting a [[old]] inside a past entry would
|
|
97
|
+
// falsify that moment. BUT a directory move relocates the whole subtree, so a
|
|
98
|
+
// time-record INSIDE the moved subtree that links a moving sibling must update
|
|
99
|
+
// that intra-subtree path label (the page genuinely moved); see runDirectory.
|
|
100
|
+
//
|
|
101
|
+
// 'sources' — sources/* immutable CAPTURED material. Never rewritten, not even
|
|
102
|
+
// inside a moved subtree: a directory move must not claim ownership over the
|
|
103
|
+
// bytes of an external source we transcribed verbatim.
|
|
104
|
+
//
|
|
105
|
+
// Matches a path SEGMENT so `pages/journal/x.md` and `projects/p/session-log/y.md`
|
|
106
|
+
// both qualify. Returns null for ordinary live pages.
|
|
107
|
+
function preservationClass(rel) {
|
|
108
|
+
const p = rel.replace(/\\/g, '/');
|
|
109
|
+
if (/(^|\/)sources(\/|$)/.test(p)) return 'sources';
|
|
110
|
+
if (p === 'log.md') return 'timerecord';
|
|
111
|
+
if (/(^|\/)(journal|session-log|weekly|archive|postmortems)(\/|$)/.test(p)) return 'timerecord';
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Single-page mode preserves BOTH classes as link sources (unchanged behavior): a
|
|
116
|
+
// rename elsewhere in the vault must never churn a frozen snapshot or a source.
|
|
117
|
+
function isPreservedSource(rel) {
|
|
118
|
+
return preservationClass(rel) !== null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── slug-form index (resolution with collision detection) ──────────────────────
|
|
122
|
+
// Unlike lint's buildSlugMap (a Set that silently dedups collisions), rename
|
|
123
|
+
// needs to KNOW when a form is shared, so it maps each form → the set of page
|
|
124
|
+
// rels that expose it. precedence forms per page: full noExt slug, bare
|
|
125
|
+
// basename, dir-relative (drop the leading scan-dir segment).
|
|
126
|
+
function dirRelForm(slug) {
|
|
127
|
+
const slash = slug.indexOf('/');
|
|
128
|
+
return slash === -1 ? null : slug.slice(slash + 1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildFormIndex(pages) {
|
|
132
|
+
const index = new Map(); // form → Set<rel>
|
|
133
|
+
const add = (form, rel) => {
|
|
134
|
+
if (!form) return;
|
|
135
|
+
if (!index.has(form)) index.set(form, new Set());
|
|
136
|
+
index.get(form).add(rel);
|
|
137
|
+
};
|
|
138
|
+
for (const p of pages) {
|
|
139
|
+
add(p.slug, p.rel);
|
|
140
|
+
// sources/* are full-slug-only link targets, exactly as lint's
|
|
141
|
+
// collectLinkTargets treats them: a bare `[[name]]` must NOT resolve to a
|
|
142
|
+
// source file. Adding their bare/dir-relative aliases here would make a real
|
|
143
|
+
// page's bare link look ambiguous and skip a legitimate rewrite.
|
|
144
|
+
if (/(^|\/)sources(\/|$)/.test(p.rel)) continue;
|
|
145
|
+
add(p.bare, p.rel);
|
|
146
|
+
add(dirRelForm(p.slug), p.rel);
|
|
147
|
+
}
|
|
148
|
+
return index;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Classify a link target against the from-page. Returns the form KIND when the
|
|
152
|
+
// target points at from-page, plus whether that form is ambiguous (shared with
|
|
153
|
+
// another page → unsafe to auto-rewrite).
|
|
154
|
+
function classifyTarget(target, fromPage, formIndex) {
|
|
155
|
+
const owners = formIndex.get(target);
|
|
156
|
+
if (!owners || !owners.has(fromPage.rel)) return { kind: null, ambiguous: false };
|
|
157
|
+
const ambiguous = owners.size > 1;
|
|
158
|
+
let kind = null;
|
|
159
|
+
if (target === fromPage.slug) kind = 'full';
|
|
160
|
+
else if (target === dirRelForm(fromPage.slug)) kind = 'dirrel';
|
|
161
|
+
else if (target === fromPage.bare) kind = 'bare';
|
|
162
|
+
return { kind, ambiguous };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The new target string for a matched form kind — same kind, new page.
|
|
166
|
+
function newTargetFor(kind, toPage) {
|
|
167
|
+
if (kind === 'full') return toPage.slug;
|
|
168
|
+
if (kind === 'dirrel') return dirRelForm(toPage.slug) ?? toPage.bare;
|
|
169
|
+
return toPage.bare; // bare
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── wikilink masking (mirror lint.mjs stripNonWikilinkRegions) ──────────────────
|
|
173
|
+
// Blank out fenced code, inline code, and HTML comments WITHOUT changing length,
|
|
174
|
+
// so a [[ref]] match index in the mask aligns with the same index in the source.
|
|
175
|
+
// Rewriting then edits the source at those exact spans, never touching a link
|
|
176
|
+
// that only appears inside a code sample.
|
|
177
|
+
function maskNonWikilinkRegions(content) {
|
|
178
|
+
let out = content;
|
|
179
|
+
out = out.replace(/^[ \t]{0,3}```[\s\S]*?^[ \t]{0,3}```/gm, (m) => m.replace(/[^\n]/g, ' '));
|
|
180
|
+
out = out.replace(/^[ \t]{0,3}~~~[\s\S]*?^[ \t]{0,3}~~~/gm, (m) => m.replace(/[^\n]/g, ' '));
|
|
181
|
+
out = out.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
|
|
182
|
+
out = out.replace(/``[^`\n]*``/g, (m) => ' '.repeat(m.length));
|
|
183
|
+
out = out.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Parse the inside of a `[[ ... ]]` into { target, suffix } where suffix is the
|
|
188
|
+
// alias/anchor tail to preserve verbatim (including a table-escaped `\|`). The
|
|
189
|
+
// target capture stops before an optional `\` preceding the `|`/`#` delimiter,
|
|
190
|
+
// matching lint's extractor exactly.
|
|
191
|
+
function splitLinkBody(body) {
|
|
192
|
+
const m = body.match(/^([^|#\\]+?)(\\?[|#][\s\S]*)?$/);
|
|
193
|
+
if (!m) return null;
|
|
194
|
+
return { target: m[1].trim(), suffix: m[2] || '' };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Rewrite every inbound reference to fromPage in `content`. Returns
|
|
198
|
+
// { content, rewrites, ambiguous } where rewrites/ambiguous list the links
|
|
199
|
+
// changed / skipped-as-ambiguous (with their 1-based line numbers).
|
|
200
|
+
function rewriteContent(content, fromPage, toPage, formIndex) {
|
|
201
|
+
const mask = maskNonWikilinkRegions(content);
|
|
202
|
+
const re = /\[\[([^\]]+?)\]\]/g;
|
|
203
|
+
const edits = []; // { start, end, replacement }
|
|
204
|
+
const rewrites = [];
|
|
205
|
+
const ambiguous = [];
|
|
206
|
+
let m;
|
|
207
|
+
while ((m = re.exec(mask)) !== null) {
|
|
208
|
+
const start = m.index;
|
|
209
|
+
const end = start + m[0].length;
|
|
210
|
+
const body = content.slice(start + 2, end - 2); // real body from source
|
|
211
|
+
const parsed = splitLinkBody(body);
|
|
212
|
+
if (!parsed) continue;
|
|
213
|
+
const { kind, ambiguous: amb } = classifyTarget(parsed.target, fromPage, formIndex);
|
|
214
|
+
if (!kind) continue; // does not resolve to from-page
|
|
215
|
+
const line = content.slice(0, start).split('\n').length;
|
|
216
|
+
if (amb) {
|
|
217
|
+
ambiguous.push({ link: m[0], line, target: parsed.target });
|
|
218
|
+
continue; // shared form — report, never auto-rewrite
|
|
219
|
+
}
|
|
220
|
+
const replacement = `[[${newTargetFor(kind, toPage)}${parsed.suffix}]]`;
|
|
221
|
+
edits.push({ start, end, replacement });
|
|
222
|
+
rewrites.push({ from: m[0], to: replacement, line });
|
|
223
|
+
}
|
|
224
|
+
// Apply edits back-to-front so earlier indices stay valid.
|
|
225
|
+
let out = content;
|
|
226
|
+
for (const e of edits.sort((a, b) => b.start - a.start)) {
|
|
227
|
+
out = out.slice(0, e.start) + e.replacement + out.slice(e.end);
|
|
228
|
+
}
|
|
229
|
+
return { content: out, rewrites, ambiguous };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── resolve a --from / --to argument to a page rel + forms ──────────────────────
|
|
233
|
+
// Accepts a full rel ("pages/foo.md" / "pages/foo"), a noExt slug, or a bare
|
|
234
|
+
// basename when unambiguous. Returns { rel, slug, bare } or null.
|
|
235
|
+
function resolveArgToPage(arg, pages, formIndex) {
|
|
236
|
+
const norm = arg.replace(/\\/g, '/').replace(/\.md$/, '');
|
|
237
|
+
// exact full-slug match first
|
|
238
|
+
const direct = pages.find((p) => p.slug === norm);
|
|
239
|
+
if (direct) return direct;
|
|
240
|
+
const owners = formIndex.get(norm);
|
|
241
|
+
if (owners && owners.size === 1) {
|
|
242
|
+
const rel = [...owners][0];
|
|
243
|
+
return pages.find((p) => p.rel === rel) || null;
|
|
244
|
+
}
|
|
245
|
+
return null; // missing or ambiguous
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function fail(args, msg) {
|
|
249
|
+
if (args.json) console.log(JSON.stringify({ ok: false, error: msg }, null, 2));
|
|
250
|
+
else console.error(`✗ ${msg}`);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── directory mode ─────────────────────────────────────────────────────────────
|
|
255
|
+
// A directory rename relocates a whole subtree (`projects/old/**` → `projects/new/**`)
|
|
256
|
+
// and rewrites inbound links across the vault. Key facts that shape the algorithm:
|
|
257
|
+
//
|
|
258
|
+
// • A directory move does NOT change basenames, only the path prefix. So bare
|
|
259
|
+
// `[[0052]]` links keep resolving to the relocated page automatically and need
|
|
260
|
+
// NO rewrite — only the FULL-slug and 1-seg DIR-RELATIVE forms encode the moved
|
|
261
|
+
// prefix and break. (Forms that drop ≥2 segments, e.g. `[[decisions/0052]]`, do
|
|
262
|
+
// not encode the renamed prefix either: lint cannot resolve them today and they
|
|
263
|
+
// survive a move unchanged — out of scope, matching lint's buildSlugMap.)
|
|
264
|
+
// • Every moved page is BOTH a target (it relocates) and a source (its links to
|
|
265
|
+
// moving siblings must update). The move is done with one renameSync of the
|
|
266
|
+
// whole directory — which also carries non-.md assets (logo svg/png, etc.) — and
|
|
267
|
+
// rewritten page bodies are written at their NEW paths afterward.
|
|
268
|
+
|
|
269
|
+
// Recursively detect any symlink under a directory (lstat, never follows). A moved
|
|
270
|
+
// subtree containing a symlink is refused: renameSync would move the link verbatim
|
|
271
|
+
// and statSync-based page collection could otherwise pull external pages in.
|
|
272
|
+
function subtreeHasSymlink(dir) {
|
|
273
|
+
for (const entry of readdirSync(dir)) {
|
|
274
|
+
const full = join(dir, entry);
|
|
275
|
+
let st;
|
|
276
|
+
try {
|
|
277
|
+
st = lstatSync(full);
|
|
278
|
+
} catch {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (st.isSymbolicLink()) return true;
|
|
282
|
+
if (st.isDirectory() && subtreeHasSymlink(full)) return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Classify a link target against the SET of moving pages. Only FULL / DIR-RELATIVE
|
|
288
|
+
// kinds are rewritten (bare survives a dir move untouched). Returns:
|
|
289
|
+
// { kind: null } → not (uniquely) a moving target
|
|
290
|
+
// { kind: null, ambiguous: true, target } → form shared by >1 page, a mover
|
|
291
|
+
// among them → report, never rewrite
|
|
292
|
+
// { kind, fromPage, toPage } → unambiguous moving target
|
|
293
|
+
function classifyMovedTarget(target, movedByRel, formIndex) {
|
|
294
|
+
const owners = formIndex.get(target);
|
|
295
|
+
if (!owners) return { kind: null };
|
|
296
|
+
const movingOwners = [...owners].filter((rel) => movedByRel.has(rel));
|
|
297
|
+
if (movingOwners.length === 0) return { kind: null }; // link unrelated to this move
|
|
298
|
+
if (owners.size > 1) {
|
|
299
|
+
// Shared form. Bare collisions are irrelevant (bare is never rewritten in dir
|
|
300
|
+
// mode); only a shared full/dirrel form is worth reporting. full slugs are
|
|
301
|
+
// unique, so this realistically only guards an exotic dir-relative clash.
|
|
302
|
+
const rel = movingOwners[0];
|
|
303
|
+
const { fromPage } = movedByRel.get(rel);
|
|
304
|
+
if (target === fromPage.slug || target === dirRelForm(fromPage.slug)) {
|
|
305
|
+
return { kind: null, ambiguous: true, target };
|
|
306
|
+
}
|
|
307
|
+
return { kind: null };
|
|
308
|
+
}
|
|
309
|
+
const rel = movingOwners[0];
|
|
310
|
+
const { fromPage, toPage } = movedByRel.get(rel);
|
|
311
|
+
let kind = null;
|
|
312
|
+
if (target === fromPage.slug) kind = 'full';
|
|
313
|
+
else if (target === dirRelForm(fromPage.slug)) kind = 'dirrel';
|
|
314
|
+
// bare (target === fromPage.bare) → intentionally null: unchanged by a dir move.
|
|
315
|
+
if (!kind) return { kind: null };
|
|
316
|
+
return { kind, fromPage, toPage };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Rewrite inbound references to any moving page in `content`. aliasPreserve keeps
|
|
320
|
+
// the original rendered label via `[[new|old]]` for unaliased links — used for
|
|
321
|
+
// time-record files inside the moved subtree so a relocated snapshot still reads
|
|
322
|
+
// with its original path label while linking to the live page.
|
|
323
|
+
function rewriteContentDir(content, movedByRel, formIndex, aliasPreserve) {
|
|
324
|
+
const mask = maskNonWikilinkRegions(content);
|
|
325
|
+
const re = /\[\[([^\]]+?)\]\]/g;
|
|
326
|
+
const edits = [];
|
|
327
|
+
const rewrites = [];
|
|
328
|
+
const ambiguous = [];
|
|
329
|
+
let m;
|
|
330
|
+
while ((m = re.exec(mask)) !== null) {
|
|
331
|
+
const start = m.index;
|
|
332
|
+
const end = start + m[0].length;
|
|
333
|
+
const body = content.slice(start + 2, end - 2);
|
|
334
|
+
const parsed = splitLinkBody(body);
|
|
335
|
+
if (!parsed) continue;
|
|
336
|
+
const cls = classifyMovedTarget(parsed.target, movedByRel, formIndex);
|
|
337
|
+
const line = content.slice(0, start).split('\n').length;
|
|
338
|
+
if (cls.ambiguous) {
|
|
339
|
+
ambiguous.push({ link: m[0], line, target: parsed.target });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (!cls.kind) continue;
|
|
343
|
+
const newTarget = newTargetFor(cls.kind, cls.toPage);
|
|
344
|
+
const replacement =
|
|
345
|
+
aliasPreserve && parsed.suffix === ''
|
|
346
|
+
? `[[${newTarget}|${parsed.target}]]`
|
|
347
|
+
: `[[${newTarget}${parsed.suffix}]]`;
|
|
348
|
+
edits.push({ start, end, replacement });
|
|
349
|
+
rewrites.push({ from: m[0], to: replacement, line });
|
|
350
|
+
}
|
|
351
|
+
let out = content;
|
|
352
|
+
for (const e of edits.sort((a, b) => b.start - a.start)) {
|
|
353
|
+
out = out.slice(0, e.start) + e.replacement + out.slice(e.end);
|
|
354
|
+
}
|
|
355
|
+
return { content: out, rewrites, ambiguous };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Verify a path resolves — following any symlink ANCESTOR — to a location inside
|
|
359
|
+
// the real vault root. A lexical ../-check cannot catch this: `projects/link/new`
|
|
360
|
+
// where `projects/link` → /tmp/outside is lexically in-vault, yet renameSync would
|
|
361
|
+
// follow the symlink and write across the vault boundary. The destination may not
|
|
362
|
+
// exist, so the deepest existing prefix is resolved (its realpath is where a write
|
|
363
|
+
// would actually land). Fail-closed: returns false on any resolution error.
|
|
364
|
+
//
|
|
365
|
+
// The walk uses lstat (NOT existsSync) so a DANGLING symlink prefix is detected as
|
|
366
|
+
// present-but-unresolvable rather than skipped as absent — otherwise the walk would
|
|
367
|
+
// step past it to an in-vault parent and wrongly report containment, letting an
|
|
368
|
+
// external rewrite land before renameSync crashes on the dangling target.
|
|
369
|
+
function realContainedInVault(absPath, realRoot) {
|
|
370
|
+
let probe = absPath;
|
|
371
|
+
// Walk up to the deepest path component that exists as a link-or-real entry.
|
|
372
|
+
for (;;) {
|
|
373
|
+
let exists = true;
|
|
374
|
+
try {
|
|
375
|
+
lstatSync(probe);
|
|
376
|
+
} catch {
|
|
377
|
+
exists = false;
|
|
378
|
+
}
|
|
379
|
+
if (exists) break;
|
|
380
|
+
const parent = dirname(probe);
|
|
381
|
+
if (parent === probe) return false;
|
|
382
|
+
probe = parent;
|
|
383
|
+
}
|
|
384
|
+
let real;
|
|
385
|
+
try {
|
|
386
|
+
real = realpathSync(probe); // follows links; throws on a dangling symlink
|
|
387
|
+
} catch {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
return real === realRoot || real.startsWith(realRoot + sep);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// The destination's deepest existing ancestor must be a directory. Otherwise
|
|
394
|
+
// mkdirSync(dirname, {recursive}) fails with ENOTDIR — but only AFTER the inbound
|
|
395
|
+
// rewrites were already written, leaving a partial mutation on a move that can
|
|
396
|
+
// never complete (e.g. `--to=projects/file/sub` where `projects/file` is a regular
|
|
397
|
+
// file). Refuse up front so a failed move never churns the vault. Runs after the
|
|
398
|
+
// realpath-containment check, so a symlink-to-dir ancestor is already in-vault.
|
|
399
|
+
function destinationHostable(absPath) {
|
|
400
|
+
let probe = dirname(absPath);
|
|
401
|
+
for (;;) {
|
|
402
|
+
let st;
|
|
403
|
+
try {
|
|
404
|
+
st = statSync(probe);
|
|
405
|
+
} catch {
|
|
406
|
+
const parent = dirname(probe);
|
|
407
|
+
if (parent === probe) return false;
|
|
408
|
+
probe = parent;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
return st.isDirectory();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function runDirectory(args, fromDirRel, ignorePatterns) {
|
|
416
|
+
const fromAbs = join(args.hypoDir, fromDirRel);
|
|
417
|
+
if (lstatSync(fromAbs).isSymbolicLink()) {
|
|
418
|
+
fail(args, `--from '${args.from}' is a symlink — refusing to rename a linked directory`);
|
|
419
|
+
}
|
|
420
|
+
// Real-root containment: reject a --from whose realpath (after following any
|
|
421
|
+
// symlink ancestor) lands outside the vault — else the move would drag an
|
|
422
|
+
// outside-the-vault directory in.
|
|
423
|
+
let realRoot;
|
|
424
|
+
try {
|
|
425
|
+
realRoot = realpathSync(args.hypoDir);
|
|
426
|
+
} catch {
|
|
427
|
+
fail(args, `wiki root cannot be resolved: ${args.hypoDir}`);
|
|
428
|
+
}
|
|
429
|
+
if (!realContainedInVault(fromAbs, realRoot)) {
|
|
430
|
+
fail(args, `--from '${args.from}' resolves outside the wiki root (symlink escape)`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Destination: normalize + keep inside the vault (mirrors page mode's --to guard).
|
|
434
|
+
const toNorm = args.to.replace(/\\/g, '/').replace(/\.md$/, '').replace(/\/+$/, '');
|
|
435
|
+
const toDirRel = normalize(toNorm).replace(/\\/g, '/');
|
|
436
|
+
if (toDirRel === '..' || toDirRel.startsWith('../') || isAbsolute(toDirRel) || toDirRel === '.') {
|
|
437
|
+
fail(args, `--to escapes the wiki root: ${args.to}`);
|
|
438
|
+
}
|
|
439
|
+
if (toDirRel === fromDirRel) {
|
|
440
|
+
fail(args, `--from and --to are the same directory (${toDirRel}) — nothing to rename`);
|
|
441
|
+
}
|
|
442
|
+
// Same top-level segment only. A move that changes the leading scan-dir segment
|
|
443
|
+
// (e.g. journal/x → pages/x) would change the targetability class and the
|
|
444
|
+
// dir-relative form semantics; that is out of scope for this increment.
|
|
445
|
+
const fromTop = fromDirRel.split('/')[0];
|
|
446
|
+
const toTop = toDirRel.split('/')[0];
|
|
447
|
+
if (fromTop !== toTop) {
|
|
448
|
+
fail(
|
|
449
|
+
args,
|
|
450
|
+
`--to '${toDirRel}' changes the top-level area ('${fromTop}' → '${toTop}'). ` +
|
|
451
|
+
`Cross-area directory moves are not supported (they change link resolution).`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
// No nesting either way: renaming a dir into its own subtree (or vice versa) is
|
|
455
|
+
// undefined.
|
|
456
|
+
if (toDirRel === fromDirRel || toDirRel.startsWith(`${fromDirRel}/`)) {
|
|
457
|
+
fail(args, `--to '${toDirRel}' is nested inside --from '${fromDirRel}'`);
|
|
458
|
+
}
|
|
459
|
+
if (fromDirRel.startsWith(`${toDirRel}/`)) {
|
|
460
|
+
fail(args, `--from '${fromDirRel}' is nested inside --to '${toDirRel}'`);
|
|
461
|
+
}
|
|
462
|
+
// Real-root containment for the destination: a symlinked --to ancestor (e.g.
|
|
463
|
+
// `projects/link` → /tmp/outside) is lexically in-vault but renameSync would
|
|
464
|
+
// follow it and write outside the vault. Resolve the deepest existing prefix.
|
|
465
|
+
const toAbs = join(args.hypoDir, toDirRel);
|
|
466
|
+
if (!realContainedInVault(toAbs, realRoot)) {
|
|
467
|
+
fail(args, `--to '${args.to}' resolves outside the wiki root (symlink escape)`);
|
|
468
|
+
}
|
|
469
|
+
if (!destinationHostable(toAbs)) {
|
|
470
|
+
fail(args, `--to '${args.to}' has a non-directory ancestor — destination cannot be created`);
|
|
471
|
+
}
|
|
472
|
+
if (subtreeHasSymlink(fromAbs)) {
|
|
473
|
+
fail(args, `--from subtree contains a symlink — refusing (move could escape the vault)`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const pages = collectPages(args.hypoDir, args.hypoDir, [], ignorePatterns);
|
|
477
|
+
const formIndex = buildFormIndex(pages);
|
|
478
|
+
|
|
479
|
+
// The moving pages: every collected page whose rel is under the from-directory.
|
|
480
|
+
const prefix = `${fromDirRel}/`;
|
|
481
|
+
const movedByRel = new Map(); // rel → { fromPage, toPage }
|
|
482
|
+
for (const p of pages) {
|
|
483
|
+
if (!p.rel.startsWith(prefix)) continue;
|
|
484
|
+
const toRel = `${toDirRel}/${p.rel.slice(prefix.length)}`;
|
|
485
|
+
const toPage = {
|
|
486
|
+
rel: toRel,
|
|
487
|
+
slug: toRel.replace(/\.md$/, ''),
|
|
488
|
+
bare: basename(toRel, '.md'),
|
|
489
|
+
path: join(args.hypoDir, toRel),
|
|
490
|
+
};
|
|
491
|
+
movedByRel.set(p.rel, { fromPage: p, toPage });
|
|
492
|
+
}
|
|
493
|
+
if (movedByRel.size === 0) {
|
|
494
|
+
fail(args, `--from '${fromDirRel}' contains no wiki pages to rename`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Renumber / merge report: a destination that already exists means this is not a
|
|
498
|
+
// clean 1:1 move. Rather than a terse hard-fail, report exactly which destination
|
|
499
|
+
// paths collide and refuse --apply, leaving the merge/renumber for manual handling.
|
|
500
|
+
if (existsSync(toAbs)) {
|
|
501
|
+
const collisions = [];
|
|
502
|
+
for (const { toPage } of movedByRel.values()) {
|
|
503
|
+
if (existsSync(toPage.path)) collisions.push(toPage.rel);
|
|
504
|
+
}
|
|
505
|
+
const report = {
|
|
506
|
+
ok: false,
|
|
507
|
+
error: `--to '${toDirRel}' already exists — directory rename requires a fresh destination`,
|
|
508
|
+
reason: 'renumber-or-merge',
|
|
509
|
+
from: fromDirRel,
|
|
510
|
+
to: toDirRel,
|
|
511
|
+
destination_collisions: collisions,
|
|
512
|
+
};
|
|
513
|
+
if (args.json) console.log(JSON.stringify(report, null, 2));
|
|
514
|
+
else {
|
|
515
|
+
console.error(`✗ ${report.error}`);
|
|
516
|
+
console.error(
|
|
517
|
+
` This is a renumber/merge: ${collisions.length} destination page(s) already exist.`,
|
|
518
|
+
);
|
|
519
|
+
for (const c of collisions) console.error(` · ${c}`);
|
|
520
|
+
console.error(` Resolve manually (merge or renumber), then retry into a fresh directory.`);
|
|
521
|
+
}
|
|
522
|
+
process.exit(1);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Post-move form index: validate that no moved page's GENERATED full/dir-relative
|
|
526
|
+
// form collides with a different page in the post-move world. (Bare forms and
|
|
527
|
+
// full slugs cannot collide here — the destination dir is fresh — so this guards
|
|
528
|
+
// the exotic dir-relative clash, per the codex design BLOCKER.)
|
|
529
|
+
const postIndex = new Map(); // form → Set<post-rel>
|
|
530
|
+
const addPost = (form, rel) => {
|
|
531
|
+
if (!form) return;
|
|
532
|
+
if (!postIndex.has(form)) postIndex.set(form, new Set());
|
|
533
|
+
postIndex.get(form).add(rel);
|
|
534
|
+
};
|
|
535
|
+
for (const p of pages) {
|
|
536
|
+
const mv = movedByRel.get(p.rel);
|
|
537
|
+
const target = mv ? mv.toPage : p;
|
|
538
|
+
addPost(target.slug, target.rel);
|
|
539
|
+
if (/(^|\/)sources(\/|$)/.test(target.rel)) continue;
|
|
540
|
+
addPost(target.bare, target.rel);
|
|
541
|
+
addPost(dirRelForm(target.slug), target.rel);
|
|
542
|
+
}
|
|
543
|
+
const formCollisions = [];
|
|
544
|
+
for (const { toPage } of movedByRel.values()) {
|
|
545
|
+
for (const form of [toPage.slug, dirRelForm(toPage.slug)]) {
|
|
546
|
+
if (!form) continue;
|
|
547
|
+
const owners = postIndex.get(form);
|
|
548
|
+
if (owners && [...owners].some((rel) => rel !== toPage.rel)) {
|
|
549
|
+
formCollisions.push({ form, page: toPage.rel });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (formCollisions.length > 0) {
|
|
554
|
+
const report = {
|
|
555
|
+
ok: false,
|
|
556
|
+
error: 'directory rename would create ambiguous link forms',
|
|
557
|
+
reason: 'form-collision',
|
|
558
|
+
from: fromDirRel,
|
|
559
|
+
to: toDirRel,
|
|
560
|
+
form_collisions: formCollisions,
|
|
561
|
+
};
|
|
562
|
+
if (args.json) console.log(JSON.stringify(report, null, 2));
|
|
563
|
+
else {
|
|
564
|
+
console.error(`✗ ${report.error}`);
|
|
565
|
+
for (const fc of formCollisions) console.error(` · '${fc.form}' (${fc.page})`);
|
|
566
|
+
}
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Rewrite inbound references across the vault.
|
|
571
|
+
const externalWrites = new Map(); // abs path → content (non-moved source files)
|
|
572
|
+
const movedBodies = new Map(); // new rel → content (moved page bodies, written post-move)
|
|
573
|
+
const fileResults = [];
|
|
574
|
+
const ambiguities = [];
|
|
575
|
+
let totalRewrites = 0;
|
|
576
|
+
for (const p of pages) {
|
|
577
|
+
const cls = preservationClass(p.rel);
|
|
578
|
+
const inSubtree = movedByRel.has(p.rel);
|
|
579
|
+
// Eligibility:
|
|
580
|
+
// sources/* → never rewritten (immutable), even inside the subtree.
|
|
581
|
+
// time-record outside → frozen (a snapshot elsewhere must not change).
|
|
582
|
+
// time-record inside → rewrite intra-subtree links, preserving the rendered
|
|
583
|
+
// label via [[new|old]] (the page genuinely relocated).
|
|
584
|
+
// ordinary page → rewrite normally.
|
|
585
|
+
if (cls === 'sources') continue;
|
|
586
|
+
if (cls === 'timerecord' && !inSubtree) continue;
|
|
587
|
+
const aliasPreserve = cls === 'timerecord' && inSubtree;
|
|
588
|
+
let raw;
|
|
589
|
+
try {
|
|
590
|
+
raw = readFileSync(p.path, 'utf-8');
|
|
591
|
+
} catch {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const { content, rewrites, ambiguous } = rewriteContentDir(
|
|
595
|
+
raw,
|
|
596
|
+
movedByRel,
|
|
597
|
+
formIndex,
|
|
598
|
+
aliasPreserve,
|
|
599
|
+
);
|
|
600
|
+
if (rewrites.length > 0) {
|
|
601
|
+
const landRel = inSubtree ? movedByRel.get(p.rel).toPage.rel : p.rel;
|
|
602
|
+
fileResults.push({ file: landRel, rewrites });
|
|
603
|
+
totalRewrites += rewrites.length;
|
|
604
|
+
if (inSubtree) movedBodies.set(movedByRel.get(p.rel).toPage.rel, content);
|
|
605
|
+
else externalWrites.set(p.path, content);
|
|
606
|
+
}
|
|
607
|
+
if (ambiguous.length > 0) ambiguities.push({ file: p.rel, ambiguous });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Apply: external rewrites in place → renameSync the whole subtree (carries
|
|
611
|
+
// non-.md assets) → write rewritten moved bodies at their new paths.
|
|
612
|
+
let moved = false;
|
|
613
|
+
if (args.apply) {
|
|
614
|
+
for (const [path, content] of externalWrites) writeFileSync(path, content);
|
|
615
|
+
mkdirSync(dirname(toAbs), { recursive: true });
|
|
616
|
+
renameSync(fromAbs, toAbs);
|
|
617
|
+
for (const [newRel, content] of movedBodies) {
|
|
618
|
+
writeFileSync(join(args.hypoDir, newRel), content);
|
|
619
|
+
}
|
|
620
|
+
moved = true;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const result = {
|
|
624
|
+
ok: true,
|
|
625
|
+
applied: args.apply,
|
|
626
|
+
mode: 'directory',
|
|
627
|
+
from: fromDirRel,
|
|
628
|
+
to: toDirRel,
|
|
629
|
+
moved,
|
|
630
|
+
pages_moved: movedByRel.size,
|
|
631
|
+
files_rewritten: fileResults.length,
|
|
632
|
+
links_rewritten: totalRewrites,
|
|
633
|
+
rewrites: fileResults,
|
|
634
|
+
ambiguous: ambiguities,
|
|
635
|
+
};
|
|
636
|
+
if (args.json) {
|
|
637
|
+
console.log(JSON.stringify(result, null, 2));
|
|
638
|
+
} else {
|
|
639
|
+
const mode = args.apply ? 'Renamed directory' : 'Dry-run (no changes written)';
|
|
640
|
+
console.log(`${mode}: ${fromDirRel}/ → ${toDirRel}/ (${movedByRel.size} page(s))`);
|
|
641
|
+
console.log(` ${totalRewrites} inbound link(s) across ${fileResults.length} file(s).`);
|
|
642
|
+
for (const f of fileResults) {
|
|
643
|
+
console.log(` · ${f.file}: ${f.rewrites.map((r) => `${r.from}→${r.to}`).join(', ')}`);
|
|
644
|
+
}
|
|
645
|
+
if (ambiguities.length > 0) {
|
|
646
|
+
console.log('\n ⚠ ambiguous links NOT rewritten (form shared by >1 page):');
|
|
647
|
+
for (const a of ambiguities) {
|
|
648
|
+
for (const x of a.ambiguous) console.log(` · ${a.file}:${x.line} ${x.link}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (!args.apply) console.log('\n Re-run with --apply to write the move + rewrites.');
|
|
652
|
+
}
|
|
653
|
+
process.exit(0);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ── main ────────────────────────────────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
function run(args) {
|
|
659
|
+
if (!args.from || !args.to) {
|
|
660
|
+
fail(args, '--from=<slug|rel> and --to=<slug|rel> are required');
|
|
661
|
+
}
|
|
662
|
+
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
663
|
+
|
|
664
|
+
// Directory mode: --from points at an existing directory → relocate the subtree.
|
|
665
|
+
const fromNorm = args.from.replace(/\\/g, '/').replace(/\.md$/, '').replace(/\/+$/, '');
|
|
666
|
+
const fromAbs = join(args.hypoDir, fromNorm);
|
|
667
|
+
const fromIsDir = existsSync(fromAbs) && statSync(fromAbs).isDirectory();
|
|
668
|
+
if (fromIsDir) {
|
|
669
|
+
// A literal `foo/` directory AND a `foo.md` page both present → ambiguous intent.
|
|
670
|
+
if (existsSync(`${fromAbs}.md`)) {
|
|
671
|
+
fail(
|
|
672
|
+
args,
|
|
673
|
+
`--from '${args.from}' is ambiguous: both a directory and a page exist. Pass a more specific path.`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
return runDirectory(args, fromNorm, ignorePatterns);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const pages = collectPages(args.hypoDir, args.hypoDir, [], ignorePatterns);
|
|
680
|
+
const formIndex = buildFormIndex(pages);
|
|
681
|
+
|
|
682
|
+
const fromPage = resolveArgToPage(args.from, pages, formIndex);
|
|
683
|
+
if (!fromPage) {
|
|
684
|
+
fail(args, `--from did not resolve to a unique existing page: ${args.from}`);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Compute the destination rel. --to may be a full rel (move across dirs) or a
|
|
688
|
+
// bare new name (rename in place within from-page's directory).
|
|
689
|
+
const toNorm = args.to.replace(/\\/g, '/').replace(/\.md$/, '');
|
|
690
|
+
const toRelRaw = toNorm.includes('/')
|
|
691
|
+
? `${toNorm}.md`
|
|
692
|
+
: `${dirname(fromPage.rel)}/${toNorm}.md`.replace(/^\.\//, '');
|
|
693
|
+
// Normalize away ./ and ../ segments, then require the result stays inside the
|
|
694
|
+
// vault: a `--to` like `../moved` must not move a page out of the wiki, and
|
|
695
|
+
// `pages/../pages/bar` must not become a non-canonical `[[pages/../pages/bar]]`
|
|
696
|
+
// link target.
|
|
697
|
+
const toRel = normalize(toRelRaw).replace(/\\/g, '/');
|
|
698
|
+
if (toRel === '..' || toRel.startsWith('../') || isAbsolute(toRel)) {
|
|
699
|
+
fail(args, `--to escapes the wiki root: ${args.to}`);
|
|
700
|
+
}
|
|
701
|
+
const toSlug = toRel.replace(/\.md$/, '');
|
|
702
|
+
const toPath = join(args.hypoDir, toRel);
|
|
703
|
+
|
|
704
|
+
// Real-root containment: a symlinked --to ancestor (e.g. `projects/link` →
|
|
705
|
+
// /tmp/outside) is lexically in-vault, yet writeFileSync(toPath) would follow it
|
|
706
|
+
// and write the moved page outside the wiki. Resolve the deepest existing prefix
|
|
707
|
+
// (lstat-based, fail-closed on a dangling link) and require it to stay in-vault.
|
|
708
|
+
let realRoot;
|
|
709
|
+
try {
|
|
710
|
+
realRoot = realpathSync(args.hypoDir);
|
|
711
|
+
} catch {
|
|
712
|
+
fail(args, `wiki root cannot be resolved: ${args.hypoDir}`);
|
|
713
|
+
}
|
|
714
|
+
if (!realContainedInVault(toPath, realRoot)) {
|
|
715
|
+
fail(args, `--to '${args.to}' resolves outside the wiki root (symlink escape)`);
|
|
716
|
+
}
|
|
717
|
+
if (!destinationHostable(toPath)) {
|
|
718
|
+
fail(args, `--to '${args.to}' has a non-directory ancestor — destination cannot be created`);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Guard: never overwrite an existing destination (an ADR-renumber / merge would
|
|
722
|
+
// land here — that is a report-only case, not a blind move).
|
|
723
|
+
if (existsSync(toPath) && toRel !== fromPage.rel) {
|
|
724
|
+
fail(
|
|
725
|
+
args,
|
|
726
|
+
`--to already exists: ${toRel}. Rename cannot overwrite a live page (renumber/merge is manual).`,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
if (toRel === fromPage.rel) {
|
|
730
|
+
fail(args, `--from and --to resolve to the same page (${toRel}) — nothing to rename`);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const toPage = {
|
|
734
|
+
rel: toRel,
|
|
735
|
+
slug: toSlug,
|
|
736
|
+
bare: basename(toRel, '.md'),
|
|
737
|
+
path: toPath,
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
// Destination must be a UNIQUE link destination: existsSync(toPath) above only
|
|
741
|
+
// catches a same-path clobber, not a cross-directory basename collision
|
|
742
|
+
// (pages/bar.md vs projects/bar.md). If the new bare or dir-relative form
|
|
743
|
+
// already resolves to a DIFFERENT existing page, the rewritten `[[new]]` links
|
|
744
|
+
// would be ambiguous — the same contract that bars rewriting an ambiguous
|
|
745
|
+
// source link. Refuse rather than emit ambiguous links.
|
|
746
|
+
for (const form of [toPage.bare, dirRelForm(toPage.slug)]) {
|
|
747
|
+
if (!form) continue;
|
|
748
|
+
const owners = formIndex.get(form);
|
|
749
|
+
if (owners && [...owners].some((rel) => rel !== fromPage.rel)) {
|
|
750
|
+
fail(
|
|
751
|
+
args,
|
|
752
|
+
`--to '${args.to}' collides with an existing page on form '${form}' — rewritten links would be ambiguous. Pick a unique name.`,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Rewrite inbound references across every NON-preserved page (skip the moved
|
|
758
|
+
// page itself — self-references are rewritten on its own content separately).
|
|
759
|
+
const fileResults = [];
|
|
760
|
+
let totalRewrites = 0;
|
|
761
|
+
const ambiguities = [];
|
|
762
|
+
for (const p of pages) {
|
|
763
|
+
if (isPreservedSource(p.rel)) continue;
|
|
764
|
+
let raw;
|
|
765
|
+
try {
|
|
766
|
+
raw = readFileSync(p.path, 'utf-8');
|
|
767
|
+
} catch {
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
const { content, rewrites, ambiguous } = rewriteContent(raw, fromPage, toPage, formIndex);
|
|
771
|
+
if (rewrites.length > 0) {
|
|
772
|
+
fileResults.push({ file: p.rel, rewrites });
|
|
773
|
+
totalRewrites += rewrites.length;
|
|
774
|
+
if (args.apply && content !== raw) {
|
|
775
|
+
// The from-page is about to move; write its rewritten body to the NEW
|
|
776
|
+
// path below, not the old one.
|
|
777
|
+
if (p.rel === fromPage.rel) {
|
|
778
|
+
fromPage._rewritten = content;
|
|
779
|
+
} else {
|
|
780
|
+
writeFileSync(p.path, content);
|
|
781
|
+
}
|
|
782
|
+
} else if (p.rel === fromPage.rel) {
|
|
783
|
+
fromPage._rewritten = content;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (ambiguous.length > 0) {
|
|
787
|
+
ambiguities.push({ file: p.rel, ambiguous });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Move the file (--apply only): write the (possibly self-rewritten) body at the
|
|
792
|
+
// new path, then drop the old one. Done as write-then-remove rather than a raw
|
|
793
|
+
// rename so the carried-over self-reference rewrites are preserved.
|
|
794
|
+
let moved = false;
|
|
795
|
+
if (args.apply) {
|
|
796
|
+
mkdirSync(dirname(toPath), { recursive: true });
|
|
797
|
+
const body = fromPage._rewritten ?? readFileSync(fromPage.path, 'utf-8');
|
|
798
|
+
writeFileSync(toPath, body);
|
|
799
|
+
if (toPath !== fromPage.path) rmSync(fromPage.path, { force: true });
|
|
800
|
+
moved = true;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const result = {
|
|
804
|
+
ok: true,
|
|
805
|
+
applied: args.apply,
|
|
806
|
+
from: fromPage.rel,
|
|
807
|
+
to: toRel,
|
|
808
|
+
moved,
|
|
809
|
+
files_rewritten: fileResults.length,
|
|
810
|
+
links_rewritten: totalRewrites,
|
|
811
|
+
rewrites: fileResults,
|
|
812
|
+
ambiguous: ambiguities,
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
if (args.json) {
|
|
816
|
+
console.log(JSON.stringify(result, null, 2));
|
|
817
|
+
} else {
|
|
818
|
+
const mode = args.apply ? 'Renamed' : 'Dry-run (no changes written)';
|
|
819
|
+
console.log(`${mode}: ${fromPage.rel} → ${toRel}`);
|
|
820
|
+
console.log(` ${totalRewrites} inbound link(s) across ${fileResults.length} file(s).`);
|
|
821
|
+
for (const f of fileResults) {
|
|
822
|
+
console.log(` · ${f.file}: ${f.rewrites.map((r) => `${r.from}→${r.to}`).join(', ')}`);
|
|
823
|
+
}
|
|
824
|
+
if (ambiguities.length > 0) {
|
|
825
|
+
console.log('\n ⚠ ambiguous links NOT rewritten (bare slug shared by >1 page):');
|
|
826
|
+
for (const a of ambiguities) {
|
|
827
|
+
for (const x of a.ambiguous) console.log(` · ${a.file}:${x.line} ${x.link}`);
|
|
828
|
+
}
|
|
829
|
+
console.log(' Resolve these manually (use a dir-relative or full-slug form).');
|
|
830
|
+
}
|
|
831
|
+
if (!args.apply) console.log('\n Re-run with --apply to write the move + rewrites.');
|
|
832
|
+
}
|
|
833
|
+
process.exit(0);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
run(parseArgs(process.argv));
|