hypomnema 1.6.2 → 1.7.0
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 +39 -14
- package/README.md +39 -14
- package/commands/capture.md +8 -6
- package/commands/crystallize.md +39 -20
- package/docs/ARCHITECTURE.md +49 -14
- package/docs/CONTRIBUTING.md +31 -29
- package/hooks/base-store.mjs +265 -0
- package/hooks/hooks.json +7 -1
- package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
- package/hooks/hypo-auto-stage.mjs +30 -0
- package/hooks/hypo-cwd-change.mjs +31 -2
- package/hooks/hypo-file-watch.mjs +21 -2
- package/hooks/hypo-first-prompt.mjs +19 -3
- package/hooks/hypo-lookup.mjs +86 -29
- package/hooks/hypo-personal-check.mjs +24 -3
- package/hooks/hypo-session-record.mjs +2 -3
- package/hooks/hypo-session-start.mjs +89 -7
- package/hooks/hypo-shared.mjs +904 -128
- package/hooks/proposal-store.mjs +513 -0
- package/package.json +40 -14
- package/scripts/capture.mjs +556 -37
- package/scripts/crystallize.mjs +639 -108
- package/scripts/doctor.mjs +304 -9
- package/scripts/feedback-sync.mjs +515 -44
- package/scripts/graph.mjs +9 -2
- package/scripts/init.mjs +230 -34
- package/scripts/lib/extensions.mjs +656 -1
- package/scripts/lib/hypo-ignore.mjs +54 -6
- package/scripts/lib/hypo-root.mjs +56 -6
- package/scripts/lib/page-usage.mjs +15 -2
- package/scripts/lib/pkg-json.mjs +40 -0
- package/scripts/lib/plugin-detect.mjs +96 -6
- package/scripts/lib/wd-match.mjs +23 -5
- package/scripts/lib/wikilink.mjs +32 -6
- package/scripts/lint.mjs +20 -4
- package/scripts/proposal.mjs +1032 -0
- package/scripts/query.mjs +25 -4
- package/scripts/resume.mjs +34 -12
- package/scripts/stats.mjs +28 -8
- package/scripts/uninstall.mjs +141 -6
- package/scripts/upgrade.mjs +197 -15
- package/skills/crystallize/SKILL.md +44 -7
- package/skills/debate/SKILL.md +88 -0
- package/skills/debate/references/orchestration-patterns.md +83 -0
- package/templates/.hyposcanignore +10 -0
- package/templates/SCHEMA.md +12 -0
- package/templates/gitignore +5 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +6 -0
- package/scripts/.gitkeep +0 -0
- package/scripts/check-bilingual.mjs +0 -153
- package/scripts/check-readme-version.mjs +0 -126
- package/scripts/check-tracker-ids.mjs +0 -426
- package/scripts/check-versions.mjs +0 -171
- package/scripts/install-git-hooks.mjs +0 -293
- package/scripts/lib/changelog-classify.mjs +0 -216
- package/scripts/lib/check-bilingual.mjs +0 -244
- package/scripts/lib/check-tracker-ids.mjs +0 -217
- package/scripts/lib/pre-commit-format.mjs +0 -251
- package/scripts/pre-commit-format.mjs +0 -198
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/pre-commit-format.mjs — pure logic for the auto-format-on-commit hook.
|
|
3
|
-
*
|
|
4
|
-
* Rule source: CLAUDE.md <formatting> directive. Pre-commit hook auto-runs the
|
|
5
|
-
* project formatter on STAGED files only. Formatter failure is non-blocking;
|
|
6
|
-
* only `git add` failure on restage is a true commit block.
|
|
7
|
-
*
|
|
8
|
-
* Why pure: lets tests construct synthetic staged sets without touching real
|
|
9
|
-
* git or invoking prettier. The CLI shim in scripts/pre-commit-format.mjs
|
|
10
|
-
* handles env resolution / repo-identity guards / process exit codes.
|
|
11
|
-
*
|
|
12
|
-
* Env discipline: every `git` spawn here MUST receive a caller-supplied `env`.
|
|
13
|
-
* The lib never reads `process.env` for git operations — that defence is what
|
|
14
|
-
* blocks GIT_DIR / GIT_WORK_TREE override attacks (see CONTRIBUTING.md).
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { spawnSync } from 'node:child_process';
|
|
18
|
-
import { existsSync } from 'node:fs';
|
|
19
|
-
import { join } from 'node:path';
|
|
20
|
-
|
|
21
|
-
const STATUS_FILTER = 'ACMR';
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Parse the NUL-token stream emitted by `git diff --cached --name-status -z`.
|
|
25
|
-
*
|
|
26
|
-
* Token shape (verified live against git 2.50): records are NUL-separated.
|
|
27
|
-
* A\0path\0 (added)
|
|
28
|
-
* M\0path\0 (modified)
|
|
29
|
-
* D\0path\0 (deleted — filtered out by --diff-filter)
|
|
30
|
-
* T\0path\0 (type change — filtered out)
|
|
31
|
-
* R<score>\0old\0new\0 (rename)
|
|
32
|
-
* C<score>\0old\0new\0 (copy)
|
|
33
|
-
*
|
|
34
|
-
* Paths containing TAB are valid — TAB is not a separator here (it appears in
|
|
35
|
-
* the non-`-z` output, never in `-z`). Only NUL separates records.
|
|
36
|
-
*
|
|
37
|
-
* @param {string} buf Raw stdout from `git diff --cached --name-status -z`.
|
|
38
|
-
* @returns {Array<{path: string, status: string}>}
|
|
39
|
-
*/
|
|
40
|
-
export function parseNameStatus(buf) {
|
|
41
|
-
const tokens = buf.split('\0');
|
|
42
|
-
// Trailing NUL leaves an empty token; drop it (and any stray empties).
|
|
43
|
-
while (tokens.length && tokens[tokens.length - 1] === '') tokens.pop();
|
|
44
|
-
const out = [];
|
|
45
|
-
for (let i = 0; i < tokens.length; ) {
|
|
46
|
-
const status = tokens[i++];
|
|
47
|
-
if (!status) continue;
|
|
48
|
-
const head = status[0];
|
|
49
|
-
if (head === 'R' || head === 'C') {
|
|
50
|
-
// Two-path record. Old is irrelevant for formatting — only the new path
|
|
51
|
-
// exists in the staged tree.
|
|
52
|
-
i++; // consume old
|
|
53
|
-
const next = tokens[i++];
|
|
54
|
-
if (next) out.push({ path: next, status: head });
|
|
55
|
-
} else if (head === 'A' || head === 'M') {
|
|
56
|
-
const p = tokens[i++];
|
|
57
|
-
if (p) out.push({ path: p, status: head });
|
|
58
|
-
} else {
|
|
59
|
-
// D, T, U, X — consume one path, drop. --diff-filter should exclude
|
|
60
|
-
// these but we defensively skip.
|
|
61
|
-
i++;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return out;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Parse `git ls-files --stage -z` output to map paths → file mode strings.
|
|
69
|
-
* Output shape: `<mode> <hash> <stage>\t<path>\0`
|
|
70
|
-
*/
|
|
71
|
-
export function parseLsFilesStage(buf) {
|
|
72
|
-
const map = new Map();
|
|
73
|
-
const records = buf.split('\0');
|
|
74
|
-
for (const rec of records) {
|
|
75
|
-
if (!rec) continue;
|
|
76
|
-
const tabIdx = rec.indexOf('\t');
|
|
77
|
-
if (tabIdx < 0) continue;
|
|
78
|
-
const meta = rec.slice(0, tabIdx);
|
|
79
|
-
const path = rec.slice(tabIdx + 1);
|
|
80
|
-
const mode = meta.split(' ')[0];
|
|
81
|
-
map.set(path, mode);
|
|
82
|
-
}
|
|
83
|
-
return map;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Drop symlinks (120000) and gitlinks/submodules (160000). Regular file modes
|
|
88
|
-
* (100644, 100755) are kept.
|
|
89
|
-
*/
|
|
90
|
-
export function filterRegularFiles(entries, modeMap) {
|
|
91
|
-
return entries.filter((e) => {
|
|
92
|
-
const m = modeMap.get(e.path);
|
|
93
|
-
if (!m) return false; // not in index — defensively skip
|
|
94
|
-
return m !== '120000' && m !== '160000';
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Partition staged paths into safe vs partial (also has unstaged hunks).
|
|
100
|
-
* Partial files are skipped to avoid swallowing unstaged work.
|
|
101
|
-
*/
|
|
102
|
-
export function partitionStagedFiles(entries, unstagedDirty) {
|
|
103
|
-
const safe = [];
|
|
104
|
-
const partial = [];
|
|
105
|
-
for (const e of entries) {
|
|
106
|
-
if (unstagedDirty.has(e.path)) partial.push(e);
|
|
107
|
-
else safe.push(e);
|
|
108
|
-
}
|
|
109
|
-
return { safe, partial };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Formatter dispatch table. Other entries (eslint, black, gofmt, cargo fmt)
|
|
114
|
-
* are placeholders — the table is data, not a branch tree. Activate by
|
|
115
|
-
* filling in an entry similar to `prettier`.
|
|
116
|
-
*
|
|
117
|
-
* Critically: NEVER use `npx` here. `npx prettier` may try a network install
|
|
118
|
-
* on a cold machine; we want a local-only binary or no-op.
|
|
119
|
-
*/
|
|
120
|
-
export function selectFormatter(repoRoot) {
|
|
121
|
-
const prettierBin = join(repoRoot, 'node_modules', '.bin', 'prettier');
|
|
122
|
-
if (existsSync(prettierBin)) {
|
|
123
|
-
return {
|
|
124
|
-
name: 'prettier',
|
|
125
|
-
bin: prettierBin,
|
|
126
|
-
buildArgs: (files) => ['--write', '--', ...files],
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Run the formatter once with all safe files. Captures exit status; never
|
|
134
|
-
* throws.
|
|
135
|
-
*/
|
|
136
|
-
export function formatFiles(safe, formatter, { env, cwd } = {}) {
|
|
137
|
-
if (!safe.length || !formatter) {
|
|
138
|
-
return { ran: false, formatterFailed: false, reason: 'noop' };
|
|
139
|
-
}
|
|
140
|
-
const paths = safe.map((e) => e.path);
|
|
141
|
-
const res = spawnSync(formatter.bin, formatter.buildArgs(paths), {
|
|
142
|
-
cwd,
|
|
143
|
-
env,
|
|
144
|
-
encoding: 'utf-8',
|
|
145
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
146
|
-
});
|
|
147
|
-
return {
|
|
148
|
-
ran: true,
|
|
149
|
-
formatterFailed: res.status !== 0,
|
|
150
|
-
exitCode: res.status,
|
|
151
|
-
stdout: res.stdout || '',
|
|
152
|
-
stderr: res.stderr || '',
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Re-stage the formatted files. Returns `{gitAddFailed: bool, stderr}`.
|
|
158
|
-
* Prettier `--write` only writes on actual content change, so re-adding
|
|
159
|
-
* unchanged files is a cheap no-op. Doing it unconditionally avoids a
|
|
160
|
-
* before/after hash comparison.
|
|
161
|
-
*/
|
|
162
|
-
export function restageFormatted(files, { env, cwd } = {}) {
|
|
163
|
-
if (!files.length) return { gitAddFailed: false };
|
|
164
|
-
const res = spawnSync('git', ['add', '--', ...files], {
|
|
165
|
-
cwd,
|
|
166
|
-
env,
|
|
167
|
-
encoding: 'utf-8',
|
|
168
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
169
|
-
});
|
|
170
|
-
return {
|
|
171
|
-
gitAddFailed: res.status !== 0,
|
|
172
|
-
stderr: res.stderr || '',
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Top-level orchestrator used by the CLI shim. The caller supplies `cwd`
|
|
178
|
-
* (the Hypomnema toplevel) and a sanitized `env` (no inherited `GIT_*`
|
|
179
|
-
* except optionally a validated `GIT_INDEX_FILE`).
|
|
180
|
-
*
|
|
181
|
-
* @returns {{gitAddFailed: boolean, summary: string}}
|
|
182
|
-
*/
|
|
183
|
-
export async function runPreCommitFormat({ cwd, env }) {
|
|
184
|
-
const summary = [];
|
|
185
|
-
const stagedRes = spawnSync(
|
|
186
|
-
'git',
|
|
187
|
-
['diff', '--cached', '--name-status', '-z', `--diff-filter=${STATUS_FILTER}`, '--'],
|
|
188
|
-
{ cwd, env, encoding: 'utf-8' },
|
|
189
|
-
);
|
|
190
|
-
if (stagedRes.status !== 0) {
|
|
191
|
-
return { gitAddFailed: false, summary: 'git diff --cached failed; skipping' };
|
|
192
|
-
}
|
|
193
|
-
const staged = parseNameStatus(stagedRes.stdout || '');
|
|
194
|
-
if (!staged.length) return { gitAddFailed: false, summary: 'no staged files' };
|
|
195
|
-
|
|
196
|
-
// Filter out symlinks / submodules.
|
|
197
|
-
const lsRes = spawnSync(
|
|
198
|
-
'git',
|
|
199
|
-
['ls-files', '--stage', '-z', '--', ...staged.map((e) => e.path)],
|
|
200
|
-
{ cwd, env, encoding: 'utf-8' },
|
|
201
|
-
);
|
|
202
|
-
let regular = staged;
|
|
203
|
-
if (lsRes.status === 0) {
|
|
204
|
-
const modeMap = parseLsFilesStage(lsRes.stdout || '');
|
|
205
|
-
regular = filterRegularFiles(staged, modeMap);
|
|
206
|
-
}
|
|
207
|
-
if (!regular.length) return { gitAddFailed: false, summary: 'no regular staged files' };
|
|
208
|
-
|
|
209
|
-
// Unstaged-dirty set for partition.
|
|
210
|
-
const unstRes = spawnSync('git', ['diff', '--name-only', '-z', '--'], {
|
|
211
|
-
cwd,
|
|
212
|
-
env,
|
|
213
|
-
encoding: 'utf-8',
|
|
214
|
-
});
|
|
215
|
-
const unstaged = new Set();
|
|
216
|
-
if (unstRes.status === 0) {
|
|
217
|
-
for (const p of (unstRes.stdout || '').split('\0')) {
|
|
218
|
-
if (p) unstaged.add(p);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
const { safe, partial } = partitionStagedFiles(regular, unstaged);
|
|
222
|
-
if (partial.length) {
|
|
223
|
-
summary.push(`skipped ${partial.length} partially-staged file(s)`);
|
|
224
|
-
}
|
|
225
|
-
if (!safe.length) return { gitAddFailed: false, summary: summary.join('; ') || 'no safe files' };
|
|
226
|
-
|
|
227
|
-
const formatter = selectFormatter(cwd);
|
|
228
|
-
if (!formatter) {
|
|
229
|
-
return {
|
|
230
|
-
gitAddFailed: false,
|
|
231
|
-
summary: [...summary, 'no formatter (node_modules/.bin/prettier missing)'].join('; '),
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
const fmt = formatFiles(safe, formatter, { env, cwd });
|
|
235
|
-
if (fmt.formatterFailed) {
|
|
236
|
-
summary.push(`${formatter.name} exit ${fmt.exitCode} (non-blocking)`);
|
|
237
|
-
return { gitAddFailed: false, summary: summary.join('; ') };
|
|
238
|
-
}
|
|
239
|
-
const restage = restageFormatted(
|
|
240
|
-
safe.map((e) => e.path),
|
|
241
|
-
{ env, cwd },
|
|
242
|
-
);
|
|
243
|
-
if (restage.gitAddFailed) {
|
|
244
|
-
return {
|
|
245
|
-
gitAddFailed: true,
|
|
246
|
-
summary: `git add failed: ${restage.stderr.trim()}`,
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
summary.push(`formatted ${safe.length} file(s) via ${formatter.name}`);
|
|
250
|
-
return { gitAddFailed: false, summary: summary.join('; ') };
|
|
251
|
-
}
|
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* scripts/pre-commit-format.mjs — Node-side entry for the pre-commit hook.
|
|
4
|
-
*
|
|
5
|
-
* Invoked by the shell shim installed at <git-dir>/hooks/pre-commit. The shim
|
|
6
|
-
* already verifies HYPOMNEMA_ROOT + HYPOMNEMA_GIT_DIR; this script is the
|
|
7
|
-
* second layer of identity defence and the only place that may exit nonzero
|
|
8
|
-
* (only when `git add` fails on restage).
|
|
9
|
-
*
|
|
10
|
-
* Env discipline:
|
|
11
|
-
* - probes git twice with ambient env to learn what Git thinks the repo is
|
|
12
|
-
* - builds `cleanEnv` by stripping every name from `git rev-parse --local-env-vars`
|
|
13
|
-
* plus GIT_NAMESPACE / GIT_CEILING_DIRECTORIES / GIT_CONFIG_* (belt-and-suspenders)
|
|
14
|
-
* - validates inherited GIT_INDEX_FILE: preserve if inside HYPOMNEMA_GIT_DIR
|
|
15
|
-
* (Git exports this for commit -am / commit -- path / commit --amend);
|
|
16
|
-
* otherwise refuse — that's a foreign-index attack vector
|
|
17
|
-
* - lib spawns get `cleanEnv` only; lib never touches process.env directly
|
|
18
|
-
*
|
|
19
|
-
* Why dynamic import: keeping the lib import behind every guard makes the
|
|
20
|
-
* fail-open story water-tight. A static import at file head would throw at
|
|
21
|
-
* module load if the lib were missing or syntactically broken, before any
|
|
22
|
-
* identity check could exit 0.
|
|
23
|
-
*
|
|
24
|
-
* Why pathToFileURL for the import: absolute filesystem paths fed to
|
|
25
|
-
* `import()` break when the checkout path contains URL-significant characters
|
|
26
|
-
* (#, %). pathToFileURL is the canonical Node way to bridge.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
try {
|
|
30
|
-
const { execFileSync } = await import('node:child_process');
|
|
31
|
-
const fs = await import('node:fs');
|
|
32
|
-
const path = await import('node:path');
|
|
33
|
-
const { pathToFileURL, fileURLToPath } = await import('node:url');
|
|
34
|
-
|
|
35
|
-
const probe = (args, env) => execFileSync('git', args, { encoding: 'utf8', env }).trim();
|
|
36
|
-
|
|
37
|
-
// (0) Derive expectedRoot from THIS script's filesystem location. The shell
|
|
38
|
-
// shim already verifies HYPOMNEMA_ROOT/HYPOMNEMA_GIT_DIR before exec'ing
|
|
39
|
-
// us, but a direct `node scripts/pre-commit-format.mjs` invocation with
|
|
40
|
-
// hostile GIT_DIR/GIT_WORK_TREE pointing at another repo that ALSO calls
|
|
41
|
-
// itself "hypomnema" would bypass the package.json identity check unless
|
|
42
|
-
// we anchor on the script's own location. import.meta.url cannot be
|
|
43
|
-
// redirected by ambient env.
|
|
44
|
-
let expectedRoot;
|
|
45
|
-
try {
|
|
46
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
47
|
-
expectedRoot = fs.realpathSync(path.resolve(here, '..'));
|
|
48
|
-
} catch {
|
|
49
|
-
process.exit(0);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// (1) Probe with ambient env to learn what Git thinks the repo is.
|
|
53
|
-
let toplevel, absGitDir, commonDir;
|
|
54
|
-
try {
|
|
55
|
-
toplevel = probe(['rev-parse', '--show-toplevel'], process.env);
|
|
56
|
-
absGitDir = probe(['rev-parse', '--absolute-git-dir'], process.env);
|
|
57
|
-
commonDir = probe(['rev-parse', '--git-common-dir'], process.env);
|
|
58
|
-
} catch {
|
|
59
|
-
process.exit(0);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// (2) Realpath-resolve for stable comparison.
|
|
63
|
-
let absGitDirR, commonDirR, toplevelR;
|
|
64
|
-
try {
|
|
65
|
-
absGitDirR = fs.realpathSync(absGitDir);
|
|
66
|
-
const cdAbs = path.isAbsolute(commonDir) ? commonDir : path.join(absGitDir, '..', commonDir);
|
|
67
|
-
commonDirR = fs.realpathSync(cdAbs);
|
|
68
|
-
toplevelR = fs.realpathSync(toplevel);
|
|
69
|
-
} catch {
|
|
70
|
-
process.exit(0);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// (3) Trust anchor — refuse to run against any toplevel other than this
|
|
74
|
-
// script's own checkout. Closes the GIT_DIR/GIT_WORK_TREE attack where a
|
|
75
|
-
// foreign hypomnema-named repo would otherwise pass the package.json check.
|
|
76
|
-
if (toplevelR !== expectedRoot) process.exit(0);
|
|
77
|
-
|
|
78
|
-
// (3a) Anchor the git dir to the expected location too. Without this, a
|
|
79
|
-
// mixed-env attack — GIT_DIR=/foreign/.git + GIT_WORK_TREE=expectedRoot
|
|
80
|
-
// + GIT_INDEX_FILE=/foreign/.git/index — would let `absGitDirR` point at
|
|
81
|
-
// the foreign repo while `--show-toplevel` reports expectedRoot. The
|
|
82
|
-
// subsequent GIT_INDEX_FILE check would then pass relative to the
|
|
83
|
-
// foreign git dir, and the lib would operate on a foreign index while
|
|
84
|
-
// mutating real files. (Live-verified by codex round 7.)
|
|
85
|
-
let expectedGitDirR;
|
|
86
|
-
try {
|
|
87
|
-
expectedGitDirR = fs.realpathSync(path.join(expectedRoot, '.git'));
|
|
88
|
-
} catch {
|
|
89
|
-
process.exit(0);
|
|
90
|
-
}
|
|
91
|
-
if (absGitDirR !== expectedGitDirR) process.exit(0);
|
|
92
|
-
|
|
93
|
-
// (4) Linked worktree → main-worktree only (documented limitation).
|
|
94
|
-
if (absGitDirR !== commonDirR) process.exit(0);
|
|
95
|
-
|
|
96
|
-
// (5) Repo identity check — package.json name must be "hypomnema" (defence in
|
|
97
|
-
// depth alongside the expectedRoot anchor above).
|
|
98
|
-
const pkgPath = path.join(toplevelR, 'package.json');
|
|
99
|
-
if (!fs.existsSync(pkgPath)) process.exit(0);
|
|
100
|
-
let pkg;
|
|
101
|
-
try {
|
|
102
|
-
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
103
|
-
} catch {
|
|
104
|
-
process.exit(0);
|
|
105
|
-
}
|
|
106
|
-
if (pkg?.name !== 'hypomnema') process.exit(0);
|
|
107
|
-
|
|
108
|
-
// (5) Build trusted env from --local-env-vars + GIT_CONFIG_* belt.
|
|
109
|
-
let localEnvList;
|
|
110
|
-
try {
|
|
111
|
-
localEnvList = probe(['rev-parse', '--local-env-vars'], process.env)
|
|
112
|
-
.split(/\r?\n/)
|
|
113
|
-
.filter(Boolean);
|
|
114
|
-
} catch {
|
|
115
|
-
// Static fallback (Git versions where --local-env-vars is unavailable).
|
|
116
|
-
localEnvList = [
|
|
117
|
-
'GIT_DIR',
|
|
118
|
-
'GIT_WORK_TREE',
|
|
119
|
-
'GIT_INDEX_FILE',
|
|
120
|
-
'GIT_OBJECT_DIRECTORY',
|
|
121
|
-
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
122
|
-
'GIT_COMMON_DIR',
|
|
123
|
-
'GIT_CONFIG',
|
|
124
|
-
'GIT_CONFIG_PARAMETERS',
|
|
125
|
-
'GIT_PREFIX',
|
|
126
|
-
'GIT_IMPLICIT_WORK_TREE',
|
|
127
|
-
'GIT_GRAFT_FILE',
|
|
128
|
-
'GIT_NO_REPLACE_OBJECTS',
|
|
129
|
-
'GIT_REPLACE_REF_BASE',
|
|
130
|
-
'GIT_SHALLOW_FILE',
|
|
131
|
-
];
|
|
132
|
-
}
|
|
133
|
-
const scrub = new Set([
|
|
134
|
-
...localEnvList,
|
|
135
|
-
'GIT_NAMESPACE',
|
|
136
|
-
'GIT_CEILING_DIRECTORIES',
|
|
137
|
-
...Object.keys(process.env).filter((k) => /^GIT_CONFIG_/.test(k)),
|
|
138
|
-
]);
|
|
139
|
-
const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => !scrub.has(k)));
|
|
140
|
-
|
|
141
|
-
// (6) GIT_INDEX_FILE preservation gated on shim invocation. Git legitimately
|
|
142
|
-
// exports this for these commit shapes (verified live by codex round 5/9
|
|
143
|
-
// on Git 2.50.1):
|
|
144
|
-
// - .git/index normal commit, commit --amend, merge commit
|
|
145
|
-
// - .git/index.lock commit -am, commit -p, commit --interactive
|
|
146
|
-
// - .git/next-index-*.lock commit -- <pathspec>, rebase partial commit
|
|
147
|
-
// The basename whitelist used in v8/v9 had a residual gap: a crafted
|
|
148
|
-
// .git/next-index-attack.lock matches the `next-index-*` prefix even
|
|
149
|
-
// though Git itself uses `next-index-<pid>.lock` (codex round 9 live
|
|
150
|
-
// replay). Closing that prefix gap by tightening pattern just invites
|
|
151
|
-
// more attacker iteration.
|
|
152
|
-
//
|
|
153
|
-
// The cleaner defence: only honour inherited GIT_INDEX_FILE when we
|
|
154
|
-
// were invoked from our own trusted shell shim (HYPOMNEMA_HOOK_INVOCATION
|
|
155
|
-
// sentinel set there). Direct invocation — which is the only path an
|
|
156
|
-
// attacker can use to plant a crafted index — drops the inherited value
|
|
157
|
-
// and lets git fall back to the default `.git/index`, i.e. the real
|
|
158
|
-
// staged set. The hook then either has nothing to do (no real stage) or
|
|
159
|
-
// formats the real stage (correct behaviour). An attacker that can also
|
|
160
|
-
// set HYPOMNEMA_HOOK_INVOCATION already has full env control and can
|
|
161
|
-
// mutate files directly without going through the hook gadget.
|
|
162
|
-
const fromShim = process.env.HYPOMNEMA_HOOK_INVOCATION === '1';
|
|
163
|
-
if (fromShim && process.env.GIT_INDEX_FILE) {
|
|
164
|
-
// Even when trusted, sanity-check that the path lives inside our git dir.
|
|
165
|
-
// Belt-and-suspenders against shim invocation with an inherited but
|
|
166
|
-
// misdirected GIT_INDEX_FILE (e.g. by a wrapper that exec'd our shim).
|
|
167
|
-
const inherited = process.env.GIT_INDEX_FILE;
|
|
168
|
-
const lexical = path.isAbsolute(inherited) ? inherited : path.join(toplevelR, inherited);
|
|
169
|
-
let absIdx;
|
|
170
|
-
try {
|
|
171
|
-
absIdx = fs.realpathSync(lexical);
|
|
172
|
-
} catch {
|
|
173
|
-
absIdx = path.resolve(lexical);
|
|
174
|
-
}
|
|
175
|
-
if (path.dirname(absIdx) === absGitDirR) {
|
|
176
|
-
cleanEnv.GIT_INDEX_FILE = inherited;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
// If !fromShim, GIT_INDEX_FILE stays scrubbed → lib uses default .git/index.
|
|
180
|
-
|
|
181
|
-
// (7) Dynamic-import the lib through a file:// URL so checkout paths with
|
|
182
|
-
// URL-significant chars (#, %) don't break ESM resolution. We import
|
|
183
|
-
// from expectedRoot, not toplevel — the script's own location is the
|
|
184
|
-
// anchor of trust.
|
|
185
|
-
const libPath = path.join(expectedRoot, 'scripts/lib/pre-commit-format.mjs');
|
|
186
|
-
let lib;
|
|
187
|
-
try {
|
|
188
|
-
lib = await import(pathToFileURL(libPath).href);
|
|
189
|
-
} catch {
|
|
190
|
-
process.exit(0);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const result = await lib.runPreCommitFormat({ cwd: toplevelR, env: cleanEnv });
|
|
194
|
-
if (result.summary) process.stderr.write(`[pre-commit-format] ${result.summary}\n`);
|
|
195
|
-
process.exit(result.gitAddFailed ? 1 : 0);
|
|
196
|
-
} catch {
|
|
197
|
-
process.exit(0);
|
|
198
|
-
}
|