hypomnema 1.7.2 → 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.
@@ -28,6 +28,12 @@ import {
28
28
  unlinkSync,
29
29
  mkdirSync,
30
30
  lstatSync,
31
+ statSync,
32
+ fstatSync,
33
+ chmodSync,
34
+ fchmodSync,
35
+ openSync,
36
+ closeSync,
31
37
  rmdirSync,
32
38
  } from 'fs';
33
39
  import { join, dirname, relative, resolve, posix, sep } from 'path';
@@ -894,9 +900,46 @@ export function readExtensionPkgStateNoMutate(pkgPath, target) {
894
900
 
895
901
  // ── sync orchestration ─────────────────────────────────────────────────────────
896
902
 
897
- function writeFreshAtomic(dest, content) {
903
+ /** Carry only src's execute bits (owner/group/other) onto a mode value; every
904
+ * other permission bit (dest's own read/write, however umask shaped it) is
905
+ * left alone. This is the one place forward-sync decides "should this file be
906
+ * executable", so every writer of dest routes through it. Exported so
907
+ * capture.mjs's own atomic writer (a captured file's FIRST write into the wiki)
908
+ * applies the identical rule; the wiki copy must not lose the bit before
909
+ * forward-sync ever gets a chance to carry it further. */
910
+ export function withSrcExecBits(destMode, srcMode) {
911
+ return (destMode & ~0o111) | (srcMode & 0o111);
912
+ }
913
+
914
+ /** Add only the execute bits src has that dest is missing, onto dest — every bit
915
+ * dest already carries (owner/group/other, ours or the user's) survives untouched.
916
+ * This is the additive counterpart to `withSrcExecBits` above: that one REPLACES
917
+ * dest's exec bits wholesale, which is right for a fresh write (there is no prior
918
+ * dest state worth keeping) but wrong for healing an existing file, where treating
919
+ * "executable" as one boolean instead of three independent bits made a cross
920
+ * combination (src owner-only, dest group-only) read as already-satisfied and
921
+ * never get healed. Used only by copyOne's content-identical branch. */
922
+ export function addSrcExecBits(destMode, srcMode) {
923
+ return destMode | (srcMode & 0o111);
924
+ }
925
+
926
+ function writeFreshAtomic(dest, content, srcMode) {
927
+ // `wx` (O_CREAT|O_EXCL) refuses to open a path that already exists, symlink
928
+ // included, so a predictable tmp name can't be raced into following one, the
929
+ // same hardening capture.mjs's own writeAtomic already carries.
898
930
  const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
899
- writeFileSync(tmp, content);
931
+ const fd = openSync(tmp, 'wx');
932
+ try {
933
+ writeFileSync(fd, content);
934
+ if (srcMode != null) {
935
+ // No fd-based write has a mode option that survives umask, so the execute
936
+ // bit has to be applied explicitly, and before the rename: otherwise dest
937
+ // is briefly visible with the wrong mode to anything racing this write.
938
+ fchmodSync(fd, withSrcExecBits(fstatSync(fd).mode, srcMode));
939
+ }
940
+ } finally {
941
+ closeSync(fd);
942
+ }
900
943
  try {
901
944
  renameSync(tmp, dest);
902
945
  } catch (err) {
@@ -914,13 +957,26 @@ function writeFreshAtomic(dest, content) {
914
957
  * overwrites user-modified / unowned files; without it those are left untouched and
915
958
  * surface as drift/conflict. A symlink/non-regular dest is never followed even under
916
959
  * force (the isRegularFile guard precedes the force branch).
960
+ *
961
+ * The executable bit is not tracked anywhere (no mode column in the SHA map's
962
+ * ownership model), so src's mode is the only source of truth for it and gets
963
+ * carried onto dest on every branch that (re)writes it. The content-identical
964
+ * branch below is the exception: it only ADDS a bit src has that dest lacks
965
+ * (per owner/group/other, not "executable" as one boolean), never turns one
966
+ * off, since a dest exec bit that src lacks can't be told apart from one the
967
+ * user set. `typeDir` is the symlink-ancestor boundary for that same branch's
968
+ * chmod: every caller passes the extension-type root (`~/.claude/hooks`, etc.)
969
+ * so a symlinked ancestor there is never chmod'd through to whatever it points
970
+ * at (codex pre-commit BLOCKER — the flat-file and manifest callers had no
971
+ * such guard, unlike the skill loop's own pre-check on `destPath`).
917
972
  */
918
- function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
973
+ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force, typeDir }) {
919
974
  const srcContent = readFileSync(srcPath);
920
975
  const srcSHA = sha256(srcContent);
976
+ const srcMode = statSync(srcPath).mode;
921
977
 
922
978
  if (!existsSync(destPath)) {
923
- if (apply) writeFreshAtomic(destPath, srcContent);
979
+ if (apply) writeFreshAtomic(destPath, srcContent, srcMode);
924
980
  return { action: 'create', sha: srcSHA };
925
981
  }
926
982
  if (!isRegularFile(destPath)) {
@@ -933,6 +989,30 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
933
989
  }
934
990
  const onDiskSHA = sha256(onDisk);
935
991
  if (onDiskSHA === srcSHA) {
992
+ // Content already matches, but the exec bit can still be stale: this used to
993
+ // be a pure no-op, which is exactly why a missing bit here never healed. Only
994
+ // heal by ADDING the specific bits src has that dest lacks (owner/group/other
995
+ // checked independently — a prior boolean "is anything executable" check made
996
+ // a cross combination like src=owner-only/dest=group-only read as already
997
+ // satisfied and skip healing the owner bit entirely, codex pre-commit BLOCKER).
998
+ // A dest bit src lacks is always left alone, because that could be this same
999
+ // heal from an older run, or a bit the user set on purpose, and we have no way
1000
+ // to tell those apart. This also means a wiki copy captured before this fix
1001
+ // (recorded 644 for a 755 local original) can no longer strip the local file
1002
+ // back to 644 on the next sync; it can only ever add bits.
1003
+ // Reported as 'update' (not a new action) so every existing "N to sync" /
1004
+ // "N synced" count and log line already keyed on create/update/force-update
1005
+ // picks it up for free.
1006
+ // ponytail: add-only means a dest that lost a bit src still has (content
1007
+ // matches, exec bit was stripped some other way) never gets it back either.
1008
+ // Upgrade path: record mode next to sha in the pkg-json map so a user's own
1009
+ // bit can be told apart from our default and healed in both directions.
1010
+ const destMode = statSync(destPath).mode;
1011
+ const missingBits = srcMode & 0o111 & ~destMode;
1012
+ if (missingBits !== 0 && !hasSymlinkAncestor(typeDir, destPath)) {
1013
+ if (apply) chmodSync(destPath, addSrcExecBits(destMode, srcMode));
1014
+ return { action: 'update', sha: srcSHA };
1015
+ }
936
1016
  return { action: 'up-to-date', sha: srcSHA };
937
1017
  }
938
1018
  if (recordedSHA && onDiskSHA === recordedSHA) {
@@ -942,7 +1022,7 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
942
1022
  if (!verify || sha256(verify) !== recordedSHA) {
943
1023
  return { action: 'skip-changed', sha: recordedSHA };
944
1024
  }
945
- writeFreshAtomic(destPath, srcContent);
1025
+ writeFreshAtomic(destPath, srcContent, srcMode);
946
1026
  }
947
1027
  return { action: 'update', sha: srcSHA };
948
1028
  }
@@ -950,7 +1030,7 @@ function copyOne({ srcPath, destPath, key, recordedSHA, apply, force }) {
950
1030
  if (force) {
951
1031
  if (apply) {
952
1032
  writeFreshAtomic(`${destPath}.bak`, onDisk);
953
- writeFreshAtomic(destPath, srcContent);
1033
+ writeFreshAtomic(destPath, srcContent, srcMode);
954
1034
  }
955
1035
  return { action: 'force-update', sha: srcSHA };
956
1036
  }
@@ -1058,6 +1138,7 @@ function syncOneSkill({
1058
1138
  recordedSHA: recordedNested[f.rel],
1059
1139
  apply,
1060
1140
  force,
1141
+ typeDir,
1061
1142
  });
1062
1143
  if (res.sha != null) newNested[f.rel] = res.sha;
1063
1144
  result.actions.push({ target, file: fileKey, action: res.action });
@@ -1375,6 +1456,7 @@ export function syncExtensions({
1375
1456
  recordedSHA: recorded[fileKey],
1376
1457
  apply,
1377
1458
  force,
1459
+ typeDir,
1378
1460
  });
1379
1461
  if (fileRes.sha != null) newSHAs[fileKey] = fileRes.sha;
1380
1462
  result.actions.push({ target, file: fileKey, action: fileRes.action });
@@ -1409,6 +1491,7 @@ export function syncExtensions({
1409
1491
  recordedSHA: recorded[mKey],
1410
1492
  apply,
1411
1493
  force,
1494
+ typeDir,
1412
1495
  });
1413
1496
  if (mRes.sha != null) newSHAs[mKey] = mRes.sha;
1414
1497
  result.actions.push({ target, file: mKey, action: mRes.action });
@@ -38,6 +38,143 @@ import { execFileSync } from 'child_process';
38
38
  import { existsSync, lstatSync, realpathSync, statSync } from 'fs';
39
39
  import { basename, dirname, isAbsolute, join, resolve, sep } from 'path';
40
40
 
41
+ // ── shared install/uninstall markers ────────────────────────────────────────
42
+ // init.mjs writes these when it installs the wiki's git pre-commit hook and
43
+ // the shell rc block; uninstall.mjs reads them back to remove exactly what
44
+ // init created. Defined once here, imported by both, so the two scripts can
45
+ // never drift into recognizing different markers.
46
+ export const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
47
+ export const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
48
+ export const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
49
+ export const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
50
+
51
+ // ── marker-span validation (shared by both the writer in init.mjs and both
52
+ // removal paths in uninstall.mjs) ───────────────────────────────────────────
53
+ //
54
+ // Two independent indexOf() calls cannot tell "well-formed" apart from
55
+ // "duplicated" or "swapped": if a file happens to hold two full copies of the
56
+ // block, indexOf finds only the first END, so slicing [firstStart, firstEnd]
57
+ // leaves the second copy's install behind with no report of it. If END
58
+ // precedes START (a hand-edited or corrupted file), slicing [start, end) with
59
+ // start > end does not error, it silently duplicates whatever sits between
60
+ // them into the "removed" (or, on the writer's side, the "replaced") span.
61
+ // Neither script has a way back from either outcome, so a span is only
62
+ // trusted when both markers appear EXACTLY once and START comes before END.
63
+ function countOccurrences(content, needle) {
64
+ let count = 0;
65
+ let idx = 0;
66
+ while ((idx = content.indexOf(needle, idx)) !== -1) {
67
+ count++;
68
+ idx += needle.length;
69
+ }
70
+ return count;
71
+ }
72
+
73
+ export function findMarkerSpan(content, startMarker, endMarker) {
74
+ const startCount = countOccurrences(content, startMarker);
75
+ const endCount = countOccurrences(content, endMarker);
76
+ if (startCount !== 1 || endCount !== 1) {
77
+ return {
78
+ ok: false,
79
+ reason: `expected exactly one start and one end marker, found ${startCount} start / ${endCount} end`,
80
+ };
81
+ }
82
+ const startIdx = content.indexOf(startMarker);
83
+ const endIdx = content.indexOf(endMarker);
84
+ if (!(startIdx < endIdx)) {
85
+ return { ok: false, reason: 'the end marker appears before the start marker' };
86
+ }
87
+ return { ok: true, startIdx, endIdx };
88
+ }
89
+
90
+ // ── body-shape validation (shared by both removal paths in uninstall.mjs) ──
91
+ //
92
+ // findMarkerSpan proves the span itself is well-formed. It says nothing about
93
+ // what sits INSIDE that span. A well-formed marker pair is trivial to forge
94
+ // around arbitrary content — a user's own shell function, a user's own
95
+ // pre-commit check — and codex reproduced exactly that (2026-08-27): a marker
96
+ // pair wrapped around `echo USER_OWNED_DEPLOY_CHECK` passed every prior check
97
+ // (one start, one end, start before end, a leading shebang) and got deleted
98
+ // along with the user's line, because nothing ever looked at the body text
99
+ // itself. These two functions are that missing check.
100
+ //
101
+ // The shell block is fully static — init never bakes a path into it — so its
102
+ // body can be matched byte-for-byte against SHELL_FUNCTION_BODY below. The
103
+ // pre-commit body cannot: it embeds the absolute install root, which moves
104
+ // across machines and package versions, so requiring an exact match would
105
+ // refuse to remove a hook a real (older, or differently-installed) init.mjs
106
+ // actually wrote. It is matched structurally instead — the "one or two `node
107
+ // '<path>' ... || exit 1` steps, then `exit 0`" shape — checking only that
108
+ // the referenced script is ours (ends in `/hooks/hypo-pre-commit.mjs` or
109
+ // `/scripts/lint.mjs`), not which root it lives under.
110
+ //
111
+ // Both directions of a mismatch here are unequal: failing to recognize a
112
+ // hook init actually wrote costs a re-run with --force-*; deleting a file
113
+ // that was never ours has no recovery. So an unrecognized shape is always
114
+ // treated as "not ours" and left standing, never as "close enough".
115
+
116
+ const PRE_COMMIT_WORKER_LINE = /^node '(.+)' \|\| exit 1$/;
117
+ const PRE_COMMIT_LINT_LINE = /^node '(.+)' --hypo-dir='(?:.+)' --strict \|\| exit 1$/;
118
+
119
+ // Reverses shellSingleQuote()'s escaping (a literal `'` becomes `'\''`) so the
120
+ // captured path can be compared against the suffix it must end in.
121
+ function unescapeShellSingleQuoted(s) {
122
+ return s.split("'\\''").join("'");
123
+ }
124
+
125
+ /**
126
+ * @param {string} content full pre-commit hook file content
127
+ * @param {{startIdx: number, endIdx: number}} span a `findMarkerSpan` result
128
+ * already confirmed `ok: true` for WIKI_PRE_COMMIT_MARKER_START/END
129
+ * @returns {boolean} true when the text between the markers is recognizable
130
+ * as a body init.mjs's wikiPreCommitContent() writes
131
+ */
132
+ export function isOwnedWikiPreCommitBody(content, span) {
133
+ const body = content.slice(span.startIdx + WIKI_PRE_COMMIT_MARKER_START.length, span.endIdx);
134
+ const lines = body.split('\n');
135
+ // wikiPreCommitContent() always places a bare "\n" right after START and
136
+ // right before END, so the first and last split segments must be empty.
137
+ if (lines[0] !== '' || lines[lines.length - 1] !== '') return false;
138
+ const middle = lines.slice(1, -1);
139
+ if (middle.length < 2 || middle.length > 3 || middle[middle.length - 1] !== 'exit 0') {
140
+ return false;
141
+ }
142
+ const steps = middle.slice(0, -1);
143
+ const worker = PRE_COMMIT_WORKER_LINE.exec(steps[0]);
144
+ if (!worker || !unescapeShellSingleQuoted(worker[1]).endsWith('/hooks/hypo-pre-commit.mjs')) {
145
+ return false;
146
+ }
147
+ if (steps.length === 2) {
148
+ const lint = PRE_COMMIT_LINT_LINE.exec(steps[1]);
149
+ if (!lint || !unescapeShellSingleQuoted(lint[1]).endsWith('/scripts/lint.mjs')) return false;
150
+ }
151
+ return true;
152
+ }
153
+
154
+ // The exact text init.mjs's shellFunctionBlock() writes between the shell
155
+ // markers. Exported so init.mjs builds the block FROM this constant rather
156
+ // than a second copy of the same literal — the two can then never drift the
157
+ // way independent copies of the pre-commit worker line already could not
158
+ // (see the module-level comment on the markers above).
159
+ export const SHELL_FUNCTION_BODY = `
160
+ function claude() {
161
+ echo "{\\"cwd\\":\\"$(pwd)\\"}" | node "$HOME/.claude/hooks/hypo-session-start.mjs" > /dev/null 2>&1
162
+ command claude "$@"
163
+ }
164
+ `;
165
+
166
+ /**
167
+ * @param {string} content full rc file content
168
+ * @param {{startIdx: number, endIdx: number}} span a `findMarkerSpan` result
169
+ * already confirmed `ok: true` for SHELL_MARKER_START/END
170
+ * @returns {boolean} true when the text between the markers is byte-identical
171
+ * to what init.mjs writes
172
+ */
173
+ export function isOwnedShellFunctionBody(content, span) {
174
+ const body = content.slice(span.startIdx + SHELL_MARKER_START.length, span.endIdx);
175
+ return body === SHELL_FUNCTION_BODY;
176
+ }
177
+
41
178
  // Fallback scrub list for git versions without `rev-parse --local-env-vars`.
42
179
  // Mirrors scripts/install-git-hooks.mjs, which established this trust model.
43
180
  const STATIC_LOCAL_ENV_VARS = [
@@ -72,7 +209,7 @@ function buildScrubbedEnv(localEnvList) {
72
209
  // Canonicalize a path that may not exist yet: realpath the deepest existing
73
210
  // ancestor and re-append the rest. Without this, a hooks dir git will create
74
211
  // lazily could evade the containment check via an unresolved symlinked parent.
75
- function canonicalize(p) {
212
+ export function canonicalize(p) {
76
213
  let cur = resolve(p);
77
214
  const tail = [];
78
215
  for (;;) {
@@ -91,7 +228,7 @@ function canonicalize(p) {
91
228
  }
92
229
  }
93
230
 
94
- function isInside(child, parent) {
231
+ export function isInside(child, parent) {
95
232
  return child === parent || child.startsWith(parent + sep);
96
233
  }
97
234
 
@@ -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
@@ -19,7 +19,7 @@
19
19
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
20
20
  import { join, extname, basename } from 'path';
21
21
  import { resolveHypoRootInfo, checkVaultOrExit, expandHome } from './lib/hypo-root.mjs';
22
- import { SESSION_STATE_NEXT_HEADINGS } from '../hooks/hypo-shared.mjs';
22
+ import { SESSION_STATE_NEXT_HEADINGS, closeFileTargetsGlobal } from '../hooks/hypo-shared.mjs';
23
23
  import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
24
24
  import {
25
25
  parseSchemaVocab,
@@ -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
@@ -554,11 +540,73 @@ const validTypes = new Set([...VALID_TYPES, ...parseSchemaTypes(args.hypoDir)]);
554
540
 
555
541
  for (const page of pages) lintPage(page, slugMap, tagVocab, pageDirs, validTypes);
556
542
 
543
+ // W4 (broken-wikilink only, NOT the full lintPage) for close's root-level
544
+ // write targets that fall outside pages/projects/journal: hot.md and log.md
545
+ // today. closeFileTargetsGlobal is the one list of what close writes
546
+ // (hooks/hypo-shared.mjs), reused here instead of re-derived, so a future
547
+ // close target is covered automatically. Only the entries NOT already under
548
+ // a scanDir are new; closeFileTargetsGlobal's projects/<slug>/* entries are
549
+ // already linted in full above.
550
+ //
551
+ // Why link-only and not the full lintPage: measured, not assumed. On the
552
+ // packaged templates/ (hot.md type:reference, log.md type:log, both declared
553
+ // in templates/SCHEMA.md's taxonomy), full lintPage on both files comes back
554
+ // completely clean, so "these types trip W2" is not the reason to hold back.
555
+ // The real reason is that live vaults do not all match the template. A vault
556
+ // whose SCHEMA.md predates the `log` type row (or has none) gets a fresh
557
+ // "Unknown type: log" W2 the moment log.md is run through lintPage, and a
558
+ // vault whose root log.md predates the frontmatter convention entirely (no
559
+ // leading `---` block at all, confirmed against a real maintainer vault)
560
+ // gets a fresh "No frontmatter found" W1. Both W1 and W2 are in
561
+ // STRICT_PROMOTE_IDS, so under --strict a vault that lint has always passed
562
+ // would start failing on a file whose content this fix never touched. Full
563
+ // lintPage stays reserved for pages/projects/journal, where every file was
564
+ // already being linted before this change and there is no such newly-exposed
565
+ // vault. W4 itself stays warn-only outside --strict, and postApply
566
+ // (crystallize.mjs) gates only on errors, so this addition can add a new
567
+ // warning to a close's lint output but can never fail one.
568
+ const closeRootTargets = [...closeFileTargetsGlobal(args.hypoDir)].filter(
569
+ (f) => !f.startsWith('pages/') && !f.startsWith('projects/') && !f.startsWith('journal/'),
570
+ );
571
+ for (const rel of closeRootTargets) {
572
+ const full = join(args.hypoDir, rel);
573
+ if (!existsSync(full) || isScanIgnored(full, args.hypoDir, ignorePatterns)) continue;
574
+ let content;
575
+ try {
576
+ content = readFileSync(full, 'utf-8');
577
+ } catch {
578
+ continue;
579
+ }
580
+ for (const link of extractWikilinks(content)) {
581
+ if (!slugMap.has(link)) {
582
+ issue('warn', rel, `Broken wikilink: [[${link}]]`, null, 'W4');
583
+ }
584
+ }
585
+ }
586
+
557
587
  // W8: design-history.md stale relative to session-log.md. Emitted once per
558
588
  // project (not per page) — runs outside the page loop. POSIX-separated path
559
589
  // literal (not path.join) so consumers can rely on `file.split('/')` shape
560
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.
561
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
+ }
562
610
  const gap = s.diffDays != null ? ` (${s.diffDays}일 차이)` : '';
563
611
  issue(
564
612
  'warn',