hypomnema 1.8.2 → 1.9.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/CHANGELOG.md +141 -51
- package/README.ko.md +18 -17
- package/README.md +16 -15
- package/commands/crystallize.md +89 -24
- package/commands/doctor.md +3 -1
- package/docs/ARCHITECTURE.md +45 -14
- package/docs/CONTRIBUTING.md +26 -8
- package/hooks/atomic-write.mjs +44 -0
- package/hooks/base-store.mjs +409 -90
- package/hooks/close-gate-store.mjs +131 -16
- package/hooks/close-journal.mjs +250 -0
- package/hooks/close-receipt.mjs +559 -0
- package/hooks/hooks.json +3 -38
- package/hooks/hypo-auto-commit.mjs +80 -10
- package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
- package/hooks/hypo-auto-stage.mjs +13 -1
- package/hooks/hypo-close-guard.mjs +2 -1
- package/hooks/hypo-cwd-change.mjs +45 -20
- package/hooks/hypo-file-watch.mjs +44 -6
- package/hooks/hypo-first-prompt.mjs +15 -9
- package/hooks/hypo-hot-rebuild.mjs +169 -112
- package/hooks/hypo-personal-check.mjs +9 -1
- package/hooks/hypo-session-record.mjs +1 -1
- package/hooks/hypo-session-start.mjs +566 -20
- package/hooks/hypo-shared.mjs +2250 -206
- package/hooks/hypo-stop.mjs +207 -0
- package/hooks/proposal-store.mjs +201 -35
- package/hooks/shared.json +15 -0
- package/hooks/version-check.mjs +9 -7
- package/package.json +3 -1
- package/scripts/crystallize.mjs +3 -4
- package/scripts/doctor.mjs +549 -123
- package/scripts/init.mjs +86 -109
- package/scripts/lib/core-hooks.mjs +48 -22
- package/scripts/lib/crystallize-close-apply.mjs +2397 -188
- package/scripts/lib/crystallize-close-check.mjs +43 -23
- package/scripts/lib/design-history-stale.mjs +36 -9
- package/scripts/lib/frontmatter.mjs +39 -0
- package/scripts/lib/git-hooks-dir.mjs +427 -52
- package/scripts/lib/hook-inventory.mjs +180 -0
- package/scripts/lib/page-usage.mjs +1 -1
- package/scripts/lib/pkg-provenance.mjs +18 -1
- package/scripts/lib/plugin-detect.mjs +43 -9
- package/scripts/lib/project-create.mjs +40 -44
- package/scripts/lib/schema-vocab.mjs +22 -11
- package/scripts/lib/template-schema-version.mjs +48 -0
- package/scripts/lib/time.mjs +46 -0
- package/scripts/lint.mjs +66 -18
- package/scripts/proposal.mjs +423 -18
- package/scripts/resume.mjs +3 -2
- package/scripts/session-audit.mjs +2 -1
- package/scripts/uninstall.mjs +144 -66
- package/scripts/upgrade.mjs +523 -234
- package/scripts/verify.mjs +2 -1
- package/scripts/weekly-report.mjs +2 -1
- package/templates/SCHEMA.md +27 -7
- package/templates/gitignore +11 -0
- package/templates/hypo-automation.md +10 -13
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +53 -25
- package/templates/hot.md +0 -24
package/scripts/init.mjs
CHANGED
|
@@ -48,8 +48,10 @@ import {
|
|
|
48
48
|
SHELL_MARKER_END,
|
|
49
49
|
SHELL_FUNCTION_BODY,
|
|
50
50
|
wikiPreCommitContent,
|
|
51
|
+
uniqueBakPath,
|
|
51
52
|
} from './lib/git-hooks-dir.mjs';
|
|
52
|
-
import {
|
|
53
|
+
import { loadHookInventory } from './lib/hook-inventory.mjs';
|
|
54
|
+
import { formatRootHotProjection } from '../hooks/hypo-shared.mjs';
|
|
53
55
|
import {
|
|
54
56
|
readPkgJson as readPkgJsonSafe,
|
|
55
57
|
writePkgJsonAtomic,
|
|
@@ -61,7 +63,7 @@ import { syncExtensions } from './lib/extensions.mjs';
|
|
|
61
63
|
import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
|
|
62
64
|
import { templateSchemaVersion } from './lib/template-schema-version.mjs';
|
|
63
65
|
import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
|
|
64
|
-
import { resolvePluginChannel,
|
|
66
|
+
import { resolvePluginChannel, isHypomnemaInstallRoot } from './lib/plugin-detect.mjs';
|
|
65
67
|
|
|
66
68
|
const HOME = homedir();
|
|
67
69
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -302,6 +304,22 @@ function copyTemplate(srcName, destPath, dryRun, transform) {
|
|
|
302
304
|
log('created', destPath);
|
|
303
305
|
}
|
|
304
306
|
|
|
307
|
+
// Root hot.md is generated, not copied. The hooks rebuild it from
|
|
308
|
+
// projects/*/hot.md with formatRootHotProjection, and the first rebuild in a new
|
|
309
|
+
// vault treats any bytes it did not produce as a hand edit (backup file plus a
|
|
310
|
+
// notice). A templates/hot.md kept equal to the generator by hand was a second
|
|
311
|
+
// copy of that body, and every edit to the generator had to be mirrored into it.
|
|
312
|
+
// Writing the generator's own empty-vault output here leaves one body.
|
|
313
|
+
function writeRootHot(hypoDir, dryRun) {
|
|
314
|
+
const dest = join(hypoDir, 'hot.md');
|
|
315
|
+
if (existsSync(dest)) {
|
|
316
|
+
log('skipped', dest);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (!dryRun) writeFileSync(dest, formatRootHotProjection([]));
|
|
320
|
+
log('created', dest);
|
|
321
|
+
}
|
|
322
|
+
|
|
305
323
|
// ── hypo-config.md ───────────────────────────────────────────────────────────
|
|
306
324
|
|
|
307
325
|
function writeHypoConfig(hypoDir, dryRun) {
|
|
@@ -367,90 +385,19 @@ function writeGitignore(hypoDir, dryRun) {
|
|
|
367
385
|
// ── hook installation ────────────────────────────────────────────────────────
|
|
368
386
|
|
|
369
387
|
function loadHookMap() {
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
const cfg = res.cfg;
|
|
381
|
-
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
382
|
-
console.error('Error: hooks/hooks.json must be a JSON object');
|
|
388
|
+
// loadHookInventory (scripts/lib/hook-inventory.mjs) owns the grammar AND the
|
|
389
|
+
// existence check now: every consumer that installs, refreshes, or removes a
|
|
390
|
+
// hook file reads through the same parser, and a name on either list that does
|
|
391
|
+
// not resolve to a real file under hooks/ fails this call before init writes
|
|
392
|
+
// anything at all (major C — a hook file dropped from the shipped package used
|
|
393
|
+
// to install a broken settings.json registration with nothing behind it).
|
|
394
|
+
const res = loadHookInventory(PKG_ROOT);
|
|
395
|
+
if (!res.ok) {
|
|
396
|
+
console.error(`Error: ${res.error}`);
|
|
383
397
|
console.error(PKG_INTEGRITY_HINT);
|
|
384
398
|
process.exit(1);
|
|
385
399
|
}
|
|
386
|
-
|
|
387
|
-
console.error('Error: hooks/hooks.json must contain a "hooks" object');
|
|
388
|
-
console.error(PKG_INTEGRITY_HINT);
|
|
389
|
-
process.exit(1);
|
|
390
|
-
}
|
|
391
|
-
function _extractCommandFileName(command) {
|
|
392
|
-
if (typeof command !== 'string') return null;
|
|
393
|
-
const matches = [...command.matchAll(/(?:^|[\/\\])([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/g)];
|
|
394
|
-
if (matches.length > 0) return matches[matches.length - 1][1];
|
|
395
|
-
const bare = command.match(/(?:^|\s)([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/);
|
|
396
|
-
return bare ? bare[1] : null;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function _isHookFileName(file) {
|
|
400
|
-
return typeof file === 'string' && /^[^/\\\s]+\.mjs$/.test(file.trim());
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
function _isHookGroup(group) {
|
|
404
|
-
return (
|
|
405
|
-
group &&
|
|
406
|
-
typeof group === 'object' &&
|
|
407
|
-
!Array.isArray(group) &&
|
|
408
|
-
Array.isArray(group.hooks) &&
|
|
409
|
-
group.hooks.length > 0 &&
|
|
410
|
-
group.hooks.every(
|
|
411
|
-
(hook) =>
|
|
412
|
-
hook &&
|
|
413
|
-
typeof hook === 'object' &&
|
|
414
|
-
!Array.isArray(hook) &&
|
|
415
|
-
hook.type === 'command' &&
|
|
416
|
-
_extractCommandFileName(hook.command),
|
|
417
|
-
)
|
|
418
|
-
);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
|
|
422
|
-
function _extractFileNames(groups) {
|
|
423
|
-
return groups.flatMap((group) => {
|
|
424
|
-
if (typeof group === 'string') return [group.trim()];
|
|
425
|
-
return group.hooks.map((hook) => _extractCommandFileName(hook.command));
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
for (const [event, groups] of Object.entries(cfg.hooks)) {
|
|
430
|
-
const valid =
|
|
431
|
-
Array.isArray(groups) &&
|
|
432
|
-
groups.length > 0 &&
|
|
433
|
-
groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
|
|
434
|
-
_extractFileNames(groups).length > 0;
|
|
435
|
-
if (!valid) {
|
|
436
|
-
console.error(
|
|
437
|
-
`Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
|
|
438
|
-
);
|
|
439
|
-
console.error(PKG_INTEGRITY_HINT);
|
|
440
|
-
process.exit(1);
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
if (
|
|
444
|
-
cfg.shared !== undefined &&
|
|
445
|
-
(!Array.isArray(cfg.shared) || !cfg.shared.every((f) => _isHookFileName(f)))
|
|
446
|
-
) {
|
|
447
|
-
console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
|
|
448
|
-
console.error(PKG_INTEGRITY_HINT);
|
|
449
|
-
process.exit(1);
|
|
450
|
-
}
|
|
451
|
-
return Object.fromEntries(
|
|
452
|
-
Object.entries(cfg.hooks).map(([event, groups]) => [event, _extractFileNames(groups)]),
|
|
453
|
-
);
|
|
400
|
+
return res.hookMap;
|
|
454
401
|
}
|
|
455
402
|
|
|
456
403
|
function installHooks(targetDir, dryRun) {
|
|
@@ -459,8 +406,20 @@ function installHooks(targetDir, dryRun) {
|
|
|
459
406
|
return;
|
|
460
407
|
}
|
|
461
408
|
if (!dryRun) mkdirSync(targetDir, { recursive: true });
|
|
462
|
-
|
|
463
|
-
|
|
409
|
+
// The canonical install order, not a directory listing. Reading the directory
|
|
410
|
+
// installed whatever happened to sit in hooks/, which is how every install
|
|
411
|
+
// ended up with a copy of hypo-pre-commit.mjs: it is invoked from the package
|
|
412
|
+
// root, never from the install, so upgrade would not refresh that copy,
|
|
413
|
+
// doctor would not report it, and uninstall would not take it away.
|
|
414
|
+
// installOrder also puts shared modules ahead of the entry hooks that import
|
|
415
|
+
// them, so an interrupted run cannot leave an entry hook live against a module
|
|
416
|
+
// that is not there yet.
|
|
417
|
+
const inventory = loadHookInventory(PKG_ROOT);
|
|
418
|
+
if (!inventory.ok) {
|
|
419
|
+
log('errors', `cannot read the hook inventory: ${inventory.error}`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
for (const file of inventory.ordered) {
|
|
464
423
|
const dest = join(targetDir, file);
|
|
465
424
|
if (existsSync(dest)) {
|
|
466
425
|
log('skipped', dest);
|
|
@@ -578,8 +537,9 @@ function readPkgVersionAt(root) {
|
|
|
578
537
|
}
|
|
579
538
|
}
|
|
580
539
|
|
|
581
|
-
// usablePkgRoot now
|
|
582
|
-
// upgrade.mjs's dualSkip provenance correction so
|
|
540
|
+
// usablePkgRoot / isHypomnemaInstallRoot now live in ./lib/plugin-detect.mjs
|
|
541
|
+
// (imported above), shared with upgrade.mjs's dualSkip provenance correction so
|
|
542
|
+
// both agree on what is real.
|
|
583
543
|
|
|
584
544
|
function writePkgJson(dryRun, extraFields = {}, root = PKG_ROOT) {
|
|
585
545
|
const dest = pkgJsonPath();
|
|
@@ -759,12 +719,14 @@ function installPkgGitHook(dryRun) {
|
|
|
759
719
|
// ── wiki pre-commit hook ─────────────────────────────────────────────────────
|
|
760
720
|
//
|
|
761
721
|
// shellSingleQuote and wikiPreCommitContent live in lib/git-hooks-dir.mjs, not
|
|
762
|
-
// here: upgrade.mjs's
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
|
|
767
|
-
|
|
722
|
+
// here: upgrade.mjs's old-to-new-form migration and doctor.mjs's report both
|
|
723
|
+
// need to build/parse the exact same body this writer produces, so one copy is
|
|
724
|
+
// shared rather than three independent copies silently drifting apart.
|
|
725
|
+
// wikiPreCommitContent no longer takes an install root: the body
|
|
726
|
+
// it generates resolves that itself, at commit time, so there is nothing here
|
|
727
|
+
// for a plugin-channel version bump to make stale.
|
|
728
|
+
|
|
729
|
+
function installWikiPreCommitHook(hypoDir, dryRun, force, lintStrict) {
|
|
768
730
|
const { dir: hooksDir, skip } = hooksDirForInstall(hypoDir);
|
|
769
731
|
if (!hooksDir) {
|
|
770
732
|
// no git repo — silently skip, as before; anything else is worth surfacing
|
|
@@ -772,7 +734,7 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
|
|
|
772
734
|
return;
|
|
773
735
|
}
|
|
774
736
|
const hookPath = join(hooksDir, 'pre-commit');
|
|
775
|
-
const newContent = wikiPreCommitContent(
|
|
737
|
+
const newContent = wikiPreCommitContent(hypoDir, lintStrict);
|
|
776
738
|
|
|
777
739
|
// Before every branch below, including --force: a symlink here would send the
|
|
778
740
|
// write through to an arbitrary external file.
|
|
@@ -789,20 +751,36 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
|
|
|
789
751
|
log('skipped', `${hookPath} (pre-commit up to date)`);
|
|
790
752
|
return;
|
|
791
753
|
}
|
|
754
|
+
// Back this up first. The marker's presence says our block is IN the
|
|
755
|
+
// file; it says nothing about what else is, and this branch replaces the
|
|
756
|
+
// whole file either way. upgrade.mjs refuses exactly this shape (content
|
|
757
|
+
// outside the marker span) and tells the user to run
|
|
758
|
+
// `init --force-commands` — so without a backup here, following our own
|
|
759
|
+
// recovery advice is what destroys their hook. Measured by a codex
|
|
760
|
+
// reviewer 2026-09-11; the `force` branch below already backed up, this
|
|
761
|
+
// one did not, and `force` is not even consulted here.
|
|
762
|
+
const bakPath = uniqueBakPath(hookPath);
|
|
763
|
+
if (!bakPath) {
|
|
764
|
+
log('skipped', `${hookPath} (unsafe backup path) — not overwriting without a safe backup`);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
792
767
|
if (!dryRun) {
|
|
768
|
+
writeFileSync(bakPath, existing);
|
|
793
769
|
writeFileSync(hookPath, newContent);
|
|
794
770
|
chmodSync(hookPath, 0o755);
|
|
795
771
|
}
|
|
796
|
-
log('merged', `${hookPath} (pre-commit updated)`);
|
|
772
|
+
log('merged', `${hookPath} (pre-commit updated, backup at ${basename(bakPath)})`);
|
|
797
773
|
} else if (force) {
|
|
798
774
|
// The .bak is a SECOND write to a DIFFERENT path, so the guard on
|
|
799
775
|
// hookPath above says nothing about it. Left unchecked, a pre-commit.bak
|
|
800
776
|
// symlink turns --force-commands into an overwrite of whatever it points
|
|
801
777
|
// at — deterministically, not as a race.
|
|
802
|
-
const bakPath = hookPath
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
778
|
+
const bakPath = uniqueBakPath(hookPath);
|
|
779
|
+
if (!bakPath) {
|
|
780
|
+
log(
|
|
781
|
+
'skipped',
|
|
782
|
+
`${hookPath} (unsafe backup path) — not force-overwriting without a safe backup`,
|
|
783
|
+
);
|
|
806
784
|
return;
|
|
807
785
|
}
|
|
808
786
|
if (!dryRun) {
|
|
@@ -810,7 +788,7 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
|
|
|
810
788
|
writeFileSync(hookPath, newContent);
|
|
811
789
|
chmodSync(hookPath, 0o755);
|
|
812
790
|
}
|
|
813
|
-
log('merged', `${hookPath} (force-overwritten, backup at
|
|
791
|
+
log('merged', `${hookPath} (force-overwritten, backup at ${basename(bakPath)})`);
|
|
814
792
|
} else {
|
|
815
793
|
log(
|
|
816
794
|
'skipped',
|
|
@@ -1040,7 +1018,12 @@ function resolveDurableRoot() {
|
|
|
1040
1018
|
if (!hypomnemaPluginEnabled) return PKG_ROOT;
|
|
1041
1019
|
if (pluginChannel.root) return pluginChannel.root;
|
|
1042
1020
|
const recorded = recordedPkgRoot();
|
|
1043
|
-
|
|
1021
|
+
// isHypomnemaInstallRoot, not the weak usablePkgRoot: this value is about to be
|
|
1022
|
+
// adopted as the durable root and re-recorded (see writePkgJson's `root` param
|
|
1023
|
+
// below), so a recorded pointer that merely resolves a readable version, without
|
|
1024
|
+
// actually being a Hypomnema package, must not be trusted here (see
|
|
1025
|
+
// lib/plugin-detect.mjs's comment on the two predicates).
|
|
1026
|
+
if (isHypomnemaInstallRoot(recorded)) return recorded;
|
|
1044
1027
|
// pluginChannel.root is null here because the channel JUDGMENT FAILED (reason
|
|
1045
1028
|
// 'registry-unreadable' or 'unresolved'), not because no plugin is installed —
|
|
1046
1029
|
// hypomnemaPluginEnabled is already true, and that case already returned
|
|
@@ -1127,7 +1110,7 @@ if (args.fromRemote) {
|
|
|
1127
1110
|
|
|
1128
1111
|
// 2. template files
|
|
1129
1112
|
copyTemplate('index.md', join(args.hypoDir, 'index.md'), args.dryRun);
|
|
1130
|
-
|
|
1113
|
+
writeRootHot(args.hypoDir, args.dryRun);
|
|
1131
1114
|
copyTemplate('log.md', join(args.hypoDir, 'log.md'), args.dryRun);
|
|
1132
1115
|
copyTemplate('SCHEMA.md', join(args.hypoDir, 'SCHEMA.md'), args.dryRun);
|
|
1133
1116
|
copyTemplate('hypo-guide.md', join(args.hypoDir, 'hypo-guide.md'), args.dryRun);
|
|
@@ -1321,13 +1304,7 @@ if (args.hooks) {
|
|
|
1321
1304
|
// any) is left exactly as-is, and channelUnresolvedNotice() above says what to
|
|
1322
1305
|
// do next.
|
|
1323
1306
|
if (!channelUnresolved) {
|
|
1324
|
-
installWikiPreCommitHook(
|
|
1325
|
-
args.hypoDir,
|
|
1326
|
-
args.dryRun,
|
|
1327
|
-
args.forceCommands,
|
|
1328
|
-
durableRoot,
|
|
1329
|
-
args.lintStrict,
|
|
1330
|
-
);
|
|
1307
|
+
installWikiPreCommitHook(args.hypoDir, args.dryRun, args.forceCommands, args.lintStrict);
|
|
1331
1308
|
} else {
|
|
1332
1309
|
log(
|
|
1333
1310
|
'skipped',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// core-hooks.mjs - read the packaged hooks/hooks.json
|
|
1
|
+
// core-hooks.mjs - read the packaged hooks/hooks.json and hooks/shared.json
|
|
2
|
+
// without side effects.
|
|
2
3
|
//
|
|
3
4
|
// The init installer (init.mjs loadHookMap) reads hooks.json, validates it, and
|
|
4
5
|
// calls process.exit(1) on any malformation. That exit-on-error behavior is
|
|
@@ -12,28 +13,39 @@
|
|
|
12
13
|
// hooks type when the result is not ok (better to capture nothing than to
|
|
13
14
|
// capture a core hook).
|
|
14
15
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
16
|
+
// The shared-file list used to live at hooks.json's own top-level "shared" key.
|
|
17
|
+
// It moved to a sibling file, hooks/shared.json: the harness started
|
|
18
|
+
// warning on hooks.json's unknown "shared" key once it began validating that
|
|
19
|
+
// file against its own hook-registration schema, and "shared" was never part of
|
|
20
|
+
// that schema, it was a Hypomnema-only convention the harness never asked for.
|
|
21
|
+
// This module still folds shared.json's contents onto `cfg.shared` after
|
|
22
|
+
// reading it, so every existing caller of deriveCoreHookBasenames keeps working
|
|
23
|
+
// against the same shape without a signature change.
|
|
24
|
+
//
|
|
25
|
+
// fail-closed: a result is ok only when both files read, parse, AND have the
|
|
26
|
+
// expected shape (a hooks registration map, and a shared array). A parsed but
|
|
27
|
+
// oddly shaped input would yield a thin basename set, which is exactly the gap
|
|
28
|
+
// through which a core hook could leak into capture. When the shape is off we
|
|
29
|
+
// still attach the parsed cfg so init can run its own validation and emit its
|
|
20
30
|
// own specific error, but ok is false so capture stays conservative.
|
|
21
31
|
|
|
22
32
|
import { readFileSync } from 'node:fs';
|
|
23
33
|
import { join } from 'node:path';
|
|
24
34
|
|
|
25
35
|
/**
|
|
26
|
-
* Read and JSON-parse hooks/hooks.json from a package
|
|
27
|
-
* top-level side effects.
|
|
36
|
+
* Read and JSON-parse hooks/hooks.json and hooks/shared.json from a package
|
|
37
|
+
* root. No process.exit, no top-level side effects.
|
|
28
38
|
*
|
|
29
39
|
* @param {string} pkgRoot absolute path to the package root (contains hooks/)
|
|
30
40
|
* @returns {{ ok: true, cfg: object }
|
|
31
41
|
* | { ok: false, error: string }
|
|
32
42
|
* | { ok: false, error: string, cfg: * }}
|
|
33
|
-
* On read or parse failure the `cfg` key is absent.
|
|
34
|
-
* `cfg`
|
|
35
|
-
* so a caller can discriminate read/parse
|
|
36
|
-
*
|
|
43
|
+
* On read or parse failure of hooks.json the `cfg` key is absent. Once
|
|
44
|
+
* hooks.json parses, `cfg` is always present (even for null/array/scalar/
|
|
45
|
+
* shape-off inputs), so a caller can discriminate a hooks.json read/parse
|
|
46
|
+
* failure from a shape failure (including a shared.json failure) by the
|
|
47
|
+
* presence of the `cfg` key. `ok` is true only when both files' shapes are
|
|
48
|
+
* as expected, and when true `cfg.shared` is the parsed shared.json array.
|
|
37
49
|
*/
|
|
38
50
|
export function readCoreHooksConfig(pkgRoot) {
|
|
39
51
|
let raw;
|
|
@@ -55,15 +67,12 @@ export function readCoreHooksConfig(pkgRoot) {
|
|
|
55
67
|
if (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
|
|
56
68
|
return { ok: false, error: 'hooks/hooks.json must contain a "hooks" object', cfg };
|
|
57
69
|
}
|
|
58
|
-
if (!Array.isArray(cfg.shared)) {
|
|
59
|
-
return { ok: false, error: 'hooks/hooks.json must contain a "shared" array', cfg };
|
|
60
|
-
}
|
|
61
70
|
// Nested shape: a structurally odd rung (event not an array, a group without a
|
|
62
|
-
// hooks array, a hook entry with no string command
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
71
|
+
// hooks array, a hook entry with no string command) is silently skipped by
|
|
72
|
+
// deriveCoreHookBasenames, yielding a THIN reserved set. That is the gap
|
|
73
|
+
// through which a core hook could leak into reverse-capture, so validate
|
|
74
|
+
// every rung and fail closed. cfg is still attached so init can run its own
|
|
75
|
+
// detailed validation and emit its own specific error.
|
|
67
76
|
for (const groups of Object.values(cfg.hooks)) {
|
|
68
77
|
if (!Array.isArray(groups)) {
|
|
69
78
|
return { ok: false, error: 'each hooks event must map to an array of groups', cfg };
|
|
@@ -86,11 +95,28 @@ export function readCoreHooksConfig(pkgRoot) {
|
|
|
86
95
|
}
|
|
87
96
|
}
|
|
88
97
|
}
|
|
89
|
-
|
|
98
|
+
|
|
99
|
+
let sharedRaw;
|
|
100
|
+
try {
|
|
101
|
+
sharedRaw = readFileSync(join(pkgRoot, 'hooks', 'shared.json'), 'utf-8');
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return { ok: false, error: `cannot read hooks/shared.json: ${err.message}`, cfg };
|
|
104
|
+
}
|
|
105
|
+
let shared;
|
|
106
|
+
try {
|
|
107
|
+
shared = JSON.parse(sharedRaw);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return { ok: false, error: `hooks/shared.json is not valid JSON: ${err.message}`, cfg };
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(shared)) {
|
|
112
|
+
return { ok: false, error: 'hooks/shared.json must be a JSON array', cfg };
|
|
113
|
+
}
|
|
114
|
+
for (const file of shared) {
|
|
90
115
|
if (typeof file !== 'string') {
|
|
91
|
-
return { ok: false, error: 'each
|
|
116
|
+
return { ok: false, error: 'each hooks/shared.json entry must be a string', cfg };
|
|
92
117
|
}
|
|
93
118
|
}
|
|
119
|
+
cfg.shared = shared;
|
|
94
120
|
return { ok: true, cfg };
|
|
95
121
|
}
|
|
96
122
|
|