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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/commands/capture.md +1 -1
- package/commands/crystallize.md +2 -2
- package/commands/uninstall.md +16 -4
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CONTRIBUTING.md +13 -4
- package/hooks/close-gate-store.mjs +435 -0
- package/hooks/hooks.json +2 -1
- package/hooks/hypo-close-guard.mjs +24 -4
- package/hooks/hypo-personal-check.mjs +1 -1
- package/hooks/hypo-session-end.mjs +21 -2
- package/hooks/hypo-shared.mjs +257 -161
- package/package.json +2 -1
- package/scripts/capture.mjs +15 -4
- package/scripts/crystallize.mjs +56 -8
- package/scripts/init.mjs +26 -11
- package/scripts/lib/design-history-stale.mjs +26 -7
- package/scripts/lib/extensions.mjs +60 -16
- package/scripts/lib/git-hooks-dir.mjs +129 -2
- package/scripts/lib/slug-resolver.mjs +181 -0
- package/scripts/lint.mjs +27 -23
- package/scripts/rename.mjs +38 -141
- package/scripts/uninstall.mjs +155 -58
- package/skills/crystallize/SKILL.md +2 -2
- package/templates/hypo-config.md +1 -1
|
@@ -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
|
|
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
|
-
//
|
|
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',
|
package/scripts/rename.mjs
CHANGED
|
@@ -48,11 +48,22 @@ import {
|
|
|
48
48
|
chmodSync,
|
|
49
49
|
rmSync,
|
|
50
50
|
} from 'fs';
|
|
51
|
-
import { join, basename, dirname, normalize, isAbsolute
|
|
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
|
|
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
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
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 &&
|
|
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
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
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(
|
|
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(
|
|
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);
|