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.
- 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 +7 -7
- 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-hot-rebuild.mjs +22 -2
- package/hooks/hypo-personal-check.mjs +1 -1
- package/hooks/hypo-session-end.mjs +21 -2
- package/hooks/hypo-shared.mjs +434 -192
- package/package.json +2 -1
- package/scripts/capture.mjs +26 -20
- package/scripts/crystallize.mjs +153 -20
- package/scripts/doctor.mjs +2 -2
- package/scripts/init.mjs +34 -18
- package/scripts/lib/design-history-stale.mjs +26 -7
- package/scripts/lib/extensions.mjs +89 -6
- package/scripts/lib/git-hooks-dir.mjs +139 -2
- package/scripts/lib/slug-resolver.mjs +181 -0
- package/scripts/lint.mjs +72 -24
- package/scripts/rename.mjs +38 -141
- package/scripts/uninstall.mjs +351 -2
- package/skills/crystallize/SKILL.md +2 -2
- package/templates/hypo-config.md +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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',
|