@mmnto/cli 1.124.0 → 2.0.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/dist/commands/doctor.d.ts +16 -10
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +162 -54
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +444 -28
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/hook-totemdir-render.test.js +55 -1
- package/dist/commands/hook-totemdir-render.test.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +39 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +32 -0
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-hooks-exit-contract.test.js +32 -1
- package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
- package/dist/commands/install-hooks.d.ts +143 -10
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +441 -69
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/install-hooks.test.js +1075 -7
- package/dist/commands/install-hooks.test.js.map +1 -1
- package/dist/commands/spec-templates.d.ts +17 -4
- package/dist/commands/spec-templates.d.ts.map +1 -1
- package/dist/commands/spec-templates.js +23 -3
- package/dist/commands/spec-templates.js.map +1 -1
- package/dist/commands/spec.d.ts +57 -8
- package/dist/commands/spec.d.ts.map +1 -1
- package/dist/commands/spec.js +124 -28
- package/dist/commands/spec.js.map +1 -1
- package/dist/commands/spec.test.js +277 -44
- package/dist/commands/spec.test.js.map +1 -1
- package/dist/utils.d.ts +5 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js.map +1 -1
- package/dist/utils.test.js +8 -3
- package/dist/utils.test.js.map +1 -1
- package/package.json +2 -2
|
@@ -4,11 +4,11 @@ import * as fs from 'node:fs';
|
|
|
4
4
|
import * as os from 'node:os';
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
8
|
import { GROUNDING_ANCHOR_FREE_TEXT, GROUNDING_ANCHOR_ISSUE, GROUNDING_ANCHOR_MIXED, GROUNDING_ANCHOR_RECORD, PROMPT_SOURCE_BUILTIN, PROMPT_SOURCE_OVERRIDE, RUN_ARTIFACT_SCHEMA_VERSION, RunArtifactSchema, } from '@mmnto/totem';
|
|
9
9
|
import { cleanTmpDir } from '../test-utils.js';
|
|
10
|
-
import { buildHookContent, buildPostCheckoutHookContent, buildPreCommitHook, buildPrePushHook, buildResolveBlock, checkHooksInstalled, detectTotemPrefix, generateHookHelpers, getFallbackCommand, installGitHook, installHooksNonInteractive, TOTEM_HOOK_END, TOTEM_HOOK_MARKER, TOTEM_PRECOMMIT_END, TOTEM_PRECOMMIT_MARKER, TOTEM_PREPUSH_END, TOTEM_PREPUSH_MARKER, upgradePrePushHookIfNeeded, } from './install-hooks.js';
|
|
11
|
-
import { SPEC_REQUIRED_SECTIONS } from './spec-templates.js';
|
|
10
|
+
import { buildHookContent, buildPostCheckoutHookContent, buildPreCommitHook, buildPrePushHook, buildResolveBlock, checkHooksInstalled, declaredHookTier, detectTotemPrefix, generateHookHelpers, getFallbackCommand, installGitHook, installHooksNonInteractive, isAttestedTrailer, isTotemOwnedWithAttestedTrailer, TOTEM_CHECKOUT_END, TOTEM_CHECKOUT_MARKER, TOTEM_HOOK_END, TOTEM_HOOK_MARKER, TOTEM_PRECOMMIT_END, TOTEM_PRECOMMIT_MARKER, TOTEM_PREPUSH_END, TOTEM_PREPUSH_MARKER, upgradePrePushHookIfNeeded, } from './install-hooks.js';
|
|
11
|
+
import { SPEC_REQUIRED_SECTIONS, SPEC_SYSTEM_PROMPT } from './spec-templates.js';
|
|
12
12
|
// The default render options every pre-#2692 positional call implied
|
|
13
13
|
// (mmnto-ai/totem#2692 C2 — the builders take a REQUIRED options object now, so
|
|
14
14
|
// each site states the tier/totemDir/fallbackCmd it always assumed). Spread and
|
|
@@ -18,6 +18,76 @@ const RENDER = {
|
|
|
18
18
|
totemDir: '.totem',
|
|
19
19
|
fallbackCmd: 'pnpm dlx @mmnto/cli',
|
|
20
20
|
};
|
|
21
|
+
// ─── Attested-extension fixtures (mmnto-ai/totem#2753) ───
|
|
22
|
+
//
|
|
23
|
+
// The measured liquid-city extension shape (`tools/git-hooks/install.cjs`, block 1):
|
|
24
|
+
// a `# [lc] …` comment line, THEN the attestation, then the extension's commands.
|
|
25
|
+
// Transcribed from that file's own string arrays and joined the way it joins them
|
|
26
|
+
// (`block.join('\n')`, appended straight onto a hook that ends with a newline), so
|
|
27
|
+
// each fixture is the exact byte sequence a liquid-city checkout carries after
|
|
28
|
+
// `# [totem] end <hook>` (mmnto-ai/liquid-city#1174).
|
|
29
|
+
//
|
|
30
|
+
// The attestation is NOT on the trailer's first non-blank line. An earlier reading of
|
|
31
|
+
// this contract required that, and it declined the founding datum outright.
|
|
32
|
+
const ATTESTED_FORK_LINE = '# <!-- totem:fork reason="lc docs-inject pre-commit extension (divergence-census justified fork)" owner="satur8d" attested="2026-06-07" -->';
|
|
33
|
+
/** install.cjs `STDIN_CAPTURE`. */
|
|
34
|
+
const LC_STDIN_CAPTURE = [
|
|
35
|
+
'LC_STDIN=$(cat)',
|
|
36
|
+
'lc_feed() { if [ -n "$LC_STDIN" ]; then printf \'%s\\n\' "$LC_STDIN"; fi; }',
|
|
37
|
+
];
|
|
38
|
+
/** install.cjs EXTENSIONS block 1 — pre-commit docs-inject. */
|
|
39
|
+
const ATTESTED_TRAILER = [
|
|
40
|
+
'',
|
|
41
|
+
'# [lc] docs-inject extension',
|
|
42
|
+
ATTESTED_FORK_LINE,
|
|
43
|
+
'sh "tools/git-hooks/pre-commit-docs-inject.sh"',
|
|
44
|
+
'',
|
|
45
|
+
].join('\n');
|
|
46
|
+
/** install.cjs EXTENSIONS block 2 — pre-push validate-fixtures. */
|
|
47
|
+
const LC_PREPUSH_FIXTURES_BLOCK = [
|
|
48
|
+
'',
|
|
49
|
+
'# [lc] validate-fixtures extension',
|
|
50
|
+
'# <!-- totem:fork reason="lc fail-closed fixture-contract validator pre-push extension (ADR-025 A2; divergence-census justified fork)" owner="satur8d" attested="2026-06-20" -->',
|
|
51
|
+
...LC_STDIN_CAPTURE,
|
|
52
|
+
'lc_feed | sh "tools/git-hooks/pre-push-validate-fixtures.sh" || exit 1',
|
|
53
|
+
'',
|
|
54
|
+
].join('\n');
|
|
55
|
+
/** install.cjs EXTENSIONS block 3 — pre-push validate-assets. */
|
|
56
|
+
const LC_PREPUSH_ASSETS_BLOCK = [
|
|
57
|
+
'',
|
|
58
|
+
'# [lc] validate-assets extension',
|
|
59
|
+
'# <!-- totem:fork reason="lc fail-closed interior-asset validator pre-push extension (mmnto-ai/liquid-city#543 Phase 2; divergence-census justified fork)" owner="satur8d" attested="2026-06-27" -->',
|
|
60
|
+
'lc_feed | sh "tools/git-hooks/pre-push-validate-assets.sh" || exit 1',
|
|
61
|
+
'',
|
|
62
|
+
].join('\n');
|
|
63
|
+
/**
|
|
64
|
+
* The canonical with ONE comment line inside the managed block altered — a hook
|
|
65
|
+
* frozen at an older template. Alters neither marker nor the trailer seam, so the
|
|
66
|
+
* ONLY difference a rewrite has to cure is the block's own text.
|
|
67
|
+
*/
|
|
68
|
+
function withStaleComment(canonical, endMarker) {
|
|
69
|
+
const lines = canonical.split('\n');
|
|
70
|
+
const endLine = lines.findIndex((line) => line.includes(endMarker));
|
|
71
|
+
const commentLine = lines.findIndex((line, i) => i > 1 && i < endLine && line.trimStart().startsWith('#') && !line.includes('[totem]'));
|
|
72
|
+
// Loud, not silent: a template with no alterable comment line would make every
|
|
73
|
+
// test below compare the canonical against itself and pass vacuously.
|
|
74
|
+
if (commentLine === -1)
|
|
75
|
+
throw new Error('no alterable comment line inside the managed block');
|
|
76
|
+
lines[commentLine] = `${lines[commentLine]} (frozen at an older template)`;
|
|
77
|
+
return lines.join('\n');
|
|
78
|
+
}
|
|
79
|
+
/** The text from the start marker through the end marker, inclusive. */
|
|
80
|
+
function managedBlockOf(text, marker, endMarker) {
|
|
81
|
+
const start = text.indexOf(marker);
|
|
82
|
+
const end = text.indexOf(endMarker, start + marker.length);
|
|
83
|
+
return text.slice(start, end + endMarker.length);
|
|
84
|
+
}
|
|
85
|
+
/** The bytes after the end marker — the trailer, its seam terminator included. */
|
|
86
|
+
function trailerOf(text, marker, endMarker) {
|
|
87
|
+
const start = text.indexOf(marker);
|
|
88
|
+
const end = text.indexOf(endMarker, start + marker.length);
|
|
89
|
+
return text.slice(end + endMarker.length);
|
|
90
|
+
}
|
|
21
91
|
// ─── Anchored-evidence fixtures (mmnto-ai/totem#2700) ───
|
|
22
92
|
//
|
|
23
93
|
// Since #2700 an artifact is evidence only when it is ANCHORED and its SUBJECT
|
|
@@ -443,8 +513,13 @@ describe('installGitHook', () => {
|
|
|
443
513
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
444
514
|
const userHook = '#!/bin/sh\necho "user hook"\n';
|
|
445
515
|
fs.writeFileSync(hookPath, userHook);
|
|
516
|
+
// The append is a whole-file atomic replacement since mmnto-ai/totem#2760
|
|
517
|
+
// round 1 (an interrupted append leaves a hook truncated mid-block): the
|
|
518
|
+
// inode changes under rename-over, where an in-place append kept it.
|
|
519
|
+
const inodeBefore = fs.statSync(hookPath, { bigint: true }).ino;
|
|
446
520
|
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
447
521
|
expect(result).toBe('appended');
|
|
522
|
+
expect(fs.statSync(hookPath, { bigint: true }).ino).not.toBe(inodeBefore);
|
|
448
523
|
const written = fs.readFileSync(hookPath, 'utf-8');
|
|
449
524
|
expect(written).toContain('echo "user hook"');
|
|
450
525
|
expect(written).toContain(TOTEM_PRECOMMIT_MARKER);
|
|
@@ -624,6 +699,404 @@ describe('installGitHook', () => {
|
|
|
624
699
|
expect(result).toBe('exists');
|
|
625
700
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(staleWithTrailingUser);
|
|
626
701
|
});
|
|
702
|
+
// ── Attested-extension carry-through (mmnto-ai/totem#2753) ──
|
|
703
|
+
//
|
|
704
|
+
// The liquid-city shape: a consumer appends its own blocks AFTER totem's end
|
|
705
|
+
// marker, each headed by a full `totem:fork` attestation. Before this slice a bare
|
|
706
|
+
// install DECLINED such a file, so `totem init -y` reported "Upgraded reflexes to
|
|
707
|
+
// v13" while both hooks stayed frozen at the 1.121.0 managed text (measured at
|
|
708
|
+
// `@mmnto/cli` 1.123.0, mmnto-ai/liquid-city#1174).
|
|
709
|
+
it('a bare install rewrites a stale managed block in place and carries an attested trailer through byte-for-byte', () => {
|
|
710
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
711
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
712
|
+
const canonical = buildPreCommitHook(RENDER);
|
|
713
|
+
const existing = withStaleComment(canonical, TOTEM_PRECOMMIT_END) + ATTESTED_TRAILER;
|
|
714
|
+
fs.writeFileSync(hookPath, existing);
|
|
715
|
+
const result = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
716
|
+
expect(result).toBe('block-rewritten');
|
|
717
|
+
const written = fs.readFileSync(hookPath, 'utf-8');
|
|
718
|
+
// The managed block is the CURRENT canonical …
|
|
719
|
+
expect(managedBlockOf(written, TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe(managedBlockOf(canonical, TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END));
|
|
720
|
+
// … and everything past the end marker is byte-identical to what was there.
|
|
721
|
+
expect(trailerOf(written, TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe(trailerOf(existing, TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END));
|
|
722
|
+
// The seam carries exactly ONE newline: the canonical's own, not a duplicate.
|
|
723
|
+
expect(written).toBe(canonical + ATTESTED_TRAILER);
|
|
724
|
+
});
|
|
725
|
+
it('carries an attested trailer through on pre-push', () => {
|
|
726
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
727
|
+
const hookPath = path.join(hooksDir, 'pre-push');
|
|
728
|
+
const canonical = buildPrePushHook(RENDER);
|
|
729
|
+
const existing = withStaleComment(canonical, TOTEM_PREPUSH_END) + ATTESTED_TRAILER;
|
|
730
|
+
fs.writeFileSync(hookPath, existing);
|
|
731
|
+
const result = installGitHook(hooksDir, 'pre-push', canonical, TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
|
|
732
|
+
expect(result).toBe('block-rewritten');
|
|
733
|
+
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(canonical + ATTESTED_TRAILER);
|
|
734
|
+
});
|
|
735
|
+
it('carries an attested trailer through on post-merge', () => {
|
|
736
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
737
|
+
const hookPath = path.join(hooksDir, 'post-merge');
|
|
738
|
+
const canonical = buildHookContent(RENDER);
|
|
739
|
+
const existing = withStaleComment(canonical, TOTEM_HOOK_END) + ATTESTED_TRAILER;
|
|
740
|
+
fs.writeFileSync(hookPath, existing);
|
|
741
|
+
const result = installGitHook(hooksDir, 'post-merge', canonical, TOTEM_HOOK_MARKER, false, TOTEM_HOOK_END);
|
|
742
|
+
expect(result).toBe('block-rewritten');
|
|
743
|
+
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(canonical + ATTESTED_TRAILER);
|
|
744
|
+
});
|
|
745
|
+
it('is idempotent — a second bare install on the rewritten hook writes nothing and reports current', () => {
|
|
746
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
747
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
748
|
+
const canonical = buildPreCommitHook(RENDER);
|
|
749
|
+
fs.writeFileSync(hookPath, withStaleComment(canonical, TOTEM_PRECOMMIT_END) + ATTESTED_TRAILER);
|
|
750
|
+
installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
751
|
+
const afterFirst = fs.readFileSync(hookPath, 'utf-8');
|
|
752
|
+
const second = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
753
|
+
// `exists` IS the no-write arm — the function returns before `writeExecutableHook`
|
|
754
|
+
// — so the action plus byte-equality is the whole claim.
|
|
755
|
+
expect(second).toBe('exists');
|
|
756
|
+
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(afterFirst);
|
|
757
|
+
});
|
|
758
|
+
it('declines a stale block whose trailer is UNATTESTED (unchanged: byte-for-byte untouched)', () => {
|
|
759
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
760
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
761
|
+
const canonical = buildPreCommitHook(RENDER);
|
|
762
|
+
const existing = withStaleComment(canonical, TOTEM_PRECOMMIT_END) +
|
|
763
|
+
'# my own pre-commit extension\necho "[me] extension"\n';
|
|
764
|
+
fs.writeFileSync(hookPath, existing);
|
|
765
|
+
const result = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
766
|
+
expect(result).toBe('exists');
|
|
767
|
+
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(existing);
|
|
768
|
+
});
|
|
769
|
+
it('declines a trailer headed by a BARE totem:fork marker (a fork claim is not an attestation)', () => {
|
|
770
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
771
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
772
|
+
const canonical = buildPreCommitHook(RENDER);
|
|
773
|
+
const existing = withStaleComment(canonical, TOTEM_PRECOMMIT_END) +
|
|
774
|
+
'# <!-- totem:fork -->\necho "[me] extension"\n';
|
|
775
|
+
fs.writeFileSync(hookPath, existing);
|
|
776
|
+
const result = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
777
|
+
expect(result).toBe('exists');
|
|
778
|
+
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(existing);
|
|
779
|
+
});
|
|
780
|
+
it('declines a trailer attested with only a reason (owner and attested are required too)', () => {
|
|
781
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
782
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
783
|
+
const canonical = buildPreCommitHook(RENDER);
|
|
784
|
+
const existing = withStaleComment(canonical, TOTEM_PRECOMMIT_END) +
|
|
785
|
+
'# <!-- totem:fork reason="lc docs-inject pre-commit extension" -->\necho "[me] extension"\n';
|
|
786
|
+
fs.writeFileSync(hookPath, existing);
|
|
787
|
+
const result = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
788
|
+
expect(result).toBe('exists');
|
|
789
|
+
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(existing);
|
|
790
|
+
});
|
|
791
|
+
it('--force still overwrites the WHOLE file, attested trailer included (semantics unchanged)', () => {
|
|
792
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
793
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
794
|
+
const canonical = buildPreCommitHook(RENDER);
|
|
795
|
+
fs.writeFileSync(hookPath, withStaleComment(canonical, TOTEM_PRECOMMIT_END) + ATTESTED_TRAILER);
|
|
796
|
+
const result = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, true, // force
|
|
797
|
+
TOTEM_PRECOMMIT_END);
|
|
798
|
+
expect(result).toBe('overwritten');
|
|
799
|
+
const written = fs.readFileSync(hookPath, 'utf-8');
|
|
800
|
+
expect(written).toBe(canonical);
|
|
801
|
+
expect(written).not.toContain('totem:fork');
|
|
802
|
+
});
|
|
803
|
+
it('carries a TWO-block liquid-city pre-push trailer through — the FIRST block attests', () => {
|
|
804
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
805
|
+
const hookPath = path.join(hooksDir, 'pre-push');
|
|
806
|
+
const canonical = buildPrePushHook(RENDER);
|
|
807
|
+
// install.cjs blocks 2 and 3, appended back to back exactly as a liquid-city
|
|
808
|
+
// checkout carries them. Only the first block's comment run is the trailer's
|
|
809
|
+
// leading run; the second block's marker sits far below a command.
|
|
810
|
+
const trailer = LC_PREPUSH_FIXTURES_BLOCK + LC_PREPUSH_ASSETS_BLOCK;
|
|
811
|
+
fs.writeFileSync(hookPath, withStaleComment(canonical, TOTEM_PREPUSH_END) + trailer);
|
|
812
|
+
const result = installGitHook(hooksDir, 'pre-push', canonical, TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
|
|
813
|
+
expect(result).toBe('block-rewritten');
|
|
814
|
+
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(canonical + trailer);
|
|
815
|
+
});
|
|
816
|
+
it('declines when the fork line sits BELOW the extension first command', () => {
|
|
817
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
818
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
819
|
+
const canonical = buildPreCommitHook(RENDER);
|
|
820
|
+
const existing = withStaleComment(canonical, TOTEM_PRECOMMIT_END) +
|
|
821
|
+
[
|
|
822
|
+
'',
|
|
823
|
+
'# [lc] docs-inject extension',
|
|
824
|
+
'if [ -f "docs/wiki/Home.md" ]; then',
|
|
825
|
+
' echo "[lc] docs-inject extension"',
|
|
826
|
+
'fi',
|
|
827
|
+
ATTESTED_FORK_LINE,
|
|
828
|
+
'',
|
|
829
|
+
].join('\n');
|
|
830
|
+
fs.writeFileSync(hookPath, existing);
|
|
831
|
+
const result = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
832
|
+
expect(result).toBe('exists');
|
|
833
|
+
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(existing);
|
|
834
|
+
});
|
|
835
|
+
it('carries an attested trailer through on post-checkout', () => {
|
|
836
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
837
|
+
const hookPath = path.join(hooksDir, 'post-checkout');
|
|
838
|
+
const canonical = buildPostCheckoutHookContent(RENDER);
|
|
839
|
+
fs.writeFileSync(hookPath, withStaleComment(canonical, TOTEM_CHECKOUT_END) + ATTESTED_TRAILER);
|
|
840
|
+
const result = installGitHook(hooksDir, 'post-checkout', canonical, TOTEM_CHECKOUT_MARKER, false, TOTEM_CHECKOUT_END);
|
|
841
|
+
expect(result).toBe('block-rewritten');
|
|
842
|
+
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(canonical + ATTESTED_TRAILER);
|
|
843
|
+
});
|
|
844
|
+
it('strips exactly one CRLF seam and keeps the CRLF bytes inside the trailer', () => {
|
|
845
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
846
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
847
|
+
const canonical = buildPreCommitHook(RENDER);
|
|
848
|
+
// A win32 checkout: the whole file, trailer included, is CRLF. The seam the
|
|
849
|
+
// rewrite removes is the end marker's own `\r\n`, and only that one.
|
|
850
|
+
const crlfTrailer = ATTESTED_TRAILER.replace(/\n/g, '\r\n');
|
|
851
|
+
fs.writeFileSync(hookPath, withStaleComment(canonical, TOTEM_PRECOMMIT_END).replace(/\n/g, '\r\n') + crlfTrailer);
|
|
852
|
+
const first = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
853
|
+
expect(first).toBe('block-rewritten');
|
|
854
|
+
const written = fs.readFileSync(hookPath, 'utf-8');
|
|
855
|
+
// One seam consumed, not two: the terminator removed is the stale block's own
|
|
856
|
+
// `\r\n` after the end marker, so the trailer keeps every byte it had — its
|
|
857
|
+
// leading blank line included — behind the canonical's `\n`.
|
|
858
|
+
expect(written).toBe(canonical + crlfTrailer);
|
|
859
|
+
expect(written).toContain('\r\n# [lc] docs-inject extension\r\n');
|
|
860
|
+
const second = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
861
|
+
expect(second).toBe('exists');
|
|
862
|
+
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(written);
|
|
863
|
+
});
|
|
864
|
+
it('handles an end marker with text on the SAME line, and is idempotent after', () => {
|
|
865
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
866
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
867
|
+
const canonical = buildPreCommitHook(RENDER);
|
|
868
|
+
// No terminator after the end marker: the trailer begins mid-line. The offset
|
|
869
|
+
// `ownedTrailerStart` returns is the byte AFTER the marker, so the trailer is
|
|
870
|
+
// ` ${ATTESTED_FORK_LINE}\n…` — attested, because its first non-blank line
|
|
871
|
+
// trims to a comment carrying the marker.
|
|
872
|
+
const sameLineTrailer = ` ${ATTESTED_FORK_LINE}\nsh "tools/git-hooks/pre-commit-docs-inject.sh"\n`;
|
|
873
|
+
const stale = withStaleComment(canonical, TOTEM_PRECOMMIT_END).replace(`${TOTEM_PRECOMMIT_END}\n`, TOTEM_PRECOMMIT_END);
|
|
874
|
+
expect(isTotemOwnedWithAttestedTrailer(stale + sameLineTrailer, TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe(true);
|
|
875
|
+
fs.writeFileSync(hookPath, stale + sameLineTrailer);
|
|
876
|
+
const first = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
877
|
+
// Nothing was removed — there was no seam terminator to consume — so the
|
|
878
|
+
// same-line text is carried through verbatim, now behind the canonical's own
|
|
879
|
+
// newline.
|
|
880
|
+
expect(first).toBe('block-rewritten');
|
|
881
|
+
const written = fs.readFileSync(hookPath, 'utf-8');
|
|
882
|
+
expect(written).toBe(canonical + sameLineTrailer);
|
|
883
|
+
const second = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
884
|
+
expect(second).toBe('exists');
|
|
885
|
+
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(written);
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
// ─── isAttestedTrailer (mmnto-ai/totem#2753) ────────
|
|
889
|
+
describe('isAttestedTrailer', () => {
|
|
890
|
+
it('is false for a blank trailer', () => {
|
|
891
|
+
expect(isAttestedTrailer('')).toBe(false);
|
|
892
|
+
expect(isAttestedTrailer('\n\n \n')).toBe(false);
|
|
893
|
+
});
|
|
894
|
+
it('is true when a full marker heads the trailer past leading blank lines', () => {
|
|
895
|
+
expect(isAttestedTrailer(`\n\n${ATTESTED_FORK_LINE}\necho hi\n`)).toBe(true);
|
|
896
|
+
});
|
|
897
|
+
// The founding datum: liquid-city labels its block, THEN signs it. A rule that
|
|
898
|
+
// reads only the trailer's first non-blank line declines this.
|
|
899
|
+
it('is true for the real liquid-city block — a `# [lc] …` label line above the marker', () => {
|
|
900
|
+
expect(isAttestedTrailer(`\n${ATTESTED_TRAILER}`)).toBe(true);
|
|
901
|
+
});
|
|
902
|
+
it('is true when the marker sits several comment lines deep, still above the first command', () => {
|
|
903
|
+
expect(isAttestedTrailer(`\n# one\n\n# two\n${ATTESTED_FORK_LINE}\nsh "x.sh"\n`)).toBe(true);
|
|
904
|
+
});
|
|
905
|
+
it('is false once a COMMAND has opened the run — an attestation below code vouches for nothing', () => {
|
|
906
|
+
expect(isAttestedTrailer(`\necho hi\n${ATTESTED_FORK_LINE}\n`)).toBe(false);
|
|
907
|
+
expect(isAttestedTrailer(`\n# a label\nsh "x.sh"\n${ATTESTED_FORK_LINE}\n`)).toBe(false);
|
|
908
|
+
});
|
|
909
|
+
// A marker riding a command line is not a signature (falsification-leg F10).
|
|
910
|
+
it('is false when the marker rides a NON-comment line', () => {
|
|
911
|
+
expect(isAttestedTrailer(`\nrm -rf ./build ${ATTESTED_FORK_LINE.slice(2)}\n`)).toBe(false);
|
|
912
|
+
});
|
|
913
|
+
// The marker is parsed per LINE here, so core's multi-line (dotAll) form of it is
|
|
914
|
+
// deliberately not in play: a marker split across two comment lines does not attest.
|
|
915
|
+
it('is false when the marker is split across two comment lines', () => {
|
|
916
|
+
const split = [
|
|
917
|
+
'',
|
|
918
|
+
'# [lc] docs-inject extension',
|
|
919
|
+
'# <!-- totem:fork reason="lc docs-inject pre-commit extension"',
|
|
920
|
+
'# owner="satur8d" attested="2026-06-07" -->',
|
|
921
|
+
'sh "tools/git-hooks/pre-commit-docs-inject.sh"',
|
|
922
|
+
'',
|
|
923
|
+
].join('\n');
|
|
924
|
+
expect(isAttestedTrailer(split)).toBe(false);
|
|
925
|
+
});
|
|
926
|
+
it('is false for a comment run carrying no full marker', () => {
|
|
927
|
+
expect(isAttestedTrailer('\n# [lc] docs-inject extension\n# <!-- totem:fork -->\nsh "x.sh"\n')).toBe(false);
|
|
928
|
+
});
|
|
929
|
+
// Core's parser captures the quoted value raw; a field that is only whitespace
|
|
930
|
+
// is present to a length check and absent to a reader (Greptile P2, round 1).
|
|
931
|
+
it('is false when any of the three fields is whitespace-only', () => {
|
|
932
|
+
const blankReason = '# <!-- totem:fork reason=" " owner="satur8d" attested="2026-06-07" -->';
|
|
933
|
+
const blankOwner = '# <!-- totem:fork reason="deploy notice" owner=" " attested="2026-06-07" -->';
|
|
934
|
+
const blankAttested = '# <!-- totem:fork reason="deploy notice" owner="satur8d" attested="\t" -->';
|
|
935
|
+
const allBlank = '# <!-- totem:fork reason=" " owner=" " attested=" " -->';
|
|
936
|
+
for (const line of [blankReason, blankOwner, blankAttested, allBlank]) {
|
|
937
|
+
expect(isAttestedTrailer(`\n${line}\nsh "x.sh"\n`), line).toBe(false);
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
});
|
|
941
|
+
// ─── declaredHookTier (mmnto-ai/totem#2753) ────────
|
|
942
|
+
describe('declaredHookTier', () => {
|
|
943
|
+
const block = (lines) => ['#!/bin/sh', `# ${TOTEM_PRECOMMIT_MARKER}`, ...lines, `# ${TOTEM_PRECOMMIT_END}`, ''].join('\n');
|
|
944
|
+
it('reads the assignment the template emits', () => {
|
|
945
|
+
expect(declaredHookTier(block(['TOTEM_HOOK_TIER="strict"']), TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe('strict');
|
|
946
|
+
});
|
|
947
|
+
// Gemini, round 1: an unanchored match would have read the COMMENT first and
|
|
948
|
+
// re-rendered a standard hook as strict — or, mirrored, a strict one as standard.
|
|
949
|
+
it('ignores a comment inside the block that quotes the assignment', () => {
|
|
950
|
+
const content = block([
|
|
951
|
+
'# TOTEM_HOOK_TIER="strict" turns on the spec-evidence gate below',
|
|
952
|
+
'TOTEM_HOOK_TIER="standard"',
|
|
953
|
+
]);
|
|
954
|
+
expect(declaredHookTier(content, TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe('standard');
|
|
955
|
+
});
|
|
956
|
+
it('is undefined when the only mention is a comment — a quoted assignment is not a declaration', () => {
|
|
957
|
+
const content = block(['# TOTEM_HOOK_TIER="strict" was the old default']);
|
|
958
|
+
expect(declaredHookTier(content, TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBeUndefined();
|
|
959
|
+
});
|
|
960
|
+
it('still reads a CRLF block', () => {
|
|
961
|
+
const content = block(['TOTEM_HOOK_TIER="strict"']).replace(/\n/g, '\r\n');
|
|
962
|
+
expect(declaredHookTier(content, TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe('strict');
|
|
963
|
+
});
|
|
964
|
+
});
|
|
965
|
+
// ─── the hook write is atomic (Greptile P1, mmnto-ai/totem#2760 round 1) ────────
|
|
966
|
+
describe('installGitHook writes atomically', () => {
|
|
967
|
+
// The attested-extension rewrite carries the consumer's own lines, which no
|
|
968
|
+
// template can regenerate — so the write must leave the old file or the new one,
|
|
969
|
+
// never a truncated hook. `writeExecutableHook` routes through core's
|
|
970
|
+
// `writeFileAtomicSync`: a same-directory temp, mode on the temp, rename last.
|
|
971
|
+
it('leaves no temp sibling behind and the rewritten hook is executable', () => {
|
|
972
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-atomic-'));
|
|
973
|
+
try {
|
|
974
|
+
const hooksDir = path.join(tmpDir, 'hooks');
|
|
975
|
+
fs.mkdirSync(hooksDir);
|
|
976
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
977
|
+
const canonical = buildPreCommitHook(RENDER);
|
|
978
|
+
fs.writeFileSync(hookPath, `#!/bin/sh\n# ${TOTEM_PRECOMMIT_MARKER}\nstale body\n# ${TOTEM_PRECOMMIT_END}\n${ATTESTED_TRAILER}`);
|
|
979
|
+
// The control that tells the two implementations apart (re-armed leg F1): an
|
|
980
|
+
// in-place `writeFileSync` truncates the SAME file — its inode survives — while
|
|
981
|
+
// rename-over replaces the directory entry with the temp's, so the inode
|
|
982
|
+
// changes. Cleanup and mode alone were satisfied by the old code too.
|
|
983
|
+
const inodeBefore = fs.statSync(hookPath, { bigint: true }).ino;
|
|
984
|
+
const action = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
985
|
+
expect(action).toBe('block-rewritten');
|
|
986
|
+
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(canonical + ATTESTED_TRAILER);
|
|
987
|
+
expect(fs.statSync(hookPath, { bigint: true }).ino).not.toBe(inodeBefore);
|
|
988
|
+
// The helper's temp is `<target>.<pid>-<uuid8>.tmp` beside the target; after a
|
|
989
|
+
// completed write the directory holds the hook alone.
|
|
990
|
+
expect(fs.readdirSync(hooksDir)).toEqual(['pre-commit']);
|
|
991
|
+
if (process.platform !== 'win32') {
|
|
992
|
+
expect(fs.statSync(hookPath).mode & 0o777).toBe(0o755);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
finally {
|
|
996
|
+
cleanTmpDir(tmpDir);
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
// The user's file is carried as BYTES (re-armed leg F8/F9): a hook that does not
|
|
1000
|
+
// round-trip UTF-8 — one cp1252 `0xE9` in a comment — must come back with that
|
|
1001
|
+
// byte, never with U+FFFD (EF BF BD) in its place. Decoding for the probes is
|
|
1002
|
+
// fine; re-encoding for the write is the corruption.
|
|
1003
|
+
const NON_UTF8_COMMENT = Buffer.from([0x23, 0x20, 0x63, 0x61, 0x66, 0xe9, 0x0a]); // "# caf\xE9\n"
|
|
1004
|
+
const REPLACEMENT_CHAR = Buffer.from([0xef, 0xbf, 0xbd]);
|
|
1005
|
+
it('appends to a user hook that is not valid UTF-8 without touching its bytes', () => {
|
|
1006
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-bytes-'));
|
|
1007
|
+
try {
|
|
1008
|
+
const hooksDir = path.join(tmpDir, 'hooks');
|
|
1009
|
+
fs.mkdirSync(hooksDir);
|
|
1010
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
1011
|
+
const userBytes = Buffer.concat([
|
|
1012
|
+
Buffer.from('#!/bin/sh\n', 'utf-8'),
|
|
1013
|
+
NON_UTF8_COMMENT,
|
|
1014
|
+
Buffer.from('echo "user hook"\n', 'utf-8'),
|
|
1015
|
+
]);
|
|
1016
|
+
fs.writeFileSync(hookPath, userBytes);
|
|
1017
|
+
const action = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
1018
|
+
expect(action).toBe('appended');
|
|
1019
|
+
const after = fs.readFileSync(hookPath);
|
|
1020
|
+
expect(after.subarray(0, userBytes.length).equals(userBytes)).toBe(true);
|
|
1021
|
+
expect(after.includes(REPLACEMENT_CHAR)).toBe(false);
|
|
1022
|
+
expect(after.toString('utf-8')).toContain(TOTEM_PRECOMMIT_MARKER);
|
|
1023
|
+
}
|
|
1024
|
+
finally {
|
|
1025
|
+
cleanTmpDir(tmpDir);
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
it('rewrites the managed block and carries a non-UTF-8 attested trailer through byte-for-byte', () => {
|
|
1029
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-bytes-'));
|
|
1030
|
+
try {
|
|
1031
|
+
const hooksDir = path.join(tmpDir, 'hooks');
|
|
1032
|
+
fs.mkdirSync(hooksDir);
|
|
1033
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
1034
|
+
const canonical = buildPreCommitHook(RENDER);
|
|
1035
|
+
// The real liquid-city trailer, then one comment line git would never see
|
|
1036
|
+
// as UTF-8. The attestation still parses (it sits above, in the decoded run).
|
|
1037
|
+
const trailerBytes = Buffer.concat([
|
|
1038
|
+
Buffer.from(ATTESTED_TRAILER, 'utf-8'),
|
|
1039
|
+
NON_UTF8_COMMENT,
|
|
1040
|
+
]);
|
|
1041
|
+
fs.writeFileSync(hookPath, Buffer.concat([
|
|
1042
|
+
Buffer.from(`#!/bin/sh\n# ${TOTEM_PRECOMMIT_MARKER}\nstale body\n# ${TOTEM_PRECOMMIT_END}\n`, 'utf-8'),
|
|
1043
|
+
trailerBytes,
|
|
1044
|
+
]));
|
|
1045
|
+
const first = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
1046
|
+
expect(first).toBe('block-rewritten');
|
|
1047
|
+
const after = fs.readFileSync(hookPath);
|
|
1048
|
+
const canonicalBytes = Buffer.from(canonical, 'utf-8');
|
|
1049
|
+
expect(after.subarray(0, canonicalBytes.length).equals(canonicalBytes)).toBe(true);
|
|
1050
|
+
expect(after.subarray(canonicalBytes.length).equals(trailerBytes)).toBe(true);
|
|
1051
|
+
expect(after.includes(REPLACEMENT_CHAR)).toBe(false);
|
|
1052
|
+
// Idempotent on the same bytes: the recomposed file equals the file on disk.
|
|
1053
|
+
// `exists` is unambiguous here — the decline below has its own action.
|
|
1054
|
+
const second = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
1055
|
+
expect(second).toBe('exists');
|
|
1056
|
+
expect(fs.readFileSync(hookPath).equals(after)).toBe(true);
|
|
1057
|
+
}
|
|
1058
|
+
finally {
|
|
1059
|
+
cleanTmpDir(tmpDir);
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
// The MANAGED REGION itself failing to decode is the one shape the rewrite arm
|
|
1063
|
+
// cannot serve (no provable byte offset), and it must not hide behind `exists`:
|
|
1064
|
+
// three silent runs against a doctor row prescribing the bare install is the
|
|
1065
|
+
// inert-instruction loop this slice exists to remove (re-armed leg F13). The
|
|
1066
|
+
// fixture is the realistic one: an ANSI-editor save turns the template's em dash
|
|
1067
|
+
// (E2 80 94) into the single cp1252 byte 0x97.
|
|
1068
|
+
it('reports skipped-non-utf8 when the managed region does not decode, leaves the file byte-identical, and --force still writes the canonical', () => {
|
|
1069
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-bytes-'));
|
|
1070
|
+
try {
|
|
1071
|
+
const hooksDir = path.join(tmpDir, 'hooks');
|
|
1072
|
+
fs.mkdirSync(hooksDir);
|
|
1073
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
1074
|
+
const canonical = buildPreCommitHook(RENDER);
|
|
1075
|
+
const canonicalBytes = Buffer.from(canonical, 'utf-8');
|
|
1076
|
+
const emDash = Buffer.from([0xe2, 0x80, 0x94]);
|
|
1077
|
+
const dashAt = canonicalBytes.indexOf(emDash);
|
|
1078
|
+
expect(dashAt).toBeGreaterThan(-1); // the template's marker line carries one
|
|
1079
|
+
const ansiSaved = Buffer.concat([
|
|
1080
|
+
canonicalBytes.subarray(0, dashAt),
|
|
1081
|
+
Buffer.from([0x97]),
|
|
1082
|
+
canonicalBytes.subarray(dashAt + emDash.length),
|
|
1083
|
+
Buffer.from(ATTESTED_TRAILER, 'utf-8'),
|
|
1084
|
+
]);
|
|
1085
|
+
fs.writeFileSync(hookPath, ansiSaved);
|
|
1086
|
+
const bare = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
1087
|
+
expect(bare).toBe('skipped-non-utf8');
|
|
1088
|
+
expect(fs.readFileSync(hookPath).equals(ansiSaved)).toBe(true);
|
|
1089
|
+
expect(fs.readdirSync(hooksDir)).toEqual(['pre-commit']);
|
|
1090
|
+
// The prescribed way out writes the canonical alone — nothing of the file is
|
|
1091
|
+
// carried, so the bytes never needed to decode.
|
|
1092
|
+
const forced = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, true, TOTEM_PRECOMMIT_END);
|
|
1093
|
+
expect(forced).toBe('overwritten');
|
|
1094
|
+
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(canonical);
|
|
1095
|
+
}
|
|
1096
|
+
finally {
|
|
1097
|
+
cleanTmpDir(tmpDir);
|
|
1098
|
+
}
|
|
1099
|
+
});
|
|
627
1100
|
});
|
|
628
1101
|
// ─── generateHookHelpers ────────────────────────────
|
|
629
1102
|
describe('generateHookHelpers', () => {
|
|
@@ -672,6 +1145,190 @@ describe('installHooksNonInteractive', () => {
|
|
|
672
1145
|
const result = await installHooksNonInteractive(tmpDir);
|
|
673
1146
|
expect(result).toBeNull();
|
|
674
1147
|
});
|
|
1148
|
+
// ── The installed tier survives a bare rewrite (mmnto-ai/totem#2753 fold F4) ──
|
|
1149
|
+
//
|
|
1150
|
+
// A bare install exists to keep a hook CURRENT. Re-rendering a `--strict` hook at
|
|
1151
|
+
// `standard` because nobody re-stated the tier is a silent enforcement downgrade
|
|
1152
|
+
// performed by the very command the doctor's stale-block remedy sends people to.
|
|
1153
|
+
it('keeps a strict-installed hook strict on a bare rewrite past an attested trailer', async () => {
|
|
1154
|
+
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
1155
|
+
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1156
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1157
|
+
const strict = { ...RENDER, tier: 'strict', fallbackCmd: getFallbackCommand(tmpDir) };
|
|
1158
|
+
fs.writeFileSync(path.join(hooksDir, 'pre-commit'), withStaleComment(buildPreCommitHook(strict), TOTEM_PRECOMMIT_END) + ATTESTED_TRAILER);
|
|
1159
|
+
fs.writeFileSync(path.join(hooksDir, 'pre-push'), withStaleComment(buildPrePushHook(strict), TOTEM_PREPUSH_END) + ATTESTED_TRAILER);
|
|
1160
|
+
// No flag, no config `hooks.tier` — the hooks' own declaration must decide.
|
|
1161
|
+
const result = await installHooksNonInteractive(tmpDir);
|
|
1162
|
+
expect(result.preCommit).toBe('block-rewritten');
|
|
1163
|
+
expect(result.prePush).toBe('block-rewritten');
|
|
1164
|
+
const preCommit = fs.readFileSync(path.join(hooksDir, 'pre-commit'), 'utf-8');
|
|
1165
|
+
const prePush = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1166
|
+
expect(declaredHookTier(preCommit, TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe('strict');
|
|
1167
|
+
expect(declaredHookTier(prePush, TOTEM_PREPUSH_MARKER, TOTEM_PREPUSH_END)).toBe('strict');
|
|
1168
|
+
expect(preCommit).toBe(buildPreCommitHook(strict) + ATTESTED_TRAILER);
|
|
1169
|
+
expect(prePush.endsWith(ATTESTED_TRAILER)).toBe(true);
|
|
1170
|
+
});
|
|
1171
|
+
it('keeps a strict-installed OWNED-WHOLE hook strict on a bare drift-repair', async () => {
|
|
1172
|
+
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
1173
|
+
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1174
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1175
|
+
const strict = { ...RENDER, tier: 'strict', fallbackCmd: getFallbackCommand(tmpDir) };
|
|
1176
|
+
fs.writeFileSync(path.join(hooksDir, 'pre-commit'), withStaleComment(buildPreCommitHook(strict), TOTEM_PRECOMMIT_END));
|
|
1177
|
+
const result = await installHooksNonInteractive(tmpDir);
|
|
1178
|
+
expect(result.preCommit).toBe('overwritten');
|
|
1179
|
+
expect(fs.readFileSync(path.join(hooksDir, 'pre-commit'), 'utf-8')).toBe(buildPreCommitHook(strict));
|
|
1180
|
+
});
|
|
1181
|
+
it('lets an explicit tier flag win over the installed hook declaration', async () => {
|
|
1182
|
+
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
1183
|
+
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1184
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1185
|
+
const strict = { ...RENDER, tier: 'strict', fallbackCmd: getFallbackCommand(tmpDir) };
|
|
1186
|
+
fs.writeFileSync(path.join(hooksDir, 'pre-commit'), withStaleComment(buildPreCommitHook(strict), TOTEM_PRECOMMIT_END) + ATTESTED_TRAILER);
|
|
1187
|
+
const downgraded = await installHooksNonInteractive(tmpDir, false, { tier: 'standard' });
|
|
1188
|
+
expect(downgraded.preCommit).toBe('block-rewritten');
|
|
1189
|
+
expect(declaredHookTier(fs.readFileSync(path.join(hooksDir, 'pre-commit'), 'utf-8'), TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe('standard');
|
|
1190
|
+
const upgraded = await installHooksNonInteractive(tmpDir, false, { tier: 'strict' });
|
|
1191
|
+
expect(upgraded.preCommit).toBe('block-rewritten');
|
|
1192
|
+
expect(declaredHookTier(fs.readFileSync(path.join(hooksDir, 'pre-commit'), 'utf-8'), TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe('strict');
|
|
1193
|
+
});
|
|
1194
|
+
// ── The flag wins when NO config resolves (fold 3 F1) ──
|
|
1195
|
+
//
|
|
1196
|
+
// The test above passes on any machine that happens to carry a global profile at
|
|
1197
|
+
// `~/.totem/totem.config.ts`, because that sends `resolveHookRenderOptions` down
|
|
1198
|
+
// its fully-resolved return. The two EARLY returns — no config anywhere, and a
|
|
1199
|
+
// config that will not load — handed back a `defaults` object with no
|
|
1200
|
+
// `tierPinned`, so the installed hook's declaration overrode the flag. These two
|
|
1201
|
+
// pin those returns with the home directory controlled, so no global profile can
|
|
1202
|
+
// resolve and the outcome does not depend on whose machine runs them.
|
|
1203
|
+
/** Run `body` with `os.homedir()` pointed at an empty directory. */
|
|
1204
|
+
async function withEmptyHome(body) {
|
|
1205
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-home-'));
|
|
1206
|
+
const saved = { USERPROFILE: process.env.USERPROFILE, HOME: process.env.HOME };
|
|
1207
|
+
process.env.USERPROFILE = home;
|
|
1208
|
+
process.env.HOME = home;
|
|
1209
|
+
try {
|
|
1210
|
+
return await body();
|
|
1211
|
+
}
|
|
1212
|
+
finally {
|
|
1213
|
+
if (saved.USERPROFILE === undefined)
|
|
1214
|
+
delete process.env.USERPROFILE;
|
|
1215
|
+
else
|
|
1216
|
+
process.env.USERPROFILE = saved.USERPROFILE;
|
|
1217
|
+
if (saved.HOME === undefined)
|
|
1218
|
+
delete process.env.HOME;
|
|
1219
|
+
else
|
|
1220
|
+
process.env.HOME = saved.HOME;
|
|
1221
|
+
cleanTmpDir(home);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
it('lets an explicit tier flag win when NO config resolves at all', async () => {
|
|
1225
|
+
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
1226
|
+
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1227
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1228
|
+
const strict = { ...RENDER, tier: 'strict', fallbackCmd: getFallbackCommand(tmpDir) };
|
|
1229
|
+
fs.writeFileSync(path.join(hooksDir, 'pre-commit'), withStaleComment(buildPreCommitHook(strict), TOTEM_PRECOMMIT_END) + ATTESTED_TRAILER);
|
|
1230
|
+
await withEmptyHome(async () => {
|
|
1231
|
+
// No local config in tmpDir, no global profile under the empty home →
|
|
1232
|
+
// `resolveConfigPath` throws and the config-missing early return is taken.
|
|
1233
|
+
const result = await installHooksNonInteractive(tmpDir, false, { tier: 'standard' });
|
|
1234
|
+
expect(result.preCommit).toBe('block-rewritten');
|
|
1235
|
+
});
|
|
1236
|
+
expect(declaredHookTier(fs.readFileSync(path.join(hooksDir, 'pre-commit'), 'utf-8'), TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe('standard');
|
|
1237
|
+
});
|
|
1238
|
+
it('lets --strict UPGRADE a standard-installed hook when no config resolves', async () => {
|
|
1239
|
+
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
1240
|
+
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1241
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1242
|
+
const standard = { ...RENDER, fallbackCmd: getFallbackCommand(tmpDir) };
|
|
1243
|
+
fs.writeFileSync(path.join(hooksDir, 'pre-commit'), withStaleComment(buildPreCommitHook(standard), TOTEM_PRECOMMIT_END));
|
|
1244
|
+
await withEmptyHome(async () => {
|
|
1245
|
+
const result = await installHooksNonInteractive(tmpDir, false, { tier: 'strict' });
|
|
1246
|
+
expect(result.preCommit).toBe('overwritten');
|
|
1247
|
+
});
|
|
1248
|
+
expect(declaredHookTier(fs.readFileSync(path.join(hooksDir, 'pre-commit'), 'utf-8'), TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe('strict');
|
|
1249
|
+
});
|
|
1250
|
+
it('lets an explicit tier flag win when the config RESOLVES but will not load', async () => {
|
|
1251
|
+
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
1252
|
+
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1253
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1254
|
+
const strict = { ...RENDER, tier: 'strict', fallbackCmd: getFallbackCommand(tmpDir) };
|
|
1255
|
+
fs.writeFileSync(path.join(hooksDir, 'pre-commit'), withStaleComment(buildPreCommitHook(strict), TOTEM_PRECOMMIT_END) + ATTESTED_TRAILER);
|
|
1256
|
+
// A local config that resolves and then fails to parse → the LOUD-defaults
|
|
1257
|
+
// early return (mmnto-ai/totem#2692 A8).
|
|
1258
|
+
fs.writeFileSync(path.join(tmpDir, 'totem.yaml'), ':\n not: [valid\n');
|
|
1259
|
+
const stderr = [];
|
|
1260
|
+
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
1261
|
+
stderr.push(args.map(String).join(' '));
|
|
1262
|
+
});
|
|
1263
|
+
try {
|
|
1264
|
+
await withEmptyHome(async () => {
|
|
1265
|
+
const result = await installHooksNonInteractive(tmpDir, false, { tier: 'standard' });
|
|
1266
|
+
expect(result.preCommit).toBe('block-rewritten');
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
finally {
|
|
1270
|
+
spy.mockRestore();
|
|
1271
|
+
}
|
|
1272
|
+
expect(declaredHookTier(fs.readFileSync(path.join(hooksDir, 'pre-commit'), 'utf-8'), TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe('standard');
|
|
1273
|
+
// The loud line must not claim a tier it does not decide (fold 4 F6): the
|
|
1274
|
+
// defaults it names are the totemDir only.
|
|
1275
|
+
const loud = stderr.find((line) => line.includes('rendered at the defaults')) ?? '';
|
|
1276
|
+
expect(loud).toContain("the tier follows an explicit flag, else the tier each installed hook declares, else 'standard'");
|
|
1277
|
+
expect(loud).not.toContain("tier 'standard')");
|
|
1278
|
+
});
|
|
1279
|
+
it('lets --strict UPGRADE a standard-installed hook when the config will not load', async () => {
|
|
1280
|
+
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
1281
|
+
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1282
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1283
|
+
const standard = { ...RENDER, fallbackCmd: getFallbackCommand(tmpDir) };
|
|
1284
|
+
fs.writeFileSync(path.join(hooksDir, 'pre-commit'), withStaleComment(buildPreCommitHook(standard), TOTEM_PRECOMMIT_END));
|
|
1285
|
+
fs.writeFileSync(path.join(tmpDir, 'totem.yaml'), ':\n not: [valid\n');
|
|
1286
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
1287
|
+
try {
|
|
1288
|
+
await withEmptyHome(async () => {
|
|
1289
|
+
const result = await installHooksNonInteractive(tmpDir, false, { tier: 'strict' });
|
|
1290
|
+
expect(result.preCommit).toBe('overwritten');
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
finally {
|
|
1294
|
+
spy.mockRestore();
|
|
1295
|
+
}
|
|
1296
|
+
expect(declaredHookTier(fs.readFileSync(path.join(hooksDir, 'pre-commit'), 'utf-8'), TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe('strict');
|
|
1297
|
+
});
|
|
1298
|
+
it('renders --force at the installed tier when nothing is pinned', async () => {
|
|
1299
|
+
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
1300
|
+
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1301
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1302
|
+
const strict = { ...RENDER, tier: 'strict', fallbackCmd: getFallbackCommand(tmpDir) };
|
|
1303
|
+
fs.writeFileSync(path.join(hooksDir, 'pre-commit'), withStaleComment(buildPreCommitHook(strict), TOTEM_PRECOMMIT_END) + ATTESTED_TRAILER);
|
|
1304
|
+
// `--force` rewrites the WHOLE file (the trailer is gone) but it is still not a
|
|
1305
|
+
// tier decision: with no flag and no `hooks.tier`, the hook keeps the tier it
|
|
1306
|
+
// declared.
|
|
1307
|
+
await withEmptyHome(async () => {
|
|
1308
|
+
const result = await installHooksNonInteractive(tmpDir, true);
|
|
1309
|
+
expect(result.preCommit).toBe('overwritten');
|
|
1310
|
+
});
|
|
1311
|
+
const written = fs.readFileSync(path.join(hooksDir, 'pre-commit'), 'utf-8');
|
|
1312
|
+
expect(declaredHookTier(written, TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe('strict');
|
|
1313
|
+
expect(written).not.toContain('totem:fork');
|
|
1314
|
+
});
|
|
1315
|
+
it('keeps a strict hook-manager helper strict when nothing is pinned', async () => {
|
|
1316
|
+
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
1317
|
+
// A husky repo: `installHooksNonInteractive` takes the manager path, which
|
|
1318
|
+
// regenerates `<totemDir>/hooks/*.sh` instead of writing .git/hooks.
|
|
1319
|
+
fs.mkdirSync(path.join(tmpDir, '.husky'), { recursive: true });
|
|
1320
|
+
const helperDir = path.join(tmpDir, '.totem', 'hooks');
|
|
1321
|
+
fs.mkdirSync(helperDir, { recursive: true });
|
|
1322
|
+
const strict = { ...RENDER, tier: 'strict', fallbackCmd: getFallbackCommand(tmpDir) };
|
|
1323
|
+
fs.writeFileSync(path.join(helperDir, 'pre-push.sh'), withStaleComment(buildPrePushHook(strict), TOTEM_PREPUSH_END));
|
|
1324
|
+
fs.writeFileSync(path.join(helperDir, 'pre-commit.sh'), withStaleComment(buildPreCommitHook(strict), TOTEM_PRECOMMIT_END));
|
|
1325
|
+
await withEmptyHome(async () => {
|
|
1326
|
+
// Manager detected → null result, helpers regenerated.
|
|
1327
|
+
expect(await installHooksNonInteractive(tmpDir)).toBeNull();
|
|
1328
|
+
});
|
|
1329
|
+
expect(declaredHookTier(fs.readFileSync(path.join(helperDir, 'pre-push.sh'), 'utf-8'), TOTEM_PREPUSH_MARKER, TOTEM_PREPUSH_END)).toBe('strict');
|
|
1330
|
+
expect(declaredHookTier(fs.readFileSync(path.join(helperDir, 'pre-commit.sh'), 'utf-8'), TOTEM_PRECOMMIT_MARKER, TOTEM_PRECOMMIT_END)).toBe('strict');
|
|
1331
|
+
});
|
|
675
1332
|
it('installs all four hooks in a git repo', async () => {
|
|
676
1333
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
677
1334
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
@@ -1140,6 +1797,22 @@ fi
|
|
|
1140
1797
|
const actual = extractTotemBlock(content);
|
|
1141
1798
|
expect(actual).toBe(expectedTotemBlock());
|
|
1142
1799
|
});
|
|
1800
|
+
// The splice keeps the user's text on both sides of the block, so it is byte-exact
|
|
1801
|
+
// only for a file that decodes losslessly; one that does not is declined with the
|
|
1802
|
+
// upgrader's ruled silent `false` and left byte-identical (re-armed leg F9/F15).
|
|
1803
|
+
it('declines a hook that does not decode as UTF-8 and leaves it byte-identical', async () => {
|
|
1804
|
+
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1805
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1806
|
+
const oldHook = Buffer.concat([
|
|
1807
|
+
Buffer.from(`#!/bin/sh\n# ${TOTEM_PREPUSH_MARKER} `, 'utf-8'),
|
|
1808
|
+
Buffer.from([0x97]), // the em dash as one cp1252 byte — an ANSI-editor save
|
|
1809
|
+
Buffer.from(' run compiled rules before push.\n# Override with: git push --no-verify\n\nif [ -f ".totem/compiled-rules.json" ]; then\n TOTEM_CMD="totem"\n if [ -n "$TOTEM_CMD" ]; then\n $TOTEM_CMD lint\n fi\nfi\n', 'utf-8'),
|
|
1810
|
+
]);
|
|
1811
|
+
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
|
|
1812
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1813
|
+
expect(upgraded).toBe(false);
|
|
1814
|
+
expect(fs.readFileSync(path.join(hooksDir, 'pre-push')).equals(oldHook)).toBe(true);
|
|
1815
|
+
});
|
|
1143
1816
|
it('skips hook without totem marker', async () => {
|
|
1144
1817
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1145
1818
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
@@ -1410,7 +2083,15 @@ describe('buildPreCommitHook with strict tier', () => {
|
|
|
1410
2083
|
it('carries the exit-3 arm distinctly from the exit-2 and reader-failure arms', () => {
|
|
1411
2084
|
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1412
2085
|
expect(hook).toContain('elif [ "$reader_status" = "3" ]; then');
|
|
1413
|
-
|
|
2086
|
+
// printf, not echo (mmnto-ai/totem#2737 fold 3): `dash` — /bin/sh on Debian
|
|
2087
|
+
// and Ubuntu — expands backslash escapes in `echo`, so a literal `\n` in an
|
|
2088
|
+
// artifact value forged a second [Totem] line there while staying inert
|
|
2089
|
+
// under bash. The two sinks that carry $spec_evidence print literally.
|
|
2090
|
+
const bs = String.fromCharCode(0x5c);
|
|
2091
|
+
expect(hook).toContain(`printf '%s${bs}n' "[Totem] BLOCKED: $spec_evidence — run 'totem spec <issue>' or 'totem spec --from <record>' (add --fresh if the response is cached) (strict mode)"`);
|
|
2092
|
+
expect(hook).toContain(`printf '%s${bs}n' "[Totem] spec evidence: $spec_evidence"`);
|
|
2093
|
+
expect(hook).not.toContain('echo "[Totem] spec evidence:');
|
|
2094
|
+
expect(hook).not.toContain('echo "[Totem] BLOCKED: $spec_evidence');
|
|
1414
2095
|
// The two pre-existing arms keep their exact text.
|
|
1415
2096
|
expect(hook).toContain('no totem spec run artifact under .totem/artifacts/runs/');
|
|
1416
2097
|
expect(hook).toContain('the spec-evidence reader could not run (node exit status');
|
|
@@ -1660,7 +2341,7 @@ describe('buildPreCommitHook anchored evidence — executed under sh (mmnto-ai/t
|
|
|
1660
2341
|
expect(r.stdout).toContain('is missing heading ### Problem Statement');
|
|
1661
2342
|
expectDistinctBlock(r.stdout);
|
|
1662
2343
|
});
|
|
1663
|
-
it.skipIf(!shellOk)('
|
|
2344
|
+
it.skipIf(!shellOk)('a promised heading present over an EMPTY body BLOCKS, naming the empty heading', () => {
|
|
1664
2345
|
writeRun('empty.json', specEvidenceArtifact({
|
|
1665
2346
|
output: { content: '### Problem Statement\n\n### Implementation Tasks\n' },
|
|
1666
2347
|
}));
|
|
@@ -1669,10 +2350,17 @@ describe('buildPreCommitHook anchored evidence — executed under sh (mmnto-ai/t
|
|
|
1669
2350
|
expect(r.stdout).toContain('has an empty heading ### Problem Statement');
|
|
1670
2351
|
expectDistinctBlock(r.stdout);
|
|
1671
2352
|
});
|
|
1672
|
-
it.skipIf(!shellOk)('a
|
|
2353
|
+
it.skipIf(!shellOk)('a LATER required heading left empty BLOCKS, naming THAT heading', () => {
|
|
2354
|
+
// Every heading the promise names BEFORE `### Implementation Tasks` gets a
|
|
2355
|
+
// body, so the reader reaches it rather than blocking earlier on a missing
|
|
2356
|
+
// one: the assertion is about which heading is NAMED, and it can only be
|
|
2357
|
+
// about that if the draft is well-formed right up to the mutation
|
|
2358
|
+
// (mmnto-ai/totem#2737 extended the promise from two headings to nine).
|
|
1673
2359
|
writeRun('half.json', specEvidenceArtifact({
|
|
1674
2360
|
output: {
|
|
1675
|
-
content:
|
|
2361
|
+
content: SPEC_REQUIRED_SECTIONS.map((heading) => heading === '### Implementation Tasks'
|
|
2362
|
+
? `${heading}\n`
|
|
2363
|
+
: `${heading}\n\nA real body.\n`).join('\n'),
|
|
1676
2364
|
},
|
|
1677
2365
|
}));
|
|
1678
2366
|
const r = runHook();
|
|
@@ -1848,6 +2536,68 @@ describe('buildPreCommitHook anchored evidence — executed under sh (mmnto-ai/t
|
|
|
1848
2536
|
expect(r.stdout).toContain('slug?[Totem] spec evidence: forged');
|
|
1849
2537
|
expect(r.stdout).not.toContain(String.fromCharCode(0x85));
|
|
1850
2538
|
});
|
|
2539
|
+
it.skipIf(!shellOk)('a U+2028 in anchor.ref is collapsed and cannot forge a second [Totem] line', () => {
|
|
2540
|
+
// The third member of the newline family: U+2028 LINE SEPARATOR is a
|
|
2541
|
+
// PRINTABLE-plane code point that terminals and pagers still break on, so
|
|
2542
|
+
// a `safe()` stopping at the C1 band left it through (mmnto-ai/totem#2737).
|
|
2543
|
+
const ls = String.fromCharCode(0x2028);
|
|
2544
|
+
const forged = `slug${ls}[Totem] spec evidence: forged`;
|
|
2545
|
+
writeRun('ls.json', specEvidenceArtifact({
|
|
2546
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: forged } },
|
|
2547
|
+
}));
|
|
2548
|
+
const r = runHook();
|
|
2549
|
+
expect(r.status).toBe(1);
|
|
2550
|
+
expect(r.stdout).toContain('slug?[Totem] spec evidence: forged');
|
|
2551
|
+
expect(r.stdout).not.toContain(ls);
|
|
2552
|
+
expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
|
|
2553
|
+
});
|
|
2554
|
+
// ── The forge that lives in PRINTABLE bytes (mmnto-ai/totem#2737 fold 3) ──
|
|
2555
|
+
//
|
|
2556
|
+
// `safe()` cannot close this one: a literal backslash followed by `n` is two
|
|
2557
|
+
// printable characters (0x5c, 0x6e), so it passes every control-character
|
|
2558
|
+
// predicate untouched. The expansion happens at the SHELL, wherever `/bin/sh`
|
|
2559
|
+
// expands backslash escapes in `echo` — `dash` on Debian and Ubuntu, and
|
|
2560
|
+
// macOS's `/bin/sh`, a bash built with `xpg_echo` on — where the pair becomes
|
|
2561
|
+
// a real newline and forges the second `[Totem]` line. The cure is the sink,
|
|
2562
|
+
// not the sanitizer: both values print through `printf '%s\n'`, which treats
|
|
2563
|
+
// its argument as literal text on every POSIX shell.
|
|
2564
|
+
//
|
|
2565
|
+
// NOTE ON REACH: `sh` on this machine is Git Bash's bash, whose `echo` does
|
|
2566
|
+
// NOT expand the escape, so these two tests pass here even against the
|
|
2567
|
+
// unfixed hook. Git Bash and a plain bash are the only shells that leave it
|
|
2568
|
+
// inert; these are real falsifiers on the Ubuntu and macOS CI legs. Measured
|
|
2569
|
+
// directly against `/usr/bin/dash` before the fix: TWO [Totem] lines; after:
|
|
2570
|
+
// one.
|
|
2571
|
+
it.skipIf(!shellOk)('a literal backslash-n in anchor.ref cannot forge a second [Totem] line under any /bin/sh', () => {
|
|
2572
|
+
const bs = String.fromCharCode(0x5c);
|
|
2573
|
+
const forged = `2737${bs}n[Totem] spec evidence: FORGED`;
|
|
2574
|
+
writeRun('backslash-n.json', specEvidenceArtifact({
|
|
2575
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: forged } },
|
|
2576
|
+
}));
|
|
2577
|
+
const r = runHook();
|
|
2578
|
+
expect(r.status).toBe(1);
|
|
2579
|
+
expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
|
|
2580
|
+
// The two characters survive as themselves — not expanded, not stripped.
|
|
2581
|
+
expect(r.stdout).toContain(`2737${bs}n[Totem] spec evidence: FORGED`);
|
|
2582
|
+
});
|
|
2583
|
+
it.skipIf(!shellOk)('a literal backslash-n in a tolerantly-matched heading cannot forge a second [Totem] line', () => {
|
|
2584
|
+
// The same payload on the fold's newest sink: the `(matched as …)` clause
|
|
2585
|
+
// echoes a line taken from the DRAFT, which is attacker-shaped text.
|
|
2586
|
+
const bs = String.fromCharCode(0x5c);
|
|
2587
|
+
const promised = SPEC_SYSTEM_PROMPT.split('\n').filter((line) => line.startsWith('### '));
|
|
2588
|
+
const verification = promised.find((h) => h.startsWith('### Verification')) ?? '';
|
|
2589
|
+
const forgedHeading = `### Verification (${bs}n[Totem] spec evidence: FORGED)`;
|
|
2590
|
+
const content = promised
|
|
2591
|
+
.map((heading) => heading === verification
|
|
2592
|
+
? `${forgedHeading}\n`
|
|
2593
|
+
: `${heading}\n\nA non-blank body under ${heading}.\n`)
|
|
2594
|
+
.join('\n');
|
|
2595
|
+
writeRun('tolerant-forge.json', specEvidenceArtifact({ output: { content } }));
|
|
2596
|
+
const r = runHook();
|
|
2597
|
+
expect(r.status).toBe(1);
|
|
2598
|
+
expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
|
|
2599
|
+
expect(r.stdout).toContain(`(matched as ${forgedHeading})`);
|
|
2600
|
+
});
|
|
1851
2601
|
it.skipIf(!shellOk)('a newline in createdAt cannot forge a second [Totem] line on the PASS path', () => {
|
|
1852
2602
|
// `createdAt` is read raw off the artifact and ECHOED in the evidence
|
|
1853
2603
|
// line: unsanitized, a hand-edited stamp forges a second `[Totem] spec
|
|
@@ -2625,4 +3375,322 @@ describe('the review-leg floor arm COMPOSED with the real gate (mmnto-ai/totem#2
|
|
|
2625
3375
|
expect(r.stdout).not.toContain('BLOCKED');
|
|
2626
3376
|
});
|
|
2627
3377
|
});
|
|
3378
|
+
// ─── The frozen mutation suite (mmnto-ai/totem#2737) ─────
|
|
3379
|
+
//
|
|
3380
|
+
// Authored against the FINAL contract — all NINE promised headings — and run
|
|
3381
|
+
// once against the UNCHANGED reader before either predicate moved, then frozen:
|
|
3382
|
+
// never edited by the commits that follow it. The RED/GREEN pattern it printed
|
|
3383
|
+
// on the unchanged reader is what makes each case evidence of a predicate
|
|
3384
|
+
// rather than a transcription of whatever the implementation happened to do.
|
|
3385
|
+
//
|
|
3386
|
+
// The nine come off SPEC_SYSTEM_PROMPT, not SPEC_REQUIRED_SECTIONS: the
|
|
3387
|
+
// constant still named two headings when this was frozen, and reading the
|
|
3388
|
+
// prompt keeps the suite byte-identical across all three commits. It is also
|
|
3389
|
+
// how no case here ever retypes the em dash in `### Verification (MANDATORY —
|
|
3390
|
+
// do not skip)` (the mmnto-ai/totem#2692 authoring trap). After the extension
|
|
3391
|
+
// the two surfaces are the same nine strings in the same order.
|
|
3392
|
+
describe('buildPreCommitHook spec-gate reader — frozen mutation suite (mmnto-ai/totem#2737)', () => {
|
|
3393
|
+
const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
|
|
3394
|
+
/** The nine `### ` lines of the built-in prompt, in prompt order. */
|
|
3395
|
+
const PROMISED = SPEC_SYSTEM_PROMPT.split('\n').filter((line) => line.startsWith('### '));
|
|
3396
|
+
/** Pick a promised heading by an ASCII prefix — never retype the em dash. */
|
|
3397
|
+
function promised(prefix) {
|
|
3398
|
+
const found = PROMISED.find((heading) => heading.startsWith(prefix));
|
|
3399
|
+
if (found === undefined)
|
|
3400
|
+
throw new Error(`no promised heading starts with ${prefix}`);
|
|
3401
|
+
return found;
|
|
3402
|
+
}
|
|
3403
|
+
const PROBLEM_STATEMENT = promised('### Problem Statement');
|
|
3404
|
+
const FILES_TO_EXAMINE = promised('### Files to Examine');
|
|
3405
|
+
const EDGE_CASES = promised('### Edge Cases');
|
|
3406
|
+
const IMPLEMENTATION_TASKS = promised('### Implementation Tasks');
|
|
3407
|
+
const EXECUTION_FLOW = promised('### Execution Flow');
|
|
3408
|
+
const VERIFICATION = promised('### Verification');
|
|
3409
|
+
const TEST_PLAN = promised('### Test Plan');
|
|
3410
|
+
/** A TAB, built rather than escaped (the mmnto-ai/totem#2692 authoring trap). */
|
|
3411
|
+
const TAB = String.fromCharCode(9);
|
|
3412
|
+
/**
|
|
3413
|
+
* All nine promised headings, each over one plain body line. An override
|
|
3414
|
+
* replaces exactly ONE heading's whole block, so each case changes one thing;
|
|
3415
|
+
* an empty override DROPS that heading from the draft entirely.
|
|
3416
|
+
*/
|
|
3417
|
+
function nineBodied(overrides = {}) {
|
|
3418
|
+
return PROMISED.map((heading) => {
|
|
3419
|
+
const override = overrides[heading];
|
|
3420
|
+
return override === undefined
|
|
3421
|
+
? `${heading}\n\nA non-blank body under ${heading}.\n`
|
|
3422
|
+
: override;
|
|
3423
|
+
})
|
|
3424
|
+
.filter((block) => block.length > 0)
|
|
3425
|
+
.join('\n');
|
|
3426
|
+
}
|
|
3427
|
+
let tmpDir;
|
|
3428
|
+
beforeEach(() => {
|
|
3429
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-2737-'));
|
|
3430
|
+
execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
|
|
3431
|
+
execSync('git checkout -q -b feat/frozen-suite', { cwd: tmpDir, stdio: 'ignore' });
|
|
3432
|
+
fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook(RENDER));
|
|
3433
|
+
});
|
|
3434
|
+
afterEach(() => {
|
|
3435
|
+
cleanTmpDir(tmpDir);
|
|
3436
|
+
});
|
|
3437
|
+
function runHook() {
|
|
3438
|
+
const r = spawnSync('sh', ['./pre-commit'], {
|
|
3439
|
+
cwd: tmpDir,
|
|
3440
|
+
encoding: 'utf-8',
|
|
3441
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
3442
|
+
});
|
|
3443
|
+
return { status: r.status, stdout: r.stdout };
|
|
3444
|
+
}
|
|
3445
|
+
function writeRun(name, artifact) {
|
|
3446
|
+
const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
|
|
3447
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3448
|
+
fs.writeFileSync(path.join(dir, name), JSON.stringify(artifact, null, 2));
|
|
3449
|
+
}
|
|
3450
|
+
/** Every BLOCKED arm must be distinguishable from the other two arms. */
|
|
3451
|
+
function expectDistinctBlock(stdout) {
|
|
3452
|
+
expect(stdout).not.toContain('no totem spec run artifact');
|
|
3453
|
+
expect(stdout).not.toContain('the spec-evidence reader could not run');
|
|
3454
|
+
}
|
|
3455
|
+
/** Judge one draft written under the BUILT-IN prompt on an ISSUE anchor. */
|
|
3456
|
+
function judge(content) {
|
|
3457
|
+
writeRun('draft.json', specEvidenceArtifact({ output: { content } }));
|
|
3458
|
+
return runHook();
|
|
3459
|
+
}
|
|
3460
|
+
// ── Positive mutations: each must PASS ──
|
|
3461
|
+
it.skipIf(!shellOk)('a section that opens with a deeper heading is not empty', () => {
|
|
3462
|
+
const r = judge(nineBodied({
|
|
3463
|
+
[IMPLEMENTATION_TASKS]: `${IMPLEMENTATION_TASKS}\n#### 1. Sub\nA step under the sub-heading.\n`,
|
|
3464
|
+
}));
|
|
3465
|
+
expect(r.status, r.stdout).toBe(0);
|
|
3466
|
+
expect(r.stdout).toContain('· shape TEMPLATE');
|
|
3467
|
+
});
|
|
3468
|
+
it.skipIf(!shellOk)('a promised heading with its parenthetical dropped matches and is named', () => {
|
|
3469
|
+
const r = judge(nineBodied({
|
|
3470
|
+
[EXECUTION_FLOW]: '### Execution Flow\n\nA body under the shortened heading.\n',
|
|
3471
|
+
[VERIFICATION]: '### Verification\n\nA body under the shortened heading.\n',
|
|
3472
|
+
}));
|
|
3473
|
+
expect(r.status, r.stdout).toBe(0);
|
|
3474
|
+
expect(r.stdout).toContain(`· tolerated ${EXECUTION_FLOW} ~ ### Execution Flow`);
|
|
3475
|
+
expect(r.stdout).toContain(`${VERIFICATION} ~ ### Verification`);
|
|
3476
|
+
});
|
|
3477
|
+
it.skipIf(!shellOk)('trailing whitespace on a heading line is tolerated silently', () => {
|
|
3478
|
+
const r = judge(nineBodied({
|
|
3479
|
+
[PROBLEM_STATEMENT]: `${PROBLEM_STATEMENT} ${TAB}\n\nA body under it.\n`,
|
|
3480
|
+
}));
|
|
3481
|
+
expect(r.status, r.stdout).toBe(0);
|
|
3482
|
+
expect(r.stdout).toContain('· shape TEMPLATE');
|
|
3483
|
+
expect(r.stdout).not.toContain('· tolerated');
|
|
3484
|
+
});
|
|
3485
|
+
it.skipIf(!shellOk)('a different trailing parenthetical matches and is named', () => {
|
|
3486
|
+
const r = judge(nineBodied({ [VERIFICATION]: '### Verification (required)\n\nA body under it.\n' }));
|
|
3487
|
+
expect(r.status, r.stdout).toBe(0);
|
|
3488
|
+
expect(r.stdout).toContain('· tolerated');
|
|
3489
|
+
expect(r.stdout).toContain(`${VERIFICATION} ~ ### Verification (required)`);
|
|
3490
|
+
});
|
|
3491
|
+
// ── Negative mutations: each must BLOCK, by name ──
|
|
3492
|
+
it.skipIf(!shellOk)('a genuinely absent promised heading blocks by name', () => {
|
|
3493
|
+
const r = judge(nineBodied({ [TEST_PLAN]: '' }));
|
|
3494
|
+
expect(r.status).toBe(1);
|
|
3495
|
+
expect(r.stdout).toContain(`is missing heading ${TEST_PLAN}`);
|
|
3496
|
+
expectDistinctBlock(r.stdout);
|
|
3497
|
+
});
|
|
3498
|
+
it.skipIf(!shellOk)('a genuinely empty section blocks by name', () => {
|
|
3499
|
+
const r = judge(nineBodied({ [PROBLEM_STATEMENT]: PROBLEM_STATEMENT }));
|
|
3500
|
+
expect(r.status).toBe(1);
|
|
3501
|
+
expect(r.stdout).toContain(`has an empty heading ${PROBLEM_STATEMENT}`);
|
|
3502
|
+
expectDistinctBlock(r.stdout);
|
|
3503
|
+
});
|
|
3504
|
+
it.skipIf(!shellOk)('a whitespace-only body is empty', () => {
|
|
3505
|
+
const r = judge(nineBodied({ [FILES_TO_EXAMINE]: `${FILES_TO_EXAMINE}\n \n${TAB}${TAB}\n\n` }));
|
|
3506
|
+
expect(r.status).toBe(1);
|
|
3507
|
+
expect(r.stdout).toContain(`has an empty heading ${FILES_TO_EXAMINE}`);
|
|
3508
|
+
expectDistinctBlock(r.stdout);
|
|
3509
|
+
});
|
|
3510
|
+
it.skipIf(!shellOk)('a deeper heading with nothing under it is not a body', () => {
|
|
3511
|
+
const r = judge(nineBodied({ [EDGE_CASES]: `${EDGE_CASES}\n#### none` }));
|
|
3512
|
+
expect(r.status).toBe(1);
|
|
3513
|
+
expect(r.stdout).toContain(`has an empty heading ${EDGE_CASES}`);
|
|
3514
|
+
expectDistinctBlock(r.stdout);
|
|
3515
|
+
});
|
|
3516
|
+
it.skipIf(!shellOk)('a shallower heading ends the section', () => {
|
|
3517
|
+
const r = judge(nineBodied({
|
|
3518
|
+
[IMPLEMENTATION_TASKS]: `${IMPLEMENTATION_TASKS}\n## Notes\nProse under the shallower heading.\n`,
|
|
3519
|
+
}));
|
|
3520
|
+
expect(r.status).toBe(1);
|
|
3521
|
+
expect(r.stdout).toContain(`has an empty heading ${IMPLEMENTATION_TASKS}`);
|
|
3522
|
+
expectDistinctBlock(r.stdout);
|
|
3523
|
+
});
|
|
3524
|
+
it.skipIf(!shellOk)('the heading level is exact', () => {
|
|
3525
|
+
const r = judge(nineBodied({ [PROBLEM_STATEMENT]: '## Problem Statement\n\nA body under it.\n' }));
|
|
3526
|
+
expect(r.status).toBe(1);
|
|
3527
|
+
expect(r.stdout).toContain(`is missing heading ${PROBLEM_STATEMENT}`);
|
|
3528
|
+
expectDistinctBlock(r.stdout);
|
|
3529
|
+
});
|
|
3530
|
+
// ── The ruled falsifier (operator ruling 2026-09-03) ──
|
|
3531
|
+
//
|
|
3532
|
+
// The denominator is all SEVEN drafts the R3 probe actually recorded, and the
|
|
3533
|
+
// claim is that none is blocked; the mutated negatives above carry the "flags
|
|
3534
|
+
// the defective" half. The four schema-constrained runs carry the promised
|
|
3535
|
+
// headings BYTE-IDENTICAL, em dash included, so they must match on the EXACT
|
|
3536
|
+
// pass and name no tolerance — which makes this test the executed proof, on
|
|
3537
|
+
// every CI OS, that the em-dash heading round-trips through JSON.stringify,
|
|
3538
|
+
// the single-quoted `node -e` word, sh and node without drift.
|
|
3539
|
+
/** The recorded R3 fixture directory, found by walking up to the repo root. */
|
|
3540
|
+
function resolveR3FixtureDir() {
|
|
3541
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
3542
|
+
for (let i = 0; i < 8; i++) {
|
|
3543
|
+
const candidate = path.join(dir, '.totem', 'fixtures', 'spec-runs-2026-09-02');
|
|
3544
|
+
if (fs.existsSync(candidate))
|
|
3545
|
+
return candidate;
|
|
3546
|
+
const parent = path.dirname(dir);
|
|
3547
|
+
if (parent === dir)
|
|
3548
|
+
break;
|
|
3549
|
+
dir = parent;
|
|
3550
|
+
}
|
|
3551
|
+
return '';
|
|
3552
|
+
}
|
|
3553
|
+
/** The ORIGINAL run's draft: the artifacts.ndjson row whose id starts e5a15c9c. */
|
|
3554
|
+
function readOriginalDraft(dir) {
|
|
3555
|
+
const ndjson = fs.readFileSync(path.join(dir, 'artifacts.ndjson'), 'utf-8');
|
|
3556
|
+
for (const line of ndjson.split('\n')) {
|
|
3557
|
+
if (line.trim().length === 0)
|
|
3558
|
+
continue;
|
|
3559
|
+
let row;
|
|
3560
|
+
try {
|
|
3561
|
+
row = JSON.parse(line);
|
|
3562
|
+
}
|
|
3563
|
+
catch (err) {
|
|
3564
|
+
void err;
|
|
3565
|
+
continue;
|
|
3566
|
+
}
|
|
3567
|
+
if (typeof row.id === 'string' && row.id.startsWith('e5a15c9c')) {
|
|
3568
|
+
const content = row.output?.content;
|
|
3569
|
+
if (typeof content === 'string')
|
|
3570
|
+
return content;
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
return '';
|
|
3574
|
+
}
|
|
3575
|
+
it.skipIf(!shellOk)('the extended reader blocks none of the seven recorded R3 drafts and names the tolerance on the three that dropped parentheticals', () => {
|
|
3576
|
+
const dir = resolveR3FixtureDir();
|
|
3577
|
+
expect(dir, 'the recorded R3 drafts (.totem/fixtures/spec-runs-2026-09-02/) are not in this checkout — the ruled falsifier cannot run').not.toBe('');
|
|
3578
|
+
const original = readOriginalDraft(dir);
|
|
3579
|
+
expect(original.length, 'the original R3 draft (artifacts.ndjson row id e5a15c9c…) was not found').toBeGreaterThan(0);
|
|
3580
|
+
const read = (name) => fs.readFileSync(path.join(dir, name), 'utf-8');
|
|
3581
|
+
const drafts = [
|
|
3582
|
+
{
|
|
3583
|
+
name: 'the original artifact (artifacts.ndjson row id e5a15c9c…)',
|
|
3584
|
+
content: original,
|
|
3585
|
+
tolerated: true,
|
|
3586
|
+
},
|
|
3587
|
+
{
|
|
3588
|
+
name: 'r3-unconstrained-run1.md',
|
|
3589
|
+
content: read('r3-unconstrained-run1.md'),
|
|
3590
|
+
tolerated: true,
|
|
3591
|
+
},
|
|
3592
|
+
{
|
|
3593
|
+
name: 'r3-unconstrained-run2.md',
|
|
3594
|
+
content: read('r3-unconstrained-run2.md'),
|
|
3595
|
+
tolerated: true,
|
|
3596
|
+
},
|
|
3597
|
+
{
|
|
3598
|
+
name: 'r3-responseSchema-run1.md',
|
|
3599
|
+
content: read('r3-responseSchema-run1.md'),
|
|
3600
|
+
tolerated: false,
|
|
3601
|
+
},
|
|
3602
|
+
{
|
|
3603
|
+
name: 'r3-responseSchema-run2.md',
|
|
3604
|
+
content: read('r3-responseSchema-run2.md'),
|
|
3605
|
+
tolerated: false,
|
|
3606
|
+
},
|
|
3607
|
+
{
|
|
3608
|
+
name: 'r3-responseJsonSchema-minLength1-run1.md',
|
|
3609
|
+
content: read('r3-responseJsonSchema-minLength1-run1.md'),
|
|
3610
|
+
tolerated: false,
|
|
3611
|
+
},
|
|
3612
|
+
{
|
|
3613
|
+
name: 'r3-responseJsonSchema-minLength1-run2.md',
|
|
3614
|
+
content: read('r3-responseJsonSchema-minLength1-run2.md'),
|
|
3615
|
+
tolerated: false,
|
|
3616
|
+
},
|
|
3617
|
+
];
|
|
3618
|
+
// The denominator is ruled: all seven, or the claim is a different one.
|
|
3619
|
+
expect(drafts).toHaveLength(7);
|
|
3620
|
+
for (const draft of drafts) {
|
|
3621
|
+
const r = judge(draft.content);
|
|
3622
|
+
expect(r.status, `${draft.name} was BLOCKED: ${r.stdout}`).toBe(0);
|
|
3623
|
+
expect(r.stdout, `${draft.name} did not read as TEMPLATE`).toContain('· shape TEMPLATE');
|
|
3624
|
+
if (draft.tolerated) {
|
|
3625
|
+
expect(r.stdout, `${draft.name} dropped a promised parenthetical but the evidence line named no tolerance`).toContain('· tolerated');
|
|
3626
|
+
}
|
|
3627
|
+
else {
|
|
3628
|
+
expect(r.stdout, `${draft.name} carries the promised headings verbatim but the evidence line named a tolerance`).not.toContain('· tolerated');
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
});
|
|
3632
|
+
});
|
|
3633
|
+
// A tolerantly-matched heading that then blocks for an empty body used to name
|
|
3634
|
+
// a string the draft does not contain: the seat reads `has an empty heading
|
|
3635
|
+
// ### Verification (MANDATORY — do not skip)`, searches the draft for it, finds
|
|
3636
|
+
// nothing, and cannot see that the reader matched `### Verification` and judged
|
|
3637
|
+
// THAT section. The block now carries both spellings. Outside the frozen block
|
|
3638
|
+
// (mmnto-ai/totem#2737 fold round) — the suite above was run against the
|
|
3639
|
+
// unchanged reader and is not edited after.
|
|
3640
|
+
describe('buildPreCommitHook spec-gate reader — tolerance disclosure on the block path (mmnto-ai/totem#2737 fold)', () => {
|
|
3641
|
+
const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
|
|
3642
|
+
const PROMISED = SPEC_SYSTEM_PROMPT.split('\n').filter((line) => line.startsWith('### '));
|
|
3643
|
+
const VERIFICATION = PROMISED.find((heading) => heading.startsWith('### Verification')) ?? '';
|
|
3644
|
+
let tmpDir;
|
|
3645
|
+
beforeEach(() => {
|
|
3646
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-2737-fold-'));
|
|
3647
|
+
execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
|
|
3648
|
+
execSync('git checkout -q -b feat/fold', { cwd: tmpDir, stdio: 'ignore' });
|
|
3649
|
+
fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook(RENDER));
|
|
3650
|
+
});
|
|
3651
|
+
afterEach(() => {
|
|
3652
|
+
cleanTmpDir(tmpDir);
|
|
3653
|
+
});
|
|
3654
|
+
it.skipIf(!shellOk)('an EMPTY section under a tolerantly-matched heading names both the promised and the matched line', () => {
|
|
3655
|
+
// Every promised heading bodied except Verification, which appears with
|
|
3656
|
+
// its parenthetical dropped AND no body — so the reader must reach the
|
|
3657
|
+
// empty-body block by way of the tolerant pass.
|
|
3658
|
+
const content = PROMISED.map((heading) => heading === VERIFICATION
|
|
3659
|
+
? '### Verification\n'
|
|
3660
|
+
: `${heading}\n\nA non-blank body under ${heading}.\n`).join('\n');
|
|
3661
|
+
const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
|
|
3662
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3663
|
+
fs.writeFileSync(path.join(dir, 'tolerant-empty.json'), JSON.stringify(specEvidenceArtifact({ output: { content } }), null, 2));
|
|
3664
|
+
const r = spawnSync('sh', ['./pre-commit'], {
|
|
3665
|
+
cwd: tmpDir,
|
|
3666
|
+
encoding: 'utf-8',
|
|
3667
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
3668
|
+
});
|
|
3669
|
+
expect(r.status).toBe(1);
|
|
3670
|
+
expect(r.stdout).toContain(`has an empty heading ${VERIFICATION} (matched as ### Verification)`);
|
|
3671
|
+
// Still distinguishable from the other two BLOCKED arms.
|
|
3672
|
+
expect(r.stdout).not.toContain('no totem spec run artifact');
|
|
3673
|
+
expect(r.stdout).not.toContain('the spec-evidence reader could not run');
|
|
3674
|
+
// The new clause echoes a DRAFT line, so it is a forged-line sink like
|
|
3675
|
+
// every other value that reaches stdout.
|
|
3676
|
+
expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
|
|
3677
|
+
// …and the matched line passes through `safe()`: a C1 control inside the
|
|
3678
|
+
// heading is collapsed rather than echoed into the block message.
|
|
3679
|
+
const nel = String.fromCharCode(0x85);
|
|
3680
|
+
const forgedHeading = `### Verification (a${nel}b)`;
|
|
3681
|
+
const forgedContent = PROMISED.map((heading) => heading === VERIFICATION
|
|
3682
|
+
? `${forgedHeading}\n`
|
|
3683
|
+
: `${heading}\n\nA non-blank body under ${heading}.\n`).join('\n');
|
|
3684
|
+
fs.writeFileSync(path.join(dir, 'tolerant-empty.json'), JSON.stringify(specEvidenceArtifact({ output: { content: forgedContent } }), null, 2));
|
|
3685
|
+
const forgedRun = spawnSync('sh', ['./pre-commit'], {
|
|
3686
|
+
cwd: tmpDir,
|
|
3687
|
+
encoding: 'utf-8',
|
|
3688
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
3689
|
+
});
|
|
3690
|
+
expect(forgedRun.status).toBe(1);
|
|
3691
|
+
expect(forgedRun.stdout).toContain(`has an empty heading ${VERIFICATION} (matched as ### Verification (a?b))`);
|
|
3692
|
+
expect(forgedRun.stdout).not.toContain(nel);
|
|
3693
|
+
expect(forgedRun.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
|
|
3694
|
+
});
|
|
3695
|
+
});
|
|
2628
3696
|
//# sourceMappingURL=install-hooks.test.js.map
|