hypomnema 1.2.1 → 1.3.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/commands/crystallize.md +23 -6
- package/commands/feedback.md +1 -1
- package/docs/CONTRIBUTING.md +96 -11
- package/hooks/hypo-auto-commit.mjs +3 -3
- package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
- package/hooks/hypo-cwd-change.mjs +2 -2
- package/hooks/hypo-first-prompt.mjs +1 -1
- package/hooks/hypo-personal-check.mjs +57 -7
- package/hooks/hypo-session-start.mjs +51 -4
- package/hooks/hypo-shared.mjs +137 -12
- package/hooks/version-check.mjs +204 -6
- package/package.json +5 -2
- package/scripts/bump-version.mjs +9 -3
- package/scripts/check-bilingual.mjs +115 -0
- package/scripts/crystallize.mjs +124 -15
- package/scripts/doctor.mjs +45 -9
- package/scripts/feedback-sync.mjs +44 -15
- package/scripts/feedback.mjs +5 -5
- package/scripts/fix-status-verify.mjs +256 -0
- package/scripts/init.mjs +45 -4
- package/scripts/install-git-hooks.mjs +258 -0
- package/scripts/lib/adr-corpus.mjs +79 -0
- package/scripts/lib/check-bilingual.mjs +141 -0
- package/scripts/lib/extensions.mjs +3 -3
- package/scripts/lib/feedback-scope.mjs +21 -0
- package/scripts/lib/fix-manifest.mjs +109 -0
- package/scripts/lib/fix-status-verify.mjs +438 -0
- package/scripts/lib/pre-commit-format.mjs +251 -0
- package/scripts/lib/project-create.mjs +2 -2
- package/scripts/lint.mjs +48 -8
- package/scripts/pre-commit-format.mjs +198 -0
- package/scripts/smoke-pack.mjs +16 -0
- package/scripts/upgrade.mjs +55 -23
- package/skills/crystallize/SKILL.md +13 -2
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +4 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* fix-status-verify (CLI) — verify fix→test linkage + ADR-line evidence
|
|
4
|
+
* against wiki spec claims.
|
|
5
|
+
*
|
|
6
|
+
* Phase 1: test-green half (anchors × spec status × runner results).
|
|
7
|
+
* Phase 2 (A-sot): manifest validation + manifest↔anchor drift + ADR core
|
|
8
|
+
* decision grep against the production corpus. See scripts/lib/fix-manifest.mjs
|
|
9
|
+
* and scripts/lib/fix-status-verify.mjs headers.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/fix-status-verify.mjs [--hypo-dir <path>]
|
|
13
|
+
* [--spec <path>]
|
|
14
|
+
* [--runner <path>]
|
|
15
|
+
* [--test-command "<cmd>"]
|
|
16
|
+
* [--json]
|
|
17
|
+
*
|
|
18
|
+
* Exit 0 if no error-level findings, 1 otherwise.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
22
|
+
import { join, dirname, resolve } from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
26
|
+
import {
|
|
27
|
+
parseAnchors,
|
|
28
|
+
parseStatus,
|
|
29
|
+
parseRunnerOutput,
|
|
30
|
+
verifyMatrix,
|
|
31
|
+
isReferenceStub,
|
|
32
|
+
validateManifest,
|
|
33
|
+
checkManifestCoverage,
|
|
34
|
+
checkAdrLines,
|
|
35
|
+
FIX_MANIFEST,
|
|
36
|
+
NO_ADR,
|
|
37
|
+
} from './lib/fix-status-verify.mjs';
|
|
38
|
+
import { buildCorpusSearch } from './lib/adr-corpus.mjs';
|
|
39
|
+
|
|
40
|
+
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
|
|
42
|
+
// Production-code corpus for the ADR-line grep (spec §A amendment 2026-06-07:
|
|
43
|
+
// templates/ ships via npm `files`, so prompt-driven fixes are verifiable).
|
|
44
|
+
const CORPUS_DIRS = ['scripts', 'hooks', 'commands', 'skills', 'templates'];
|
|
45
|
+
// MUST exclude the manifest itself — it holds every adrKeyLine as a literal and
|
|
46
|
+
// would self-match, making ADR_LINE_MISSING impossible to ever fire.
|
|
47
|
+
const CORPUS_EXCLUDE = ['scripts/lib/fix-manifest.mjs'];
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
const out = {
|
|
51
|
+
hypoDir: process.env.HYPO_DIR || join(homedir(), 'hypomnema'),
|
|
52
|
+
spec: null,
|
|
53
|
+
runner: join(REPO, 'tests/runner.mjs'),
|
|
54
|
+
testCommand: 'npm test',
|
|
55
|
+
json: false,
|
|
56
|
+
manifest: null,
|
|
57
|
+
};
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
const a = argv[i];
|
|
60
|
+
if (a === '--hypo-dir') out.hypoDir = argv[++i];
|
|
61
|
+
else if (a === '--spec') out.spec = argv[++i];
|
|
62
|
+
else if (a === '--runner') out.runner = argv[++i];
|
|
63
|
+
else if (a === '--test-command') out.testCommand = argv[++i];
|
|
64
|
+
else if (a === '--manifest') out.manifest = argv[++i];
|
|
65
|
+
else if (a === '--json') out.json = true;
|
|
66
|
+
else if (a === '--help' || a === '-h') {
|
|
67
|
+
printHelp();
|
|
68
|
+
process.exit(0);
|
|
69
|
+
} else {
|
|
70
|
+
console.error(`unknown arg: ${a}`);
|
|
71
|
+
process.exit(2);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!out.spec) {
|
|
75
|
+
out.spec = join(out.hypoDir, 'projects/hypomnema/spec-v1.2.md');
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function printHelp() {
|
|
81
|
+
console.log(
|
|
82
|
+
[
|
|
83
|
+
'fix-status-verify — Phase 1 (test-green half) of learned_behavior #6',
|
|
84
|
+
'',
|
|
85
|
+
'Options:',
|
|
86
|
+
' --hypo-dir <path> Wiki root (default: $HYPO_DIR or ~/hypomnema)',
|
|
87
|
+
' --spec <path> Override spec-v1.2.md path',
|
|
88
|
+
' --runner <path> Override tests/runner.mjs path',
|
|
89
|
+
' --test-command "<cmd>" Test invocation (default: "npm test")',
|
|
90
|
+
' --json Emit machine-readable JSON report',
|
|
91
|
+
'',
|
|
92
|
+
'Exit 0 if no error findings, 1 otherwise.',
|
|
93
|
+
'',
|
|
94
|
+
'NOTE: The default --spec is a `type: reference` redirect stub (the real',
|
|
95
|
+
'spec moved to archive/). Running without --spec fails with STUB_SPEC by',
|
|
96
|
+
'design — pass --spec <real spec> to verify against actual claims.',
|
|
97
|
+
'',
|
|
98
|
+
'Phase 2 (A-sot): also greps each manifest adrKeyLine against the',
|
|
99
|
+
'production corpus (scripts/ hooks/ commands/ skills/ templates/) and',
|
|
100
|
+
'checks manifest↔anchor drift. NO_ADR rows skip the grep (test-green only).',
|
|
101
|
+
].join('\n'),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function runTests(testCommand) {
|
|
106
|
+
// Parse simple command (no shell metacharacters supported in args; this is
|
|
107
|
+
// a maintainer tool, not a security boundary).
|
|
108
|
+
const parts = testCommand.split(/\s+/).filter(Boolean);
|
|
109
|
+
const [cmd, ...args] = parts;
|
|
110
|
+
const result = spawnSync(cmd, args, {
|
|
111
|
+
cwd: REPO,
|
|
112
|
+
encoding: 'utf-8',
|
|
113
|
+
env: { ...process.env, FORCE_COLOR: '0' },
|
|
114
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
stdout: result.stdout || '',
|
|
118
|
+
stderr: result.stderr || '',
|
|
119
|
+
exitCode: result.status,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function formatFinding(f) {
|
|
124
|
+
const icon = f.level === 'error' ? '✗' : '⚠';
|
|
125
|
+
// Some findings are not tied to a specific fix # (STUB_SPEC,
|
|
126
|
+
// TEST_RUN_NONZERO_EXIT). Only render the `fix #N` segment when present so
|
|
127
|
+
// they don't print `fix #undefined`.
|
|
128
|
+
const ref = f.fixNum != null ? ` fix #${f.fixNum}` : '';
|
|
129
|
+
return ` ${icon} [${f.class}]${ref}` + (f.testName ? ` (${f.testName})` : '') + `: ${f.detail}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function main() {
|
|
133
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
134
|
+
|
|
135
|
+
// Manifest source: built-in code constant by default (ADR 0036). --manifest
|
|
136
|
+
// <path.mjs> overrides for tests, which inject a fixture manifest matching
|
|
137
|
+
// their synthetic fixes so the real manifest does not couple to fixtures.
|
|
138
|
+
let manifest = FIX_MANIFEST;
|
|
139
|
+
if (opts.manifest) {
|
|
140
|
+
if (!existsSync(opts.manifest)) {
|
|
141
|
+
console.error(`manifest not found: ${opts.manifest}`);
|
|
142
|
+
process.exit(2);
|
|
143
|
+
}
|
|
144
|
+
const mod = await import(pathToFileURL(resolve(opts.manifest)).href);
|
|
145
|
+
manifest = mod.FIX_MANIFEST;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!existsSync(opts.spec)) {
|
|
149
|
+
console.error(`spec not found: ${opts.spec}`);
|
|
150
|
+
console.error('hint: pass --hypo-dir <path> or set $HYPO_DIR');
|
|
151
|
+
process.exit(2);
|
|
152
|
+
}
|
|
153
|
+
if (!existsSync(opts.runner)) {
|
|
154
|
+
console.error(`runner not found: ${opts.runner}`);
|
|
155
|
+
process.exit(2);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const specText = readFileSync(opts.spec, 'utf-8');
|
|
159
|
+
const runnerText = readFileSync(opts.runner, 'utf-8');
|
|
160
|
+
|
|
161
|
+
const anchors = parseAnchors(runnerText);
|
|
162
|
+
const status = parseStatus(specText);
|
|
163
|
+
const specIsStub = isReferenceStub(specText);
|
|
164
|
+
|
|
165
|
+
if (!opts.json) {
|
|
166
|
+
console.log(`fix-status-verify (Phase 1)`);
|
|
167
|
+
console.log(` spec: ${opts.spec}`);
|
|
168
|
+
console.log(` runner: ${opts.runner}`);
|
|
169
|
+
console.log(` ${status.size} positive status claim(s), ${anchors.size} anchor(s)`);
|
|
170
|
+
console.log(` running: ${opts.testCommand}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const testRun = runTests(opts.testCommand);
|
|
174
|
+
const testResults = parseRunnerOutput(testRun.stdout + '\n' + testRun.stderr);
|
|
175
|
+
|
|
176
|
+
if (!opts.json) {
|
|
177
|
+
const passes = [...testResults.values()].filter((v) => v === 'pass').length;
|
|
178
|
+
const fails = [...testResults.values()].filter((v) => v === 'fail').length;
|
|
179
|
+
console.log(` test run: ${passes} pass, ${fails} fail (exit ${testRun.exitCode})`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const matrixResult = verifyMatrix({ anchors, status, testResults, specIsStub });
|
|
183
|
+
const findings = [...matrixResult.findings];
|
|
184
|
+
|
|
185
|
+
// Phase 2 (A-sot): manifest validation + ADR-line grep. validateManifest and
|
|
186
|
+
// checkAdrLines are spec-independent (manifest/code health) and run always;
|
|
187
|
+
// checkManifestCoverage keys off the spec status (a no-op under STUB_SPEC,
|
|
188
|
+
// where status is empty).
|
|
189
|
+
const needsCorpus = manifest.some((r) => r.adrKeyLine !== NO_ADR);
|
|
190
|
+
const adrSearch = needsCorpus
|
|
191
|
+
? buildCorpusSearch({ repoRoot: REPO, includeDirs: CORPUS_DIRS, excludePaths: CORPUS_EXCLUDE })
|
|
192
|
+
: () => false;
|
|
193
|
+
const adrExists = (adrPath) => existsSync(join(opts.hypoDir, 'projects/hypomnema', adrPath));
|
|
194
|
+
findings.push(...validateManifest(manifest));
|
|
195
|
+
findings.push(...checkManifestCoverage({ manifest, anchors, status }));
|
|
196
|
+
findings.push(...checkAdrLines({ manifest, searchFn: adrSearch, adrExistsFn: adrExists }));
|
|
197
|
+
|
|
198
|
+
// CLI-level error: if the test command itself exited nonzero, the test run
|
|
199
|
+
// is not green even if the anchored tests happen to all pass in the parsed
|
|
200
|
+
// output. Surface as a synthetic error finding so `ok` flips false.
|
|
201
|
+
if (testRun.exitCode !== 0) {
|
|
202
|
+
findings.push({
|
|
203
|
+
level: 'error',
|
|
204
|
+
class: 'TEST_RUN_NONZERO_EXIT',
|
|
205
|
+
detail: `test command "${opts.testCommand}" exited ${testRun.exitCode}`,
|
|
206
|
+
exitCode: testRun.exitCode,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const ok = !findings.some((f) => f.level === 'error') && testRun.exitCode === 0;
|
|
210
|
+
|
|
211
|
+
const MANDATORY_NOTE =
|
|
212
|
+
'test-linkage + green + ADR-line grep (Phase 2): manifest evidence checked against production corpus';
|
|
213
|
+
|
|
214
|
+
if (opts.json) {
|
|
215
|
+
console.log(
|
|
216
|
+
JSON.stringify(
|
|
217
|
+
{
|
|
218
|
+
ok,
|
|
219
|
+
spec: opts.spec,
|
|
220
|
+
runner: opts.runner,
|
|
221
|
+
statusClaims: status.size,
|
|
222
|
+
anchorCount: anchors.size,
|
|
223
|
+
testsRan: testResults.size,
|
|
224
|
+
testExitCode: testRun.exitCode,
|
|
225
|
+
findings,
|
|
226
|
+
note: MANDATORY_NOTE,
|
|
227
|
+
},
|
|
228
|
+
null,
|
|
229
|
+
2,
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
} else {
|
|
233
|
+
const errors = findings.filter((f) => f.level === 'error');
|
|
234
|
+
const warns = findings.filter((f) => f.level === 'warn');
|
|
235
|
+
if (errors.length === 0 && warns.length === 0) {
|
|
236
|
+
console.log(` ✓ all ${status.size} claimed-merged fix(es) verified`);
|
|
237
|
+
} else {
|
|
238
|
+
if (errors.length) {
|
|
239
|
+
console.log(`\nerrors (${errors.length}):`);
|
|
240
|
+
for (const f of errors) console.log(formatFinding(f));
|
|
241
|
+
}
|
|
242
|
+
if (warns.length) {
|
|
243
|
+
console.log(`\nwarnings (${warns.length}):`);
|
|
244
|
+
for (const f of warns) console.log(formatFinding(f));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
console.log(`\n(${MANDATORY_NOTE})`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
process.exit(ok ? 0 : 1);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
main().catch((e) => {
|
|
254
|
+
console.error(e);
|
|
255
|
+
process.exit(2);
|
|
256
|
+
});
|
package/scripts/init.mjs
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
readFileIfRegular,
|
|
47
47
|
} from './lib/pkg-json.mjs';
|
|
48
48
|
import { syncExtensions } from './lib/extensions.mjs';
|
|
49
|
+
import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
|
|
49
50
|
|
|
50
51
|
const HOME = homedir();
|
|
51
52
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -108,6 +109,7 @@ function parseArgs(argv) {
|
|
|
108
109
|
fromRemote: null,
|
|
109
110
|
shellSetup: true,
|
|
110
111
|
shellConfig: null,
|
|
112
|
+
allowDowngrade: false,
|
|
111
113
|
};
|
|
112
114
|
for (const arg of argv.slice(2)) {
|
|
113
115
|
if (arg === '--help' || arg === '-h') {
|
|
@@ -136,6 +138,8 @@ Init options:
|
|
|
136
138
|
--from-remote=<url> Clone existing Hypomnema wiki from remote and install hooks
|
|
137
139
|
--no-shell Skip shell function setup (~/.zshrc / ~/.bashrc)
|
|
138
140
|
--shell-config=<path> Shell config file path (default: auto-detect)
|
|
141
|
+
--allow-downgrade Override the guard that refuses to overwrite NEWER active
|
|
142
|
+
hooks with an older package (stale-PATH-CLI footgun)
|
|
139
143
|
--dry-run Show what would be done without making changes
|
|
140
144
|
--help, -h Show this help message
|
|
141
145
|
|
|
@@ -160,6 +164,7 @@ docstring at the top of scripts/<command>.mjs.`);
|
|
|
160
164
|
} else if (arg === '--dry-run') args.dryRun = true;
|
|
161
165
|
else if (arg === '--no-shell') args.shellSetup = false;
|
|
162
166
|
else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
|
|
167
|
+
else if (arg === '--allow-downgrade') args.allowDowngrade = true;
|
|
163
168
|
}
|
|
164
169
|
return args;
|
|
165
170
|
}
|
|
@@ -419,6 +424,27 @@ function pkgJsonPath() {
|
|
|
419
424
|
return join(HOME, '.claude', 'hypo-pkg.json');
|
|
420
425
|
}
|
|
421
426
|
|
|
427
|
+
/**
|
|
428
|
+
* ADR 0038 (P): abort when this package would DOWNGRADE a newer active install.
|
|
429
|
+
* Exit 2 (distinct from the generic exit-1 error class) on refusal so callers and
|
|
430
|
+
* tests can tell "refused downgrade" apart from "init failed". No-ops on a fresh
|
|
431
|
+
* install (no metadata), unparseable versions, the same package re-running itself,
|
|
432
|
+
* --allow-downgrade, or --dry-run.
|
|
433
|
+
*/
|
|
434
|
+
function maybeRefuseDowngrade(op, allowDowngrade, dryRun) {
|
|
435
|
+
if (allowDowngrade || dryRun) return;
|
|
436
|
+
const active = readPkgJsonSafe(pkgJsonPath());
|
|
437
|
+
if (!active || !active.pkgVersion || !PKG_VERSION) return;
|
|
438
|
+
const verdict = classifyInstall(
|
|
439
|
+
{ pkgRoot: PKG_ROOT, version: PKG_VERSION },
|
|
440
|
+
{ pkgRoot: active.pkgRoot, version: active.pkgVersion },
|
|
441
|
+
);
|
|
442
|
+
if (verdict === 'downgrade') {
|
|
443
|
+
console.error(downgradeGuardMessage(PKG_VERSION, active.pkgVersion, op));
|
|
444
|
+
process.exit(2);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
422
448
|
function writePkgJson(dryRun, extraFields = {}) {
|
|
423
449
|
const dest = pkgJsonPath();
|
|
424
450
|
const existing = readPkgJsonSafe(dest);
|
|
@@ -777,6 +803,17 @@ const args = parseArgs(process.argv);
|
|
|
777
803
|
// Validate hooks.json before any file writes so a bad package leaves no partial state
|
|
778
804
|
const HOOK_MAP = args.hooks || args.codex ? loadHookMap() : null;
|
|
779
805
|
|
|
806
|
+
// Downgrade guard (ADR 0038, P): refuse to overwrite a NEWER active install with
|
|
807
|
+
// an older package. The footgun: a stale `npm i -g hypomnema` owns the `hypomnema`
|
|
808
|
+
// bin on PATH, so `hypomnema init` runs the OLD package and silently downgrades
|
|
809
|
+
// the newer install (dropping features like the update-notifier). Runs
|
|
810
|
+
// UNCONDITIONALLY before the first write — `--no-hooks --no-commands` is NOT safe:
|
|
811
|
+
// init still installs the wiki pre-commit hook (repointed to the stale pkgRoot)
|
|
812
|
+
// and, with `--codex`, writes ~/.codex hooks/settings. The guard no-ops on a fresh
|
|
813
|
+
// install, same realpath'd pkgRoot (dev re-run / npm-link / post-commit sync),
|
|
814
|
+
// --allow-downgrade, or --dry-run, so legitimate flows are unaffected.
|
|
815
|
+
maybeRefuseDowngrade('init', args.allowDowngrade, args.dryRun);
|
|
816
|
+
|
|
780
817
|
if (args.fromRemote) {
|
|
781
818
|
// ── from-remote path: clone → read config → install hooks ──────────────────
|
|
782
819
|
const cloned = cloneFromRemote(args.fromRemote, args.hypoDir, args.dryRun);
|
|
@@ -863,9 +900,13 @@ if (args.hooks) {
|
|
|
863
900
|
mergeSettingsJson(join(HOME, '.claude', 'settings.json'), claudeHooks, args.dryRun, HOOK_MAP);
|
|
864
901
|
}
|
|
865
902
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
903
|
+
// Always record the active install identity (pkgRoot + pkgVersion), even for a
|
|
904
|
+
// --no-hooks --no-commands scaffold. That flow still installs the wiki pre-commit
|
|
905
|
+
// hook (and, with --codex, ~/.codex hooks); without a recorded pkgVersion baseline
|
|
906
|
+
// the ADR 0038 downgrade guard has nothing to compare against, so a later stale
|
|
907
|
+
// sibling could repoint those surfaces unguarded. writePkgJson merges (...existing),
|
|
908
|
+
// so this never clobbers a commands/extensions map written elsewhere.
|
|
909
|
+
writePkgJson(args.dryRun, commandSHAs ? { commands: commandSHAs } : {});
|
|
869
910
|
|
|
870
911
|
// 4b. user extensions companion sync (ADR 0024). Runs after
|
|
871
912
|
// writePkgJson so the per-target SHA map is merged into the same hypo-pkg.json
|
|
@@ -887,7 +928,7 @@ if (args.hooks) {
|
|
|
887
928
|
}
|
|
888
929
|
for (const r of extResult.registered) log('merged', `extension ${r}`);
|
|
889
930
|
for (const w of extResult.warnings) log('skipped', `extension: ${w}`);
|
|
890
|
-
// E3 (#31): a hard conflict (unowned/symlinked target) blocks install — surface
|
|
931
|
+
// E3 (fix #31): a hard conflict (unowned/symlinked target) blocks install — surface
|
|
891
932
|
// the recovery and force a non-zero exit. Drift is advisory (resolvable, no block).
|
|
892
933
|
if (extResult.conflicts.length > 0) {
|
|
893
934
|
log('errors', '[WIKI: existing file conflicts. Backup and retry, or use --force-extensions]');
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/install-git-hooks.mjs — idempotent git pre-commit hook installer.
|
|
4
|
+
*
|
|
5
|
+
* Wired into package.json as the `prepare` script so it runs after every
|
|
6
|
+
* `npm install` / `npm ci` in this checkout. The installer is FULLY fail-open:
|
|
7
|
+
* any filesystem or git error → silent exit 0. The CLAUDE.md formatter rule
|
|
8
|
+
* is best-effort; a failed install must never block contributor onboarding.
|
|
9
|
+
*
|
|
10
|
+
* Trust model:
|
|
11
|
+
* - `expectedRoot` is derived from THIS script's filesystem location (via
|
|
12
|
+
* `import.meta.url` → realpath), NOT from `git rev-parse`. Git probes
|
|
13
|
+
* can be redirected by ambient GIT_DIR/GIT_WORK_TREE; the script's own
|
|
14
|
+
* path cannot.
|
|
15
|
+
* - All git probes use a scrubbed env (every name from `--local-env-vars`
|
|
16
|
+
* plus GIT_NAMESPACE / GIT_CEILING_DIRECTORIES / GIT_CONFIG_*).
|
|
17
|
+
* - Probes run with `cwd: expectedRoot` so npm `--prefix` invocations
|
|
18
|
+
* can't redirect resolution either.
|
|
19
|
+
* - Generated shim embeds `HYPOMNEMA_ROOT` + `HYPOMNEMA_GIT_DIR`. Runtime
|
|
20
|
+
* shim refuses to exec unless both literals match the current values.
|
|
21
|
+
*
|
|
22
|
+
* Refusal conditions (all exit 0):
|
|
23
|
+
* - `CI=true` env (npm ci on CI runs prepare; we must not touch hooks)
|
|
24
|
+
* - `npm_command` in {pack, publish} or `npm_lifecycle_event=prepublishOnly`
|
|
25
|
+
* - `.git/` absent (consumer install of the published tarball)
|
|
26
|
+
* - linked worktree (--absolute-git-dir != --git-common-dir)
|
|
27
|
+
* - toplevel != expectedRoot
|
|
28
|
+
* - hooks dir / pre-commit file is a symlink
|
|
29
|
+
* - existing non-marker pre-commit (don't clobber the user's own hook)
|
|
30
|
+
* - any filesystem error (ENOENT, EPERM, EACCES, …)
|
|
31
|
+
*
|
|
32
|
+
* Verbose mode: set HYPOMNEMA_HOOK_VERBOSE=1 to see skip/install reasons.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { execFileSync } from 'node:child_process';
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import url from 'node:url';
|
|
39
|
+
|
|
40
|
+
function exitSilent(msg) {
|
|
41
|
+
if (process.env.HYPOMNEMA_HOOK_VERBOSE === '1') {
|
|
42
|
+
process.stderr.write(`[install-git-hooks] ${msg}\n`);
|
|
43
|
+
}
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Static fallback list for `--local-env-vars`. Used when we don't yet have a
|
|
48
|
+
// trusted git invocation (the installer is bootstrapping its own trust chain),
|
|
49
|
+
// and also when git is too old to support `--local-env-vars`.
|
|
50
|
+
const STATIC_LOCAL_ENV_VARS = [
|
|
51
|
+
'GIT_DIR',
|
|
52
|
+
'GIT_WORK_TREE',
|
|
53
|
+
'GIT_INDEX_FILE',
|
|
54
|
+
'GIT_OBJECT_DIRECTORY',
|
|
55
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
56
|
+
'GIT_COMMON_DIR',
|
|
57
|
+
'GIT_CONFIG',
|
|
58
|
+
'GIT_CONFIG_PARAMETERS',
|
|
59
|
+
'GIT_PREFIX',
|
|
60
|
+
'GIT_IMPLICIT_WORK_TREE',
|
|
61
|
+
'GIT_GRAFT_FILE',
|
|
62
|
+
'GIT_NO_REPLACE_OBJECTS',
|
|
63
|
+
'GIT_REPLACE_REF_BASE',
|
|
64
|
+
'GIT_SHALLOW_FILE',
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
function buildScrubbedEnv(localEnvList) {
|
|
68
|
+
const scrub = new Set([
|
|
69
|
+
...(localEnvList || STATIC_LOCAL_ENV_VARS),
|
|
70
|
+
'GIT_NAMESPACE',
|
|
71
|
+
'GIT_CEILING_DIRECTORIES',
|
|
72
|
+
...Object.keys(process.env).filter((k) => /^GIT_CONFIG_/.test(k)),
|
|
73
|
+
]);
|
|
74
|
+
return Object.fromEntries(Object.entries(process.env).filter(([k]) => !scrub.has(k)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function shellSingleQuote(s) {
|
|
78
|
+
// POSIX-safe: 'x' → 'x', x'y → 'x'\''y'
|
|
79
|
+
return `'` + s.replace(/'/g, `'\\''`) + `'`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function shimBody(root, gitDir) {
|
|
83
|
+
return `#!/bin/sh
|
|
84
|
+
# hypomnema-pre-commit-marker v1
|
|
85
|
+
# Fail-open at every guard. Only the .mjs (after identity checks) can exit nonzero.
|
|
86
|
+
set +e
|
|
87
|
+
HYPOMNEMA_ROOT=${shellSingleQuote(root)}
|
|
88
|
+
HYPOMNEMA_GIT_DIR=${shellSingleQuote(gitDir)}
|
|
89
|
+
TOPLEVEL="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
|
|
90
|
+
[ -z "$TOPLEVEL" ] && exit 0
|
|
91
|
+
[ "$TOPLEVEL" = "$HYPOMNEMA_ROOT" ] || exit 0
|
|
92
|
+
ABSGITDIR="$(git rev-parse --absolute-git-dir 2>/dev/null)" || exit 0
|
|
93
|
+
[ "$ABSGITDIR" = "$HYPOMNEMA_GIT_DIR" ] || exit 0
|
|
94
|
+
SCRIPT="$HYPOMNEMA_ROOT/scripts/pre-commit-format.mjs"
|
|
95
|
+
[ -f "$SCRIPT" ] || exit 0
|
|
96
|
+
command -v node >/dev/null 2>&1 || exit 0
|
|
97
|
+
# Sentinel: tells the .mjs it is running under our trusted shim (not direct
|
|
98
|
+
# attacker invocation). The .mjs preserves inherited GIT_INDEX_FILE only when
|
|
99
|
+
# this sentinel is present — direct invocation drops it and falls back to the
|
|
100
|
+
# default \`.git/index\`. This closes prefix-matching attacks on the index
|
|
101
|
+
# whitelist (e.g. attacker-crafted .git/next-index-attack.lock).
|
|
102
|
+
HYPOMNEMA_HOOK_INVOCATION=1 exec node "$SCRIPT"
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeShim(target, root, gitDir) {
|
|
107
|
+
try {
|
|
108
|
+
fs.writeFileSync(target, shimBody(root, gitDir), { mode: 0o755 });
|
|
109
|
+
try {
|
|
110
|
+
fs.chmodSync(target, 0o755);
|
|
111
|
+
} catch {}
|
|
112
|
+
return exitSilent(`installed pre-commit hook for ${root}`);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return exitSilent(`write hook failed: ${e.code || e.message}; skipping`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function main() {
|
|
119
|
+
try {
|
|
120
|
+
// (0) CI / lifecycle guards.
|
|
121
|
+
if (process.env.CI === 'true') return exitSilent('CI=true; skipping');
|
|
122
|
+
const lc = process.env.npm_command;
|
|
123
|
+
if (lc === 'pack' || lc === 'publish') {
|
|
124
|
+
return exitSilent(`npm_command=${lc}; skipping`);
|
|
125
|
+
}
|
|
126
|
+
if (process.env.npm_lifecycle_event === 'prepublishOnly') {
|
|
127
|
+
return exitSilent('prepublishOnly; skipping');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// (1) Derive expectedRoot from THIS script's location, not from git.
|
|
131
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
132
|
+
let expectedRoot;
|
|
133
|
+
try {
|
|
134
|
+
expectedRoot = fs.realpathSync(path.resolve(here, '..'));
|
|
135
|
+
} catch {
|
|
136
|
+
return exitSilent('cannot resolve script location; skipping');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// (2) Bootstrap scrubbed env with the static fallback list, just enough to
|
|
140
|
+
// get a trusted git probe running. Then enrich from --local-env-vars at
|
|
141
|
+
// runtime (modern git) so the scrub list always tracks git's own truth.
|
|
142
|
+
let cleanEnv = buildScrubbedEnv(null);
|
|
143
|
+
let run = (args) =>
|
|
144
|
+
execFileSync('git', args, {
|
|
145
|
+
encoding: 'utf-8',
|
|
146
|
+
env: cleanEnv,
|
|
147
|
+
cwd: expectedRoot,
|
|
148
|
+
}).trim();
|
|
149
|
+
try {
|
|
150
|
+
const list = run(['rev-parse', '--local-env-vars']).split(/\r?\n/).filter(Boolean);
|
|
151
|
+
cleanEnv = buildScrubbedEnv(list);
|
|
152
|
+
run = (args) =>
|
|
153
|
+
execFileSync('git', args, {
|
|
154
|
+
encoding: 'utf-8',
|
|
155
|
+
env: cleanEnv,
|
|
156
|
+
cwd: expectedRoot,
|
|
157
|
+
}).trim();
|
|
158
|
+
} catch {
|
|
159
|
+
// Old git without --local-env-vars; keep static-list cleanEnv.
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// (3) Probe git with sanitized env.
|
|
163
|
+
let topR, absGitDir, commonDir;
|
|
164
|
+
try {
|
|
165
|
+
topR = fs.realpathSync(run(['rev-parse', '--show-toplevel']));
|
|
166
|
+
absGitDir = fs.realpathSync(run(['rev-parse', '--absolute-git-dir']));
|
|
167
|
+
const cd = run(['rev-parse', '--git-common-dir']);
|
|
168
|
+
commonDir = fs.realpathSync(path.isAbsolute(cd) ? cd : path.join(absGitDir, '..', cd));
|
|
169
|
+
} catch {
|
|
170
|
+
return exitSilent('git probe failed; skipping');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// (4) Identity + worktree + containment.
|
|
174
|
+
if (topR !== expectedRoot) {
|
|
175
|
+
return exitSilent('toplevel != expectedRoot; skipping');
|
|
176
|
+
}
|
|
177
|
+
if (absGitDir !== commonDir) {
|
|
178
|
+
return exitSilent('linked worktree; skipping');
|
|
179
|
+
}
|
|
180
|
+
if (!absGitDir.startsWith(expectedRoot + path.sep)) {
|
|
181
|
+
return exitSilent('gitDir outside expectedRoot; skipping');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// (5) Resolve hooks dir (still under sanitized env).
|
|
185
|
+
let rawHooksDir;
|
|
186
|
+
try {
|
|
187
|
+
rawHooksDir = run(['rev-parse', '--git-path', 'hooks']);
|
|
188
|
+
} catch {
|
|
189
|
+
return exitSilent('cannot resolve hooks dir; skipping');
|
|
190
|
+
}
|
|
191
|
+
if (!path.isAbsolute(rawHooksDir)) {
|
|
192
|
+
rawHooksDir = path.resolve(expectedRoot, rawHooksDir);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!fs.existsSync(rawHooksDir)) {
|
|
196
|
+
// Git creates .git/hooks lazily. Only create when it would land inside
|
|
197
|
+
// absGitDir — protects against `core.hooksPath=/elsewhere`.
|
|
198
|
+
if (!rawHooksDir.startsWith(absGitDir + path.sep)) {
|
|
199
|
+
return exitSilent('hooks dir outside gitDir; skipping');
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
fs.mkdirSync(rawHooksDir, { recursive: true });
|
|
203
|
+
} catch {
|
|
204
|
+
return exitSilent('mkdir hooks dir failed; skipping');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let absHooksDir;
|
|
209
|
+
try {
|
|
210
|
+
absHooksDir = fs.realpathSync(rawHooksDir);
|
|
211
|
+
} catch {
|
|
212
|
+
return exitSilent('realpath hooks dir failed; skipping');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// (6) Symlink rejection + containment.
|
|
216
|
+
try {
|
|
217
|
+
if (fs.lstatSync(rawHooksDir).isSymbolicLink()) {
|
|
218
|
+
return exitSilent('hooks dir is symlink; skipping');
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
return exitSilent('lstat hooks dir failed; skipping');
|
|
222
|
+
}
|
|
223
|
+
const rel = path.relative(absGitDir, absHooksDir);
|
|
224
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
225
|
+
return exitSilent('hooks dir outside .git/; skipping');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// (7) Existing pre-commit logic.
|
|
229
|
+
const target = path.join(absHooksDir, 'pre-commit');
|
|
230
|
+
let existing;
|
|
231
|
+
try {
|
|
232
|
+
existing = fs.lstatSync(target);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
if (e.code === 'ENOENT') {
|
|
235
|
+
return writeShim(target, expectedRoot, absGitDir);
|
|
236
|
+
}
|
|
237
|
+
return exitSilent(`stat target failed: ${e.code}; skipping`);
|
|
238
|
+
}
|
|
239
|
+
if (existing.isSymbolicLink()) {
|
|
240
|
+
return exitSilent('pre-commit is symlink; not overwriting');
|
|
241
|
+
}
|
|
242
|
+
let head;
|
|
243
|
+
try {
|
|
244
|
+
head = fs.readFileSync(target, 'utf-8').split('\n').slice(0, 3).join('\n');
|
|
245
|
+
} catch {
|
|
246
|
+
return exitSilent('read existing pre-commit failed; skipping');
|
|
247
|
+
}
|
|
248
|
+
if (head.includes('hypomnema-pre-commit-marker v1')) {
|
|
249
|
+
// Same marker — regenerate (refreshes embedded root if checkout moved).
|
|
250
|
+
return writeShim(target, expectedRoot, absGitDir);
|
|
251
|
+
}
|
|
252
|
+
return exitSilent('existing non-marker pre-commit; not overwriting');
|
|
253
|
+
} catch (e) {
|
|
254
|
+
return exitSilent(`unexpected: ${e.code || e.message}; skipping`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
main();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adr-corpus — fs-backed production-code corpus search for the ADR-line grep.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from the pure verifier (scripts/lib/fix-status-verify.mjs) so
|
|
5
|
+
* that layer stays IO-free and unit-testable with injected searchFns. This
|
|
6
|
+
* module is itself testable against real temp directories.
|
|
7
|
+
*
|
|
8
|
+
* CRITICAL (self-match): the manifest module (scripts/lib/fix-manifest.mjs)
|
|
9
|
+
* lives *inside* the scripts/ corpus and holds every adrKeyLine as a literal.
|
|
10
|
+
* If it were scanned, every line would self-match and ADR_LINE_MISSING could
|
|
11
|
+
* never fire — the gate would be silently vacuous. The builder therefore
|
|
12
|
+
* excludes caller-supplied paths, resolved absolute, BEFORE reading any file.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_EXTENSIONS = ['.mjs', '.js', '.md', '.json', '.cjs'];
|
|
19
|
+
|
|
20
|
+
function* walk(dir, excludeAbs, extensions) {
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
24
|
+
} catch {
|
|
25
|
+
return; // missing corpus dir is not fatal — other dirs may exist
|
|
26
|
+
}
|
|
27
|
+
for (const ent of entries) {
|
|
28
|
+
const full = join(dir, ent.name);
|
|
29
|
+
if (excludeAbs.has(resolve(full))) continue;
|
|
30
|
+
if (ent.isDirectory()) {
|
|
31
|
+
if (ent.name === 'node_modules' || ent.name === '.git') continue;
|
|
32
|
+
yield* walk(full, excludeAbs, extensions);
|
|
33
|
+
} else if (ent.isFile()) {
|
|
34
|
+
if (extensions.some((e) => ent.name.endsWith(e))) yield full;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build a fixed-string corpus search function.
|
|
41
|
+
*
|
|
42
|
+
* buildCorpusSearch({ repoRoot, includeDirs, excludePaths, extensions })
|
|
43
|
+
* → (literal:string) => boolean
|
|
44
|
+
*
|
|
45
|
+
* - includeDirs / excludePaths are resolved relative to repoRoot.
|
|
46
|
+
* - excludePaths are matched by resolved absolute path (handles symlinks of the
|
|
47
|
+
* caller-supplied path consistently with the walk's resolve()).
|
|
48
|
+
* - search is case-sensitive String.includes (fixed string, not regex).
|
|
49
|
+
*
|
|
50
|
+
* Files are read once and cached so repeated searches (one per manifest row)
|
|
51
|
+
* do not re-walk the tree.
|
|
52
|
+
*/
|
|
53
|
+
export function buildCorpusSearch({
|
|
54
|
+
repoRoot,
|
|
55
|
+
includeDirs,
|
|
56
|
+
excludePaths = [],
|
|
57
|
+
extensions = DEFAULT_EXTENSIONS,
|
|
58
|
+
}) {
|
|
59
|
+
const excludeAbs = new Set(excludePaths.map((p) => resolve(repoRoot, p)));
|
|
60
|
+
const contents = [];
|
|
61
|
+
for (const dir of includeDirs) {
|
|
62
|
+
const abs = resolve(repoRoot, dir);
|
|
63
|
+
let isDir = false;
|
|
64
|
+
try {
|
|
65
|
+
isDir = statSync(abs).isDirectory();
|
|
66
|
+
} catch {
|
|
67
|
+
isDir = false;
|
|
68
|
+
}
|
|
69
|
+
if (!isDir) continue;
|
|
70
|
+
for (const file of walk(abs, excludeAbs, extensions)) {
|
|
71
|
+
try {
|
|
72
|
+
contents.push(readFileSync(file, 'utf-8'));
|
|
73
|
+
} catch {
|
|
74
|
+
/* unreadable file — skip */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return (literal) => contents.some((text) => text.includes(literal));
|
|
79
|
+
}
|