@mmnto/cli 1.124.0 → 2.1.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/describe.d.ts +13 -1
- package/dist/commands/describe.d.ts.map +1 -1
- package/dist/commands/describe.js +29 -1
- package/dist/commands/describe.js.map +1 -1
- package/dist/commands/describe.test.js +86 -3
- package/dist/commands/describe.test.js.map +1 -1
- 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-templates.d.ts +2 -2
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +2 -2
- 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 +596 -92
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/install-hooks.test.js +1535 -19
- package/dist/commands/install-hooks.test.js.map +1 -1
- package/dist/commands/legs.d.ts +35 -7
- package/dist/commands/legs.d.ts.map +1 -1
- package/dist/commands/legs.js +26 -7
- package/dist/commands/legs.js.map +1 -1
- package/dist/commands/legs.test.js +87 -0
- package/dist/commands/legs.test.js.map +1 -1
- package/dist/commands/release-train-shape.test.d.ts +2 -0
- package/dist/commands/release-train-shape.test.d.ts.map +1 -0
- package/dist/commands/release-train-shape.test.js +226 -0
- package/dist/commands/release-train-shape.test.js.map +1 -0
- 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/commands/tools-hook-parity.test.js +200 -0
- package/dist/commands/tools-hook-parity.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
|
|
@@ -2196,22 +2946,33 @@ describe('buildPrePushHook — the review-leg floor arm (mmnto-ai/totem#2698)',
|
|
|
2196
2946
|
// queued `merge-gate` verb (mmnto-ai/totem#2708) would satisfy it.
|
|
2197
2947
|
expect(hook).not.toContain(`$TOTEM_CMD legs --help`);
|
|
2198
2948
|
});
|
|
2199
|
-
it('invokes the bare gate on strict and the advisory form otherwise', () => {
|
|
2949
|
+
it('invokes the bare gate on strict and the advisory form otherwise, recording the status on BOTH arms', () => {
|
|
2200
2950
|
expect(hook).toContain('$TOTEM_CMD legs gate\n');
|
|
2201
|
-
expect(hook).toContain('$TOTEM_CMD legs gate --advisory');
|
|
2202
|
-
expect(hook).toContain('legs_status=$?');
|
|
2951
|
+
expect(hook).toContain('$TOTEM_CMD legs gate --advisory\n legs_status=$?');
|
|
2952
|
+
expect(hook).toContain('legs_strict=1\n $TOTEM_CMD legs gate\n legs_status=$?');
|
|
2203
2953
|
});
|
|
2204
|
-
it('blocks on exit 3 and
|
|
2205
|
-
expect(hook).toContain(`echo "[Totem] BLOCKED: this push is legs-owed and carries no fresh falsification-leg deposit — run the leg, then 'totem legs deposit --sha HEAD --from <findings.json>' (mmnto-ai/totem#2698
|
|
2206
|
-
expect(hook).toContain('echo "[Totem] BLOCKED: the legs gate could not derive (totem legs gate exit status
|
|
2954
|
+
it('blocks on exit 3 and exit 2 at ANY tier with DISTINCT lines that name the knob; any other non-zero blocks on strict only (mmnto-ai/totem#2771)', () => {
|
|
2955
|
+
expect(hook).toContain(`echo "[Totem] BLOCKED: this push is legs-owed and carries no fresh falsification-leg deposit — run the leg, then 'totem legs deposit --sha HEAD --from <findings.json>' (mmnto-ai/totem#2698; strict mode, an agent seat, or hooks.legsOwed.enforce: block)"`);
|
|
2956
|
+
expect(hook).toContain('echo "[Totem] BLOCKED: the legs gate could not derive (totem legs gate exit status 2) — fix the checkout and retry (strict mode, an agent seat, or hooks.legsOwed.enforce: block)"');
|
|
2957
|
+
expect(hook).toContain('echo "[Totem] BLOCKED: the legs gate failed before deriving (totem legs gate exit status $legs_status) — fix the config or the CLI and retry (strict mode or an agent seat)"');
|
|
2207
2958
|
expect(hook).toContain('if [ "$legs_status" = "3" ]; then');
|
|
2208
|
-
expect(hook).toContain('elif [ "$legs_status"
|
|
2959
|
+
expect(hook).toContain('elif [ "$legs_status" = "2" ]; then');
|
|
2960
|
+
expect(hook).toContain('elif [ "$legs_status" != "0" ] && [ "$legs_strict" = "1" ]; then');
|
|
2961
|
+
// The mapping sits AFTER both invocations, outside the tier guard: the
|
|
2962
|
+
// advisory arm falls through to the same three checks the strict arm does.
|
|
2963
|
+
const advisoryIdx = hook.indexOf('$TOTEM_CMD legs gate --advisory');
|
|
2964
|
+
const mappingIdx = hook.indexOf('if [ "$legs_status" = "3" ]; then');
|
|
2965
|
+
expect(advisoryIdx).toBeGreaterThan(-1);
|
|
2966
|
+
expect(mappingIdx).toBeGreaterThan(advisoryIdx);
|
|
2967
|
+
// And the hook never reads the config itself — the knob reaches it only as
|
|
2968
|
+
// an exit code (the gate is the one reader of hooks.legsOwed.enforce).
|
|
2969
|
+
expect(hook).not.toContain('totem.config');
|
|
2209
2970
|
});
|
|
2210
2971
|
it('FAILS CLOSED on strict when the resolved CLI lacks the verb, compat-opens otherwise', () => {
|
|
2211
2972
|
// The OPPOSITE of the `--gate` / `--scope-to-diff` precedent, by ruling
|
|
2212
2973
|
// (mmnto-ai/totem#2698 OQ2): those flags degrade to a form that still runs;
|
|
2213
2974
|
// an absent VERB has no degraded form, so strict blocks with the cure.
|
|
2214
|
-
expect(hook).toContain(`echo "[Totem] BLOCKED: this hook expects 'totem legs gate' (mmnto-ai/totem#2698) but the resolved CLI lacks it — 'npm i -g @mmnto/cli@latest' (strict mode)" >&2`);
|
|
2975
|
+
expect(hook).toContain(`echo "[Totem] BLOCKED: this hook expects 'totem legs gate' (mmnto-ai/totem#2698) but the resolved CLI lacks it — 'npm i -g @mmnto/cli@latest' (strict mode or an agent seat)" >&2`);
|
|
2215
2976
|
expect(hook).toContain(`echo "[totem] Hook running without the legs gate (CLI predates 'totem legs'); 'npm i -g @mmnto/cli@latest' enables it." >&2`);
|
|
2216
2977
|
});
|
|
2217
2978
|
it('runs BEFORE the shield block and inside the $TOTEM_CMD guard', () => {
|
|
@@ -2369,8 +3130,8 @@ describe('the review-leg floor arm executed under sh (mmnto-ai/totem#2698)', ()
|
|
|
2369
3130
|
expect(invocations()).not.toContain('legs gate --advisory');
|
|
2370
3131
|
expect(r.status).toBe(1);
|
|
2371
3132
|
});
|
|
2372
|
-
it.skipIf(!shellOk)('standard tier, no agent: --advisory is passed and exit
|
|
2373
|
-
writeStub({ helpText: HELP_WITH_GATE, gateExit:
|
|
3133
|
+
it.skipIf(!shellOk)('standard tier, no agent: --advisory is passed and exit 0 proceeds past the arm', () => {
|
|
3134
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 0 });
|
|
2374
3135
|
const r = runHook({ tier: 'standard', agent: false });
|
|
2375
3136
|
// Read from the recorded argv, not from the hook text: the flag has to
|
|
2376
3137
|
// have reached the CLI, not merely appear in the template.
|
|
@@ -2379,6 +3140,44 @@ describe('the review-leg floor arm executed under sh (mmnto-ai/totem#2698)', ()
|
|
|
2379
3140
|
expect(r.status).toBe(0);
|
|
2380
3141
|
expect(r.stdout).not.toContain('BLOCKED');
|
|
2381
3142
|
});
|
|
3143
|
+
// Since mmnto-ai/totem#2771 the advisory arm's exit code is READ. A real gate
|
|
3144
|
+
// under --advisory returns 3 or 2 only when hooks.legsOwed.enforce says
|
|
3145
|
+
// block, so a stub exiting 3 here IS the knob's shape as the hook sees it.
|
|
3146
|
+
it.skipIf(!shellOk)('standard tier + gate exit 3 (the enforce: block shape): BLOCKS with the line that names the knob', () => {
|
|
3147
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 3 });
|
|
3148
|
+
const r = runHook({ tier: 'standard', agent: false });
|
|
3149
|
+
expect(invocations()).toContain('legs gate --advisory');
|
|
3150
|
+
expect(r.status).toBe(1);
|
|
3151
|
+
expect(r.stdout).toContain('[Totem] BLOCKED: this push is legs-owed');
|
|
3152
|
+
expect(r.stdout).toContain('strict mode, an agent seat, or hooks.legsOwed.enforce: block');
|
|
3153
|
+
expect(r.stdout).toContain('[Totem] legs: stub gate line');
|
|
3154
|
+
});
|
|
3155
|
+
it.skipIf(!shellOk)('standard tier + gate exit 2: blocks with the DISTINCT not-derived line', () => {
|
|
3156
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 2 });
|
|
3157
|
+
const r = runHook({ tier: 'standard', agent: false });
|
|
3158
|
+
expect(invocations()).toContain('legs gate --advisory');
|
|
3159
|
+
expect(r.status).toBe(1);
|
|
3160
|
+
expect(r.stdout).toContain('the legs gate could not derive (totem legs gate exit status 2)');
|
|
3161
|
+
expect(r.stdout).not.toContain('this push is legs-owed');
|
|
3162
|
+
});
|
|
3163
|
+
// A PIN, not a falsifier: this case holds on the pre-knob hook too (whose
|
|
3164
|
+
// standard arm never read the status at all). It is here so the one
|
|
3165
|
+
// behaviour the knob must NOT change stays asserted next to the ones it does.
|
|
3166
|
+
it.skipIf(!shellOk)('standard tier + gate exit 1 (a failure BEFORE the derivation): passes, byte for byte the pre-knob behaviour', () => {
|
|
3167
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 1 });
|
|
3168
|
+
const r = runHook({ tier: 'standard', agent: false });
|
|
3169
|
+
expect(invocations()).toContain('legs gate --advisory');
|
|
3170
|
+
expect(r.status).toBe(0);
|
|
3171
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
3172
|
+
});
|
|
3173
|
+
it.skipIf(!shellOk)('strict tier + gate exit 1: blocks with the failed-before-deriving line, not the not-derived one', () => {
|
|
3174
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 1 });
|
|
3175
|
+
const r = runHook({ tier: 'strict', agent: false });
|
|
3176
|
+
expect(invocations()).toContain('legs gate');
|
|
3177
|
+
expect(r.status).toBe(1);
|
|
3178
|
+
expect(r.stdout).toContain('the legs gate failed before deriving (totem legs gate exit status 1)');
|
|
3179
|
+
expect(r.stdout).not.toContain('could not derive');
|
|
3180
|
+
});
|
|
2382
3181
|
});
|
|
2383
3182
|
// The arm COMPOSED with the real gate (mmnto-ai/totem#2698 fold 2, MATERIAL).
|
|
2384
3183
|
//
|
|
@@ -2497,8 +3296,8 @@ describe('the review-leg floor arm COMPOSED with the real gate (mmnto-ai/totem#2
|
|
|
2497
3296
|
process.chdir(realCwd);
|
|
2498
3297
|
cleanTmpDir(tmpDir);
|
|
2499
3298
|
});
|
|
2500
|
-
function runHook() {
|
|
2501
|
-
fs.writeFileSync(path.join(repoDir, 'pre-push'), buildPrePushHook({ ...RENDER, tier
|
|
3299
|
+
function runHook(tier = 'strict') {
|
|
3300
|
+
fs.writeFileSync(path.join(repoDir, 'pre-push'), buildPrePushHook({ ...RENDER, tier }));
|
|
2502
3301
|
const env = {
|
|
2503
3302
|
...process.env,
|
|
2504
3303
|
PATH: [binDir, process.env['PATH'] ?? ''].join(path.delimiter),
|
|
@@ -2509,6 +3308,20 @@ describe('the review-leg floor arm COMPOSED with the real gate (mmnto-ai/totem#2
|
|
|
2509
3308
|
const result = spawnSync('sh', ['./pre-push'], { cwd: repoDir, env, encoding: 'utf-8' });
|
|
2510
3309
|
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
2511
3310
|
}
|
|
3311
|
+
/**
|
|
3312
|
+
* Rewrite the fixture's config with the knob — in the WORKING TREE only. The
|
|
3313
|
+
* gate judges the committed branch diff, so an uncommitted config edit never
|
|
3314
|
+
* enters the owed set; the gate's config LOAD reads the working file.
|
|
3315
|
+
*/
|
|
3316
|
+
function setEnforce(enforce) {
|
|
3317
|
+
fs.writeFileSync(path.join(repoDir, 'totem.config.ts'), [
|
|
3318
|
+
'export default {',
|
|
3319
|
+
" targets: [{ glob: 'docs/**/*.md', type: 'spec', strategy: 'markdown-heading' }],",
|
|
3320
|
+
` hooks: { legsOwed: { globs: ['docs/**'], enforce: '${enforce}' } },`,
|
|
3321
|
+
'};',
|
|
3322
|
+
'',
|
|
3323
|
+
].join(NL));
|
|
3324
|
+
}
|
|
2512
3325
|
const invocations = () => {
|
|
2513
3326
|
const native = logPath.split('/').join(path.sep);
|
|
2514
3327
|
return fs.existsSync(native) ? fs.readFileSync(native, 'utf-8').trim().split(NL) : [];
|
|
@@ -2624,5 +3437,708 @@ describe('the review-leg floor arm COMPOSED with the real gate (mmnto-ai/totem#2
|
|
|
2624
3437
|
expect(r.stdout).toContain('[Totem] legs: not owed — no changed path matched hooks.legsOwed.globs (1 globs; head');
|
|
2625
3438
|
expect(r.stdout).not.toContain('BLOCKED');
|
|
2626
3439
|
});
|
|
3440
|
+
// The knob, end to end (mmnto-ai/totem#2771): the config declares it, the
|
|
3441
|
+
// REAL gate reads it and maps the exit, the STANDARD-tier hook — which passes
|
|
3442
|
+
// --advisory and, before the knob, ignored the status entirely — blocks on
|
|
3443
|
+
// what comes back. No stub scripts the 3 here; the derivation produces it.
|
|
3444
|
+
it.skipIf(!composedOk)("OWED, no deposit, STANDARD tier, hooks.legsOwed.enforce: 'block': the hook blocks and names the knob", () => {
|
|
3445
|
+
setEnforce('block');
|
|
3446
|
+
const r = runHook('standard');
|
|
3447
|
+
// The advisory FLAG was passed — the tier did not change — and the gate
|
|
3448
|
+
// still returned 3 because the knob overrode it.
|
|
3449
|
+
expect(invocations()).toContain('legs gate --advisory');
|
|
3450
|
+
expect(invocations()).not.toContain('legs gate');
|
|
3451
|
+
expect(r.status).toBe(1);
|
|
3452
|
+
expect(r.stdout).toContain('[Totem] BLOCKED: this push is legs-owed (docs/** →');
|
|
3453
|
+
expect(r.stdout).toContain('[Totem] legs: hooks.legsOwed.enforce = block');
|
|
3454
|
+
expect(r.stdout).toContain('strict mode, an agent seat, or hooks.legsOwed.enforce: block');
|
|
3455
|
+
// The shield block after the arm was never reached.
|
|
3456
|
+
expect(invocations()).not.toContain('doctor --strict');
|
|
3457
|
+
});
|
|
3458
|
+
it.skipIf(!composedOk)("OWED, no deposit, STRICT tier, hooks.legsOwed.enforce: 'advisory': the gate's text prints, the hook passes", () => {
|
|
3459
|
+
setEnforce('advisory');
|
|
3460
|
+
const r = runHook('strict');
|
|
3461
|
+
expect(invocations()).toContain('legs gate');
|
|
3462
|
+
// The gate's own verdict text is byte-identical to the blocking case —
|
|
3463
|
+
// only the exit code moved — and the knob line says why.
|
|
3464
|
+
expect(r.stdout).toContain('[Totem] BLOCKED: this push is legs-owed (docs/** →');
|
|
3465
|
+
expect(r.stdout).toContain('[Totem] legs: hooks.legsOwed.enforce = advisory');
|
|
3466
|
+
// The HOOK's block line never fires, and the hook proceeds to the shield block.
|
|
3467
|
+
expect(r.stdout).not.toContain('strict mode, an agent seat, or hooks.legsOwed.enforce: block');
|
|
3468
|
+
expect(invocations()).toContain('doctor --strict');
|
|
3469
|
+
expect(r.status).toBe(0);
|
|
3470
|
+
});
|
|
3471
|
+
it.skipIf(!composedOk)("OWED with a covering ANCESTOR deposit under enforce: 'block' on the STANDARD tier: the hook passes on evidence", () => {
|
|
3472
|
+
setEnforce('block');
|
|
3473
|
+
deposit(ancestorSha);
|
|
3474
|
+
const r = runHook('standard');
|
|
3475
|
+
expect(invocations()).toContain('legs gate --advisory');
|
|
3476
|
+
expect(r.stdout).toContain('[Totem] legs evidence: .totem/artifacts/legs/');
|
|
3477
|
+
expect(r.stdout).toContain('[Totem] legs: hooks.legsOwed.enforce = block');
|
|
3478
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
3479
|
+
expect(r.status).toBe(0);
|
|
3480
|
+
});
|
|
3481
|
+
});
|
|
3482
|
+
// ─── Fences and bare hash lines (mmnto-ai/totem#2769) ─────
|
|
3483
|
+
//
|
|
3484
|
+
// Two holes the mmnto-ai/totem#2759 round declined as pre-existing. Each
|
|
3485
|
+
// NEGATIVE case here passes on the reader as it stood on main (a lone `####`
|
|
3486
|
+
// counted as body; a promised heading inside a fenced block matched); each must
|
|
3487
|
+
// BLOCK after. The POSITIVE cases pin what the fix must not take with it: a
|
|
3488
|
+
// section whose only content is a code block is a written section, and a
|
|
3489
|
+
// fenced quote of a heading beside a real section changes nothing.
|
|
3490
|
+
describe('buildPreCommitHook spec-gate reader — fences and bare hash lines (mmnto-ai/totem#2769)', () => {
|
|
3491
|
+
const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
|
|
3492
|
+
const PROMISED = SPEC_SYSTEM_PROMPT.split('\n').filter((line) => line.startsWith('### '));
|
|
3493
|
+
function promised(prefix) {
|
|
3494
|
+
const found = PROMISED.find((heading) => heading.startsWith(prefix));
|
|
3495
|
+
if (found === undefined)
|
|
3496
|
+
throw new Error(`no promised heading starts with ${prefix}`);
|
|
3497
|
+
return found;
|
|
3498
|
+
}
|
|
3499
|
+
const EDGE_CASES = promised('### Edge Cases');
|
|
3500
|
+
const EXECUTION_FLOW = promised('### Execution Flow');
|
|
3501
|
+
const TEST_PLAN = promised('### Test Plan');
|
|
3502
|
+
/** Three backticks, built rather than authored (three quoting layers sit between here and the reader). */
|
|
3503
|
+
const FENCE = String.fromCharCode(96, 96, 96);
|
|
3504
|
+
const NL = String.fromCharCode(10);
|
|
3505
|
+
function nineBodied(overrides = {}) {
|
|
3506
|
+
return PROMISED.map((heading) => {
|
|
3507
|
+
const override = overrides[heading];
|
|
3508
|
+
return override === undefined
|
|
3509
|
+
? `${heading}${NL}${NL}A non-blank body under ${heading}.${NL}`
|
|
3510
|
+
: override;
|
|
3511
|
+
})
|
|
3512
|
+
.filter((block) => block.length > 0)
|
|
3513
|
+
.join(NL);
|
|
3514
|
+
}
|
|
3515
|
+
let tmpDir;
|
|
3516
|
+
beforeEach(() => {
|
|
3517
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-2769-'));
|
|
3518
|
+
execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
|
|
3519
|
+
execSync('git checkout -q -b feat/fence-suite', { cwd: tmpDir, stdio: 'ignore' });
|
|
3520
|
+
fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook(RENDER));
|
|
3521
|
+
});
|
|
3522
|
+
afterEach(() => {
|
|
3523
|
+
cleanTmpDir(tmpDir);
|
|
3524
|
+
});
|
|
3525
|
+
function judge(content) {
|
|
3526
|
+
const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
|
|
3527
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3528
|
+
fs.writeFileSync(path.join(dir, 'draft.json'), JSON.stringify(specEvidenceArtifact({ output: { content } }), null, 2));
|
|
3529
|
+
const r = spawnSync('sh', ['./pre-commit'], {
|
|
3530
|
+
cwd: tmpDir,
|
|
3531
|
+
encoding: 'utf-8',
|
|
3532
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
3533
|
+
});
|
|
3534
|
+
return { status: r.status, stdout: r.stdout };
|
|
3535
|
+
}
|
|
3536
|
+
// ── Negative: pass today, must block ──
|
|
3537
|
+
it.skipIf(!shellOk)('a lone #### line is not a body (hole 1)', () => {
|
|
3538
|
+
const r = judge(nineBodied({ [EDGE_CASES]: `${EDGE_CASES}${NL}####${NL}` }));
|
|
3539
|
+
expect(r.status).toBe(1);
|
|
3540
|
+
expect(r.stdout).toContain(`has an empty heading ${EDGE_CASES}`);
|
|
3541
|
+
});
|
|
3542
|
+
it.skipIf(!shellOk)('a run of # characters with only whitespace around it is not a body either', () => {
|
|
3543
|
+
const r = judge(nineBodied({ [EDGE_CASES]: `${EDGE_CASES}${NL} ### ${NL}#${NL}` }));
|
|
3544
|
+
expect(r.status).toBe(1);
|
|
3545
|
+
expect(r.stdout).toContain(`has an empty heading ${EDGE_CASES}`);
|
|
3546
|
+
});
|
|
3547
|
+
it.skipIf(!shellOk)('the promised skeleton quoted inside a fenced block is not the sections it names (hole 2)', () => {
|
|
3548
|
+
const r = judge(`Some prose about the draft.${NL}${NL}${FENCE}${NL}${nineBodied()}${NL}${FENCE}${NL}`);
|
|
3549
|
+
expect(r.status).toBe(1);
|
|
3550
|
+
expect(r.stdout).toContain(`is missing heading ${PROMISED[0]}`);
|
|
3551
|
+
});
|
|
3552
|
+
it.skipIf(!shellOk)('a tilde fence hides a heading the same way', () => {
|
|
3553
|
+
const r = judge(nineBodied({ [TEST_PLAN]: `~~~${NL}${TEST_PLAN}${NL}${NL}quoted, not written${NL}~~~${NL}` }));
|
|
3554
|
+
expect(r.status).toBe(1);
|
|
3555
|
+
expect(r.stdout).toContain(`is missing heading ${TEST_PLAN}`);
|
|
3556
|
+
});
|
|
3557
|
+
it.skipIf(!shellOk)('a heading whose only content is an EMPTY fence is empty', () => {
|
|
3558
|
+
const r = judge(nineBodied({ [EXECUTION_FLOW]: `${EXECUTION_FLOW}${NL}${FENCE}${NL}${FENCE}${NL}` }));
|
|
3559
|
+
expect(r.status).toBe(1);
|
|
3560
|
+
expect(r.stdout).toContain(`has an empty heading ${EXECUTION_FLOW}`);
|
|
3561
|
+
});
|
|
3562
|
+
// ── Positive: must keep passing ──
|
|
3563
|
+
it.skipIf(!shellOk)('a section whose only content is a code block is a written section', () => {
|
|
3564
|
+
const r = judge(nineBodied({
|
|
3565
|
+
[EXECUTION_FLOW]: `${EXECUTION_FLOW}${NL}${FENCE}mermaid${NL}A --> B${NL}${FENCE}${NL}`,
|
|
3566
|
+
}));
|
|
3567
|
+
expect(r.status).toBe(0);
|
|
3568
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
3569
|
+
});
|
|
3570
|
+
it.skipIf(!shellOk)('a fenced quote of a heading beside the real section changes nothing', () => {
|
|
3571
|
+
const r = judge(nineBodied({
|
|
3572
|
+
[TEST_PLAN]: `${TEST_PLAN}${NL}${NL}Real plan text.${NL}${NL}${FENCE}${NL}${EDGE_CASES}${NL}(quoted as an example)${NL}${FENCE}${NL}`,
|
|
3573
|
+
}));
|
|
3574
|
+
expect(r.status).toBe(0);
|
|
3575
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
3576
|
+
expect(r.stdout).not.toContain('tolerated');
|
|
3577
|
+
});
|
|
3578
|
+
it.skipIf(!shellOk)('a heading that appears fenced first and real later is found at the real line — the disclosure proves which', () => {
|
|
3579
|
+
// The EXACT promised heading sits inside a fence; the real section below
|
|
3580
|
+
// is written in the TOLERANT form. On the pre-fix reader the exact pass
|
|
3581
|
+
// matched the fenced copy and the pass line carried no tolerance; skipping
|
|
3582
|
+
// the fenced copy makes the tolerant pass find the real line and SAY so.
|
|
3583
|
+
const r = judge(nineBodied({
|
|
3584
|
+
[EXECUTION_FLOW]: `${FENCE}${NL}${EXECUTION_FLOW}${NL}${FENCE}${NL}${NL}### Execution Flow${NL}${NL}The real section, in the tolerant spelling.${NL}`,
|
|
3585
|
+
}));
|
|
3586
|
+
expect(r.status).toBe(0);
|
|
3587
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
3588
|
+
expect(r.stdout).toContain(`tolerated ${EXECUTION_FLOW} ~ ### Execution Flow`);
|
|
3589
|
+
});
|
|
3590
|
+
it.skipIf(!shellOk)('an unclosed fence runs to the end of the draft, and the block says where it opened', () => {
|
|
3591
|
+
// Everything after an unclosed opener is inside the fence: the headings
|
|
3592
|
+
// quoted there are not candidates, so the draft is missing them — and the
|
|
3593
|
+
// reason names the fence, so the cure is never a heading the draft contains.
|
|
3594
|
+
const r = judge(`Intro.${NL}${FENCE}${NL}${nineBodied()}`);
|
|
3595
|
+
expect(r.status).toBe(1);
|
|
3596
|
+
expect(r.stdout).toContain(`is missing heading ${PROMISED[0]}`);
|
|
3597
|
+
expect(r.stdout).toContain('it appears only inside a fenced code block opened at line 2');
|
|
3598
|
+
expect(r.stdout).toContain('that fence is never closed');
|
|
3599
|
+
});
|
|
3600
|
+
// ── The fence state's edges (the leg's probes, pinned) ──
|
|
3601
|
+
it.skipIf(!shellOk)('a three-backtick line does not close a four-backtick fence', () => {
|
|
3602
|
+
// CommonMark: the closer is the same character, at least as long. A draft
|
|
3603
|
+
// wrapped in a four-backtick fence with one nested three-backtick example
|
|
3604
|
+
// is one code block with zero headings.
|
|
3605
|
+
const FENCE4 = FENCE + String.fromCharCode(96);
|
|
3606
|
+
const r = judge(`Intro prose.${NL}${FENCE4}${NL}quoted example:${NL}${FENCE}${NL}${nineBodied()}${NL}${FENCE4}${NL}`);
|
|
3607
|
+
expect(r.status).toBe(1);
|
|
3608
|
+
expect(r.stdout).toContain(`is missing heading ${PROMISED[0]}`);
|
|
3609
|
+
expect(r.stdout).toContain('inside a fenced code block opened at line 2');
|
|
3610
|
+
});
|
|
3611
|
+
it.skipIf(!shellOk)('a four-backtick closer closes a three-backtick fence', () => {
|
|
3612
|
+
const FENCE4 = FENCE + String.fromCharCode(96);
|
|
3613
|
+
const r = judge(nineBodied({
|
|
3614
|
+
[EXECUTION_FLOW]: `${EXECUTION_FLOW}${NL}${FENCE}${NL}A --> B${NL}${FENCE4}${NL}`,
|
|
3615
|
+
}));
|
|
3616
|
+
expect(r.status).toBe(0);
|
|
3617
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
3618
|
+
});
|
|
3619
|
+
it.skipIf(!shellOk)('a fence line indented four spaces is not a fence (an indented code block, or a lazy continuation after a paragraph)', () => {
|
|
3620
|
+
// CommonMark: up to three spaces of indentation keeps a fence a fence; four
|
|
3621
|
+
// makes it an indented code block. The DISCRIMINATING shape: an indented
|
|
3622
|
+
// fence line with the whole skeleton after it and no closer. A reader that
|
|
3623
|
+
// treated the indented line as an opener would swallow every heading; this
|
|
3624
|
+
// one reads it as text, so the draft passes.
|
|
3625
|
+
const r = judge(`Intro.${NL} ${FENCE}${NL}${nineBodied()}`);
|
|
3626
|
+
expect(r.status).toBe(0);
|
|
3627
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
3628
|
+
});
|
|
3629
|
+
it.skipIf(!shellOk)('a backtick opener whose info string carries a backtick is not a fence', () => {
|
|
3630
|
+
const r = judge(nineBodied({
|
|
3631
|
+
[EXECUTION_FLOW]: `${EXECUTION_FLOW}${NL}${FENCE}js${String.fromCharCode(96)}x${NL}`,
|
|
3632
|
+
}));
|
|
3633
|
+
expect(r.status).toBe(0);
|
|
3634
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
3635
|
+
});
|
|
3636
|
+
it.skipIf(!shellOk)('a run of tildes used as a divider opens a fence in Markdown too, and the block says so', () => {
|
|
3637
|
+
// Twenty tildes are a fence opener under CommonMark (a thematic break is
|
|
3638
|
+
// ---, *** or ___); every heading after it is code until a closer at
|
|
3639
|
+
// least as long. The block names the fence rather than a heading the
|
|
3640
|
+
// draft contains verbatim.
|
|
3641
|
+
const rule = '~'.repeat(20);
|
|
3642
|
+
const r = judge(nineBodied({ [PROMISED[1]]: `${PROMISED[1]}${NL}${NL}Body.${NL}${NL}${rule}${NL}` }));
|
|
3643
|
+
expect(r.status).toBe(1);
|
|
3644
|
+
expect(r.stdout).toContain(`is missing heading ${PROMISED[2]}`);
|
|
3645
|
+
// Line 9: three lines for the first section, one blank, then the override's
|
|
3646
|
+
// heading, blank, body, blank, and the divider.
|
|
3647
|
+
expect(r.stdout).toContain('it appears only inside a fenced code block opened at line 9');
|
|
3648
|
+
expect(r.stdout).toContain('that fence is never closed');
|
|
3649
|
+
});
|
|
3650
|
+
it.skipIf(!shellOk)('a fenced promised heading in its TOLERANT spelling is not body either', () => {
|
|
3651
|
+
// The matcher treats the parenthetical-dropped spelling as the same heading;
|
|
3652
|
+
// the body rule now does too, whatever the indentation inside the fence.
|
|
3653
|
+
const tolerant = EXECUTION_FLOW.replace(/ \([^)]*\)$/, '');
|
|
3654
|
+
expect(tolerant).not.toBe(EXECUTION_FLOW);
|
|
3655
|
+
const r = judge(nineBodied({
|
|
3656
|
+
[EDGE_CASES]: `${EDGE_CASES}${NL}${FENCE}${NL} ${tolerant}${NL}${FENCE}${NL}`,
|
|
3657
|
+
}));
|
|
3658
|
+
expect(r.status).toBe(1);
|
|
3659
|
+
expect(r.stdout).toContain(`has an empty heading ${EDGE_CASES}`);
|
|
3660
|
+
expect(r.stdout).toContain('nothing under it counts as body: a promised heading quoted inside a fenced code block');
|
|
3661
|
+
});
|
|
3662
|
+
it.skipIf(!shellOk)('an INDENTED fenced copy of a missing heading is still found and named', () => {
|
|
3663
|
+
const indented = nineBodied()
|
|
3664
|
+
.split(NL)
|
|
3665
|
+
.map((line) => ` ${line}`)
|
|
3666
|
+
.join(NL);
|
|
3667
|
+
const r = judge(`Prose.${NL}${FENCE}${NL}${indented}${NL}${FENCE}${NL}`);
|
|
3668
|
+
expect(r.status).toBe(1);
|
|
3669
|
+
expect(r.stdout).toContain(`is missing heading ${PROMISED[0]}`);
|
|
3670
|
+
expect(r.stdout).toContain('it appears only inside a fenced code block opened at line 2');
|
|
3671
|
+
// Closed fence: the cure is to write the section outside it, not to close it.
|
|
3672
|
+
expect(r.stdout).toContain('write the section outside the fence');
|
|
3673
|
+
expect(r.stdout).not.toContain('never closed');
|
|
3674
|
+
});
|
|
3675
|
+
it.skipIf(!shellOk)('a lone #### under a heading names the rule that emptied the section', () => {
|
|
3676
|
+
const r = judge(nineBodied({ [EDGE_CASES]: `${EDGE_CASES}${NL}####${NL}` }));
|
|
3677
|
+
expect(r.status).toBe(1);
|
|
3678
|
+
expect(r.stdout).toContain(`has an empty heading ${EDGE_CASES} — nothing under it counts as body: a line of # characters`);
|
|
3679
|
+
});
|
|
3680
|
+
it.skipIf(!shellOk)('every rule that emptied a section is named, and an empty fence pair is one of them', () => {
|
|
3681
|
+
// Both rules at once: a bare hash line and a fenced promised heading.
|
|
3682
|
+
const both = judge(nineBodied({
|
|
3683
|
+
[EDGE_CASES]: `${EDGE_CASES}${NL}####${NL}${FENCE}${NL}${TEST_PLAN}${NL}${FENCE}${NL}`,
|
|
3684
|
+
}));
|
|
3685
|
+
expect(both.status).toBe(1);
|
|
3686
|
+
expect(both.stdout).toContain('nothing under it counts as body: a promised heading quoted inside a fenced code block; a line of # characters');
|
|
3687
|
+
// An empty fence pair is a rule this slice introduced; it names itself too.
|
|
3688
|
+
const empty = judge(nineBodied({ [EDGE_CASES]: `${EDGE_CASES}${NL}${FENCE}${NL}${FENCE}${NL}` }));
|
|
3689
|
+
expect(empty.status).toBe(1);
|
|
3690
|
+
expect(empty.stdout).toContain('nothing under it counts as body: an empty fenced code block');
|
|
3691
|
+
});
|
|
3692
|
+
it.skipIf(!shellOk)('a closing fence that repeats the info string does not close (the cure names a bare closer)', () => {
|
|
3693
|
+
// A common model output error: ```mermaid … ```mermaid. Under CommonMark the
|
|
3694
|
+
// second line is not a closer, so every heading after it is fenced.
|
|
3695
|
+
const idx = PROMISED.indexOf(EXECUTION_FLOW);
|
|
3696
|
+
const r = judge(nineBodied({
|
|
3697
|
+
[EXECUTION_FLOW]: `${EXECUTION_FLOW}${NL}${FENCE}mermaid${NL}A --> B${NL}${FENCE}mermaid${NL}`,
|
|
3698
|
+
}));
|
|
3699
|
+
expect(r.status).toBe(1);
|
|
3700
|
+
expect(r.stdout).toContain(`is missing heading ${PROMISED[idx + 1]}`);
|
|
3701
|
+
expect(r.stdout).toContain('close it with a bare closing fence');
|
|
3702
|
+
});
|
|
3703
|
+
it.skipIf(!shellOk)('the never-closed cure is offered only for the fence the copy sits in', () => {
|
|
3704
|
+
// Test Plan exists only as a copy inside a CLOSED fence; an unrelated fence
|
|
3705
|
+
// is left open at the end of the draft. The cure must not blame that one.
|
|
3706
|
+
const r = judge(`${nineBodied({
|
|
3707
|
+
[EDGE_CASES]: `${EDGE_CASES}${NL}${NL}A real body line.${NL}${NL}${FENCE}${NL}${TEST_PLAN}${NL}${FENCE}${NL}`,
|
|
3708
|
+
[TEST_PLAN]: '',
|
|
3709
|
+
})}${NL}${FENCE}${NL}stray, never closed${NL}`);
|
|
3710
|
+
expect(r.status).toBe(1);
|
|
3711
|
+
expect(r.stdout).toContain(`is missing heading ${TEST_PLAN}`);
|
|
3712
|
+
expect(r.stdout).toContain('write the section outside the fence');
|
|
3713
|
+
expect(r.stdout).not.toContain('never closed');
|
|
3714
|
+
});
|
|
3715
|
+
it.skipIf(!shellOk)('RECORD: a bound record whose only headings sit inside a fence has no heading with a body', () => {
|
|
3716
|
+
const abs = path.join(tmpDir, '.totem', 'specs', 'rec.md');
|
|
3717
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
3718
|
+
fs.writeFileSync(abs, `${FENCE}yaml${NL}# Problem${NL}problem: the thing${NL}${FENCE}${NL}`, 'utf-8');
|
|
3719
|
+
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex');
|
|
3720
|
+
const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
|
|
3721
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3722
|
+
fs.writeFileSync(path.join(dir, 'draft.json'), JSON.stringify(specEvidenceArtifact({
|
|
3723
|
+
grounding: {
|
|
3724
|
+
anchor: { kind: GROUNDING_ANCHOR_RECORD, ref: '.totem/specs/rec.md', sha256 },
|
|
3725
|
+
},
|
|
3726
|
+
}), null, 2));
|
|
3727
|
+
const r = spawnSync('sh', ['./pre-commit'], {
|
|
3728
|
+
cwd: tmpDir,
|
|
3729
|
+
encoding: 'utf-8',
|
|
3730
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
3731
|
+
});
|
|
3732
|
+
expect(r.status).toBe(1);
|
|
3733
|
+
expect(r.stdout).toContain('the bound record at .totem/specs/rec.md has no heading with a body');
|
|
3734
|
+
// A real heading above the fence cures it — the record is edited, not re-run.
|
|
3735
|
+
fs.writeFileSync(abs, `# Record${NL}${NL}${FENCE}yaml${NL}problem: the thing${NL}${FENCE}${NL}`, 'utf-8');
|
|
3736
|
+
const sha2 = crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex');
|
|
3737
|
+
fs.writeFileSync(path.join(dir, 'draft.json'), JSON.stringify(specEvidenceArtifact({
|
|
3738
|
+
grounding: {
|
|
3739
|
+
anchor: { kind: GROUNDING_ANCHOR_RECORD, ref: '.totem/specs/rec.md', sha256: sha2 },
|
|
3740
|
+
},
|
|
3741
|
+
}), null, 2));
|
|
3742
|
+
const ok = spawnSync('sh', ['./pre-commit'], {
|
|
3743
|
+
cwd: tmpDir,
|
|
3744
|
+
encoding: 'utf-8',
|
|
3745
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
3746
|
+
});
|
|
3747
|
+
expect(ok.status).toBe(0);
|
|
3748
|
+
expect(ok.stdout).toContain('record sha256 matches');
|
|
3749
|
+
});
|
|
3750
|
+
it.skipIf(!shellOk)('#### inside a fence is code, and code is body', () => {
|
|
3751
|
+
const r = judge(nineBodied({
|
|
3752
|
+
[EDGE_CASES]: `${EDGE_CASES}${NL}${FENCE}${NL}####${NL}#####${NL}${FENCE}${NL}`,
|
|
3753
|
+
}));
|
|
3754
|
+
expect(r.status).toBe(0);
|
|
3755
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
3756
|
+
});
|
|
3757
|
+
it.skipIf(!shellOk)('seven hashes are a bare hash line, not a heading and not a body', () => {
|
|
3758
|
+
const r = judge(nineBodied({ [EDGE_CASES]: `${EDGE_CASES}${NL}#######${NL}` }));
|
|
3759
|
+
expect(r.status).toBe(1);
|
|
3760
|
+
expect(r.stdout).toContain(`has an empty heading ${EDGE_CASES}`);
|
|
3761
|
+
});
|
|
3762
|
+
it.skipIf(!shellOk)('a fenced promised heading is not body for the section above it', () => {
|
|
3763
|
+
// Zero authored words: the only content under Edge Cases is the next
|
|
3764
|
+
// promised heading, quoted. Not a written section.
|
|
3765
|
+
const r = judge(nineBodied({
|
|
3766
|
+
[EDGE_CASES]: `${EDGE_CASES}${NL}${FENCE}${NL}${PROMISED[PROMISED.indexOf(EDGE_CASES) + 1]}${NL}${FENCE}${NL}`,
|
|
3767
|
+
}));
|
|
3768
|
+
expect(r.status).toBe(1);
|
|
3769
|
+
expect(r.stdout).toContain(`has an empty heading ${EDGE_CASES}`);
|
|
3770
|
+
});
|
|
3771
|
+
it.skipIf(!shellOk)('a closer followed by a non-breaking space does not close the fence', () => {
|
|
3772
|
+
// CommonMark permits only spaces and tabs after a closing fence; JS trim()
|
|
3773
|
+
// would accept U+00A0 and close a fence Markdown keeps open, letting the
|
|
3774
|
+
// skeleton after it reach the gate.
|
|
3775
|
+
const NBSP = String.fromCharCode(160);
|
|
3776
|
+
const r = judge(`Intro.${NL}${FENCE}${NL}quoted${NL}${FENCE}${NBSP}${NL}${nineBodied()}`);
|
|
3777
|
+
expect(r.status).toBe(1);
|
|
3778
|
+
expect(r.stdout).toContain(`is missing heading ${PROMISED[0]}`);
|
|
3779
|
+
expect(r.stdout).toContain('that fence is never closed');
|
|
3780
|
+
// A closer followed by ordinary spaces and a tab still closes.
|
|
3781
|
+
const TAB = String.fromCharCode(9);
|
|
3782
|
+
const ok = judge(`Intro.${NL}${FENCE}${NL}quoted${NL}${FENCE} ${TAB}${NL}${NL}${nineBodied()}`);
|
|
3783
|
+
expect(ok.status).toBe(0);
|
|
3784
|
+
});
|
|
3785
|
+
it.skipIf(!shellOk)('a matching delimiter indented four spaces does not close the fence', () => {
|
|
3786
|
+
// CommonMark: a closing fence may be indented at most three spaces. The
|
|
3787
|
+
// reader counts the run only when the indent is under four, so a
|
|
3788
|
+
// four-space delimiter is text inside the fence and the skeleton after it
|
|
3789
|
+
// stays fenced (Greptile follow-up on mmnto-ai/totem#2769).
|
|
3790
|
+
const r = judge(`Intro.${NL}${FENCE}${NL}quoted${NL} ${FENCE}${NL}${nineBodied()}`);
|
|
3791
|
+
expect(r.status).toBe(1);
|
|
3792
|
+
expect(r.stdout).toContain(`is missing heading ${PROMISED[0]}`);
|
|
3793
|
+
expect(r.stdout).toContain('that fence is never closed');
|
|
3794
|
+
// Three spaces still close.
|
|
3795
|
+
const ok = judge(`Intro.${NL}${FENCE}${NL}quoted${NL} ${FENCE}${NL}${NL}${nineBodied()}`);
|
|
3796
|
+
expect(ok.status).toBe(0);
|
|
3797
|
+
});
|
|
3798
|
+
// ── The DOCUMENT arm (overridden prompt, bound record) under fences ──
|
|
3799
|
+
function judgeDocument(content) {
|
|
3800
|
+
const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
|
|
3801
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3802
|
+
fs.writeFileSync(path.join(dir, 'draft.json'), JSON.stringify(specEvidenceArtifact({
|
|
3803
|
+
admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE } },
|
|
3804
|
+
output: { content },
|
|
3805
|
+
}), null, 2));
|
|
3806
|
+
const r = spawnSync('sh', ['./pre-commit'], {
|
|
3807
|
+
cwd: tmpDir,
|
|
3808
|
+
encoding: 'utf-8',
|
|
3809
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
3810
|
+
});
|
|
3811
|
+
return { status: r.status, stdout: r.stdout };
|
|
3812
|
+
}
|
|
3813
|
+
it.skipIf(!shellOk)('DOCUMENT: a heading that exists only inside a fence is no heading', () => {
|
|
3814
|
+
// A YAML document whose "# Problem" lines sit inside a fence has no
|
|
3815
|
+
// markdown heading at all; the pre-fix reader read the comments as headings.
|
|
3816
|
+
const r = judgeDocument(`The spec, as YAML:${NL}${NL}${FENCE}yaml${NL}# Problem${NL}problem: the thing${NL}# Tasks${NL}tasks: [a, b]${NL}${FENCE}${NL}`);
|
|
3817
|
+
expect(r.status).toBe(1);
|
|
3818
|
+
expect(r.stdout).toContain('has no heading with a body');
|
|
3819
|
+
});
|
|
3820
|
+
it.skipIf(!shellOk)('DOCUMENT: a real heading whose body is a code block is bodied', () => {
|
|
3821
|
+
const r = judgeDocument(`## The spec${NL}${NL}${FENCE}yaml${NL}problem: the thing${NL}${FENCE}${NL}`);
|
|
3822
|
+
expect(r.status).toBe(0);
|
|
3823
|
+
expect(r.stdout).toContain('shape DOCUMENT');
|
|
3824
|
+
});
|
|
3825
|
+
});
|
|
3826
|
+
// ─── The frozen mutation suite (mmnto-ai/totem#2737) ─────
|
|
3827
|
+
//
|
|
3828
|
+
// Authored against the FINAL contract — all NINE promised headings — and run
|
|
3829
|
+
// once against the UNCHANGED reader before either predicate moved, then frozen:
|
|
3830
|
+
// never edited by the commits that follow it. The RED/GREEN pattern it printed
|
|
3831
|
+
// on the unchanged reader is what makes each case evidence of a predicate
|
|
3832
|
+
// rather than a transcription of whatever the implementation happened to do.
|
|
3833
|
+
//
|
|
3834
|
+
// The nine come off SPEC_SYSTEM_PROMPT, not SPEC_REQUIRED_SECTIONS: the
|
|
3835
|
+
// constant still named two headings when this was frozen, and reading the
|
|
3836
|
+
// prompt keeps the suite byte-identical across all three commits. It is also
|
|
3837
|
+
// how no case here ever retypes the em dash in `### Verification (MANDATORY —
|
|
3838
|
+
// do not skip)` (the mmnto-ai/totem#2692 authoring trap). After the extension
|
|
3839
|
+
// the two surfaces are the same nine strings in the same order.
|
|
3840
|
+
describe('buildPreCommitHook spec-gate reader — frozen mutation suite (mmnto-ai/totem#2737)', () => {
|
|
3841
|
+
const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
|
|
3842
|
+
/** The nine `### ` lines of the built-in prompt, in prompt order. */
|
|
3843
|
+
const PROMISED = SPEC_SYSTEM_PROMPT.split('\n').filter((line) => line.startsWith('### '));
|
|
3844
|
+
/** Pick a promised heading by an ASCII prefix — never retype the em dash. */
|
|
3845
|
+
function promised(prefix) {
|
|
3846
|
+
const found = PROMISED.find((heading) => heading.startsWith(prefix));
|
|
3847
|
+
if (found === undefined)
|
|
3848
|
+
throw new Error(`no promised heading starts with ${prefix}`);
|
|
3849
|
+
return found;
|
|
3850
|
+
}
|
|
3851
|
+
const PROBLEM_STATEMENT = promised('### Problem Statement');
|
|
3852
|
+
const FILES_TO_EXAMINE = promised('### Files to Examine');
|
|
3853
|
+
const EDGE_CASES = promised('### Edge Cases');
|
|
3854
|
+
const IMPLEMENTATION_TASKS = promised('### Implementation Tasks');
|
|
3855
|
+
const EXECUTION_FLOW = promised('### Execution Flow');
|
|
3856
|
+
const VERIFICATION = promised('### Verification');
|
|
3857
|
+
const TEST_PLAN = promised('### Test Plan');
|
|
3858
|
+
/** A TAB, built rather than escaped (the mmnto-ai/totem#2692 authoring trap). */
|
|
3859
|
+
const TAB = String.fromCharCode(9);
|
|
3860
|
+
/**
|
|
3861
|
+
* All nine promised headings, each over one plain body line. An override
|
|
3862
|
+
* replaces exactly ONE heading's whole block, so each case changes one thing;
|
|
3863
|
+
* an empty override DROPS that heading from the draft entirely.
|
|
3864
|
+
*/
|
|
3865
|
+
function nineBodied(overrides = {}) {
|
|
3866
|
+
return PROMISED.map((heading) => {
|
|
3867
|
+
const override = overrides[heading];
|
|
3868
|
+
return override === undefined
|
|
3869
|
+
? `${heading}\n\nA non-blank body under ${heading}.\n`
|
|
3870
|
+
: override;
|
|
3871
|
+
})
|
|
3872
|
+
.filter((block) => block.length > 0)
|
|
3873
|
+
.join('\n');
|
|
3874
|
+
}
|
|
3875
|
+
let tmpDir;
|
|
3876
|
+
beforeEach(() => {
|
|
3877
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-2737-'));
|
|
3878
|
+
execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
|
|
3879
|
+
execSync('git checkout -q -b feat/frozen-suite', { cwd: tmpDir, stdio: 'ignore' });
|
|
3880
|
+
fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook(RENDER));
|
|
3881
|
+
});
|
|
3882
|
+
afterEach(() => {
|
|
3883
|
+
cleanTmpDir(tmpDir);
|
|
3884
|
+
});
|
|
3885
|
+
function runHook() {
|
|
3886
|
+
const r = spawnSync('sh', ['./pre-commit'], {
|
|
3887
|
+
cwd: tmpDir,
|
|
3888
|
+
encoding: 'utf-8',
|
|
3889
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
3890
|
+
});
|
|
3891
|
+
return { status: r.status, stdout: r.stdout };
|
|
3892
|
+
}
|
|
3893
|
+
function writeRun(name, artifact) {
|
|
3894
|
+
const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
|
|
3895
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3896
|
+
fs.writeFileSync(path.join(dir, name), JSON.stringify(artifact, null, 2));
|
|
3897
|
+
}
|
|
3898
|
+
/** Every BLOCKED arm must be distinguishable from the other two arms. */
|
|
3899
|
+
function expectDistinctBlock(stdout) {
|
|
3900
|
+
expect(stdout).not.toContain('no totem spec run artifact');
|
|
3901
|
+
expect(stdout).not.toContain('the spec-evidence reader could not run');
|
|
3902
|
+
}
|
|
3903
|
+
/** Judge one draft written under the BUILT-IN prompt on an ISSUE anchor. */
|
|
3904
|
+
function judge(content) {
|
|
3905
|
+
writeRun('draft.json', specEvidenceArtifact({ output: { content } }));
|
|
3906
|
+
return runHook();
|
|
3907
|
+
}
|
|
3908
|
+
// ── Positive mutations: each must PASS ──
|
|
3909
|
+
it.skipIf(!shellOk)('a section that opens with a deeper heading is not empty', () => {
|
|
3910
|
+
const r = judge(nineBodied({
|
|
3911
|
+
[IMPLEMENTATION_TASKS]: `${IMPLEMENTATION_TASKS}\n#### 1. Sub\nA step under the sub-heading.\n`,
|
|
3912
|
+
}));
|
|
3913
|
+
expect(r.status, r.stdout).toBe(0);
|
|
3914
|
+
expect(r.stdout).toContain('· shape TEMPLATE');
|
|
3915
|
+
});
|
|
3916
|
+
it.skipIf(!shellOk)('a promised heading with its parenthetical dropped matches and is named', () => {
|
|
3917
|
+
const r = judge(nineBodied({
|
|
3918
|
+
[EXECUTION_FLOW]: '### Execution Flow\n\nA body under the shortened heading.\n',
|
|
3919
|
+
[VERIFICATION]: '### Verification\n\nA body under the shortened heading.\n',
|
|
3920
|
+
}));
|
|
3921
|
+
expect(r.status, r.stdout).toBe(0);
|
|
3922
|
+
expect(r.stdout).toContain(`· tolerated ${EXECUTION_FLOW} ~ ### Execution Flow`);
|
|
3923
|
+
expect(r.stdout).toContain(`${VERIFICATION} ~ ### Verification`);
|
|
3924
|
+
});
|
|
3925
|
+
it.skipIf(!shellOk)('trailing whitespace on a heading line is tolerated silently', () => {
|
|
3926
|
+
const r = judge(nineBodied({
|
|
3927
|
+
[PROBLEM_STATEMENT]: `${PROBLEM_STATEMENT} ${TAB}\n\nA body under it.\n`,
|
|
3928
|
+
}));
|
|
3929
|
+
expect(r.status, r.stdout).toBe(0);
|
|
3930
|
+
expect(r.stdout).toContain('· shape TEMPLATE');
|
|
3931
|
+
expect(r.stdout).not.toContain('· tolerated');
|
|
3932
|
+
});
|
|
3933
|
+
it.skipIf(!shellOk)('a different trailing parenthetical matches and is named', () => {
|
|
3934
|
+
const r = judge(nineBodied({ [VERIFICATION]: '### Verification (required)\n\nA body under it.\n' }));
|
|
3935
|
+
expect(r.status, r.stdout).toBe(0);
|
|
3936
|
+
expect(r.stdout).toContain('· tolerated');
|
|
3937
|
+
expect(r.stdout).toContain(`${VERIFICATION} ~ ### Verification (required)`);
|
|
3938
|
+
});
|
|
3939
|
+
// ── Negative mutations: each must BLOCK, by name ──
|
|
3940
|
+
it.skipIf(!shellOk)('a genuinely absent promised heading blocks by name', () => {
|
|
3941
|
+
const r = judge(nineBodied({ [TEST_PLAN]: '' }));
|
|
3942
|
+
expect(r.status).toBe(1);
|
|
3943
|
+
expect(r.stdout).toContain(`is missing heading ${TEST_PLAN}`);
|
|
3944
|
+
expectDistinctBlock(r.stdout);
|
|
3945
|
+
});
|
|
3946
|
+
it.skipIf(!shellOk)('a genuinely empty section blocks by name', () => {
|
|
3947
|
+
const r = judge(nineBodied({ [PROBLEM_STATEMENT]: PROBLEM_STATEMENT }));
|
|
3948
|
+
expect(r.status).toBe(1);
|
|
3949
|
+
expect(r.stdout).toContain(`has an empty heading ${PROBLEM_STATEMENT}`);
|
|
3950
|
+
expectDistinctBlock(r.stdout);
|
|
3951
|
+
});
|
|
3952
|
+
it.skipIf(!shellOk)('a whitespace-only body is empty', () => {
|
|
3953
|
+
const r = judge(nineBodied({ [FILES_TO_EXAMINE]: `${FILES_TO_EXAMINE}\n \n${TAB}${TAB}\n\n` }));
|
|
3954
|
+
expect(r.status).toBe(1);
|
|
3955
|
+
expect(r.stdout).toContain(`has an empty heading ${FILES_TO_EXAMINE}`);
|
|
3956
|
+
expectDistinctBlock(r.stdout);
|
|
3957
|
+
});
|
|
3958
|
+
it.skipIf(!shellOk)('a deeper heading with nothing under it is not a body', () => {
|
|
3959
|
+
const r = judge(nineBodied({ [EDGE_CASES]: `${EDGE_CASES}\n#### none` }));
|
|
3960
|
+
expect(r.status).toBe(1);
|
|
3961
|
+
expect(r.stdout).toContain(`has an empty heading ${EDGE_CASES}`);
|
|
3962
|
+
expectDistinctBlock(r.stdout);
|
|
3963
|
+
});
|
|
3964
|
+
it.skipIf(!shellOk)('a shallower heading ends the section', () => {
|
|
3965
|
+
const r = judge(nineBodied({
|
|
3966
|
+
[IMPLEMENTATION_TASKS]: `${IMPLEMENTATION_TASKS}\n## Notes\nProse under the shallower heading.\n`,
|
|
3967
|
+
}));
|
|
3968
|
+
expect(r.status).toBe(1);
|
|
3969
|
+
expect(r.stdout).toContain(`has an empty heading ${IMPLEMENTATION_TASKS}`);
|
|
3970
|
+
expectDistinctBlock(r.stdout);
|
|
3971
|
+
});
|
|
3972
|
+
it.skipIf(!shellOk)('the heading level is exact', () => {
|
|
3973
|
+
const r = judge(nineBodied({ [PROBLEM_STATEMENT]: '## Problem Statement\n\nA body under it.\n' }));
|
|
3974
|
+
expect(r.status).toBe(1);
|
|
3975
|
+
expect(r.stdout).toContain(`is missing heading ${PROBLEM_STATEMENT}`);
|
|
3976
|
+
expectDistinctBlock(r.stdout);
|
|
3977
|
+
});
|
|
3978
|
+
// ── The ruled falsifier (operator ruling 2026-09-03) ──
|
|
3979
|
+
//
|
|
3980
|
+
// The denominator is all SEVEN drafts the R3 probe actually recorded, and the
|
|
3981
|
+
// claim is that none is blocked; the mutated negatives above carry the "flags
|
|
3982
|
+
// the defective" half. The four schema-constrained runs carry the promised
|
|
3983
|
+
// headings BYTE-IDENTICAL, em dash included, so they must match on the EXACT
|
|
3984
|
+
// pass and name no tolerance — which makes this test the executed proof, on
|
|
3985
|
+
// every CI OS, that the em-dash heading round-trips through JSON.stringify,
|
|
3986
|
+
// the single-quoted `node -e` word, sh and node without drift.
|
|
3987
|
+
/** The recorded R3 fixture directory, found by walking up to the repo root. */
|
|
3988
|
+
function resolveR3FixtureDir() {
|
|
3989
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
3990
|
+
for (let i = 0; i < 8; i++) {
|
|
3991
|
+
const candidate = path.join(dir, '.totem', 'fixtures', 'spec-runs-2026-09-02');
|
|
3992
|
+
if (fs.existsSync(candidate))
|
|
3993
|
+
return candidate;
|
|
3994
|
+
const parent = path.dirname(dir);
|
|
3995
|
+
if (parent === dir)
|
|
3996
|
+
break;
|
|
3997
|
+
dir = parent;
|
|
3998
|
+
}
|
|
3999
|
+
return '';
|
|
4000
|
+
}
|
|
4001
|
+
/** The ORIGINAL run's draft: the artifacts.ndjson row whose id starts e5a15c9c. */
|
|
4002
|
+
function readOriginalDraft(dir) {
|
|
4003
|
+
const ndjson = fs.readFileSync(path.join(dir, 'artifacts.ndjson'), 'utf-8');
|
|
4004
|
+
for (const line of ndjson.split('\n')) {
|
|
4005
|
+
if (line.trim().length === 0)
|
|
4006
|
+
continue;
|
|
4007
|
+
let row;
|
|
4008
|
+
try {
|
|
4009
|
+
row = JSON.parse(line);
|
|
4010
|
+
}
|
|
4011
|
+
catch (err) {
|
|
4012
|
+
void err;
|
|
4013
|
+
continue;
|
|
4014
|
+
}
|
|
4015
|
+
if (typeof row.id === 'string' && row.id.startsWith('e5a15c9c')) {
|
|
4016
|
+
const content = row.output?.content;
|
|
4017
|
+
if (typeof content === 'string')
|
|
4018
|
+
return content;
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
return '';
|
|
4022
|
+
}
|
|
4023
|
+
it.skipIf(!shellOk)('the extended reader blocks none of the seven recorded R3 drafts and names the tolerance on the three that dropped parentheticals', () => {
|
|
4024
|
+
const dir = resolveR3FixtureDir();
|
|
4025
|
+
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('');
|
|
4026
|
+
const original = readOriginalDraft(dir);
|
|
4027
|
+
expect(original.length, 'the original R3 draft (artifacts.ndjson row id e5a15c9c…) was not found').toBeGreaterThan(0);
|
|
4028
|
+
const read = (name) => fs.readFileSync(path.join(dir, name), 'utf-8');
|
|
4029
|
+
const drafts = [
|
|
4030
|
+
{
|
|
4031
|
+
name: 'the original artifact (artifacts.ndjson row id e5a15c9c…)',
|
|
4032
|
+
content: original,
|
|
4033
|
+
tolerated: true,
|
|
4034
|
+
},
|
|
4035
|
+
{
|
|
4036
|
+
name: 'r3-unconstrained-run1.md',
|
|
4037
|
+
content: read('r3-unconstrained-run1.md'),
|
|
4038
|
+
tolerated: true,
|
|
4039
|
+
},
|
|
4040
|
+
{
|
|
4041
|
+
name: 'r3-unconstrained-run2.md',
|
|
4042
|
+
content: read('r3-unconstrained-run2.md'),
|
|
4043
|
+
tolerated: true,
|
|
4044
|
+
},
|
|
4045
|
+
{
|
|
4046
|
+
name: 'r3-responseSchema-run1.md',
|
|
4047
|
+
content: read('r3-responseSchema-run1.md'),
|
|
4048
|
+
tolerated: false,
|
|
4049
|
+
},
|
|
4050
|
+
{
|
|
4051
|
+
name: 'r3-responseSchema-run2.md',
|
|
4052
|
+
content: read('r3-responseSchema-run2.md'),
|
|
4053
|
+
tolerated: false,
|
|
4054
|
+
},
|
|
4055
|
+
{
|
|
4056
|
+
name: 'r3-responseJsonSchema-minLength1-run1.md',
|
|
4057
|
+
content: read('r3-responseJsonSchema-minLength1-run1.md'),
|
|
4058
|
+
tolerated: false,
|
|
4059
|
+
},
|
|
4060
|
+
{
|
|
4061
|
+
name: 'r3-responseJsonSchema-minLength1-run2.md',
|
|
4062
|
+
content: read('r3-responseJsonSchema-minLength1-run2.md'),
|
|
4063
|
+
tolerated: false,
|
|
4064
|
+
},
|
|
4065
|
+
];
|
|
4066
|
+
// The denominator is ruled: all seven, or the claim is a different one.
|
|
4067
|
+
expect(drafts).toHaveLength(7);
|
|
4068
|
+
for (const draft of drafts) {
|
|
4069
|
+
const r = judge(draft.content);
|
|
4070
|
+
expect(r.status, `${draft.name} was BLOCKED: ${r.stdout}`).toBe(0);
|
|
4071
|
+
expect(r.stdout, `${draft.name} did not read as TEMPLATE`).toContain('· shape TEMPLATE');
|
|
4072
|
+
if (draft.tolerated) {
|
|
4073
|
+
expect(r.stdout, `${draft.name} dropped a promised parenthetical but the evidence line named no tolerance`).toContain('· tolerated');
|
|
4074
|
+
}
|
|
4075
|
+
else {
|
|
4076
|
+
expect(r.stdout, `${draft.name} carries the promised headings verbatim but the evidence line named a tolerance`).not.toContain('· tolerated');
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4079
|
+
});
|
|
4080
|
+
});
|
|
4081
|
+
// A tolerantly-matched heading that then blocks for an empty body used to name
|
|
4082
|
+
// a string the draft does not contain: the seat reads `has an empty heading
|
|
4083
|
+
// ### Verification (MANDATORY — do not skip)`, searches the draft for it, finds
|
|
4084
|
+
// nothing, and cannot see that the reader matched `### Verification` and judged
|
|
4085
|
+
// THAT section. The block now carries both spellings. Outside the frozen block
|
|
4086
|
+
// (mmnto-ai/totem#2737 fold round) — the suite above was run against the
|
|
4087
|
+
// unchanged reader and is not edited after.
|
|
4088
|
+
describe('buildPreCommitHook spec-gate reader — tolerance disclosure on the block path (mmnto-ai/totem#2737 fold)', () => {
|
|
4089
|
+
const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
|
|
4090
|
+
const PROMISED = SPEC_SYSTEM_PROMPT.split('\n').filter((line) => line.startsWith('### '));
|
|
4091
|
+
const VERIFICATION = PROMISED.find((heading) => heading.startsWith('### Verification')) ?? '';
|
|
4092
|
+
let tmpDir;
|
|
4093
|
+
beforeEach(() => {
|
|
4094
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-2737-fold-'));
|
|
4095
|
+
execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
|
|
4096
|
+
execSync('git checkout -q -b feat/fold', { cwd: tmpDir, stdio: 'ignore' });
|
|
4097
|
+
fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook(RENDER));
|
|
4098
|
+
});
|
|
4099
|
+
afterEach(() => {
|
|
4100
|
+
cleanTmpDir(tmpDir);
|
|
4101
|
+
});
|
|
4102
|
+
it.skipIf(!shellOk)('an EMPTY section under a tolerantly-matched heading names both the promised and the matched line', () => {
|
|
4103
|
+
// Every promised heading bodied except Verification, which appears with
|
|
4104
|
+
// its parenthetical dropped AND no body — so the reader must reach the
|
|
4105
|
+
// empty-body block by way of the tolerant pass.
|
|
4106
|
+
const content = PROMISED.map((heading) => heading === VERIFICATION
|
|
4107
|
+
? '### Verification\n'
|
|
4108
|
+
: `${heading}\n\nA non-blank body under ${heading}.\n`).join('\n');
|
|
4109
|
+
const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
|
|
4110
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
4111
|
+
fs.writeFileSync(path.join(dir, 'tolerant-empty.json'), JSON.stringify(specEvidenceArtifact({ output: { content } }), null, 2));
|
|
4112
|
+
const r = spawnSync('sh', ['./pre-commit'], {
|
|
4113
|
+
cwd: tmpDir,
|
|
4114
|
+
encoding: 'utf-8',
|
|
4115
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
4116
|
+
});
|
|
4117
|
+
expect(r.status).toBe(1);
|
|
4118
|
+
expect(r.stdout).toContain(`has an empty heading ${VERIFICATION} (matched as ### Verification)`);
|
|
4119
|
+
// Still distinguishable from the other two BLOCKED arms.
|
|
4120
|
+
expect(r.stdout).not.toContain('no totem spec run artifact');
|
|
4121
|
+
expect(r.stdout).not.toContain('the spec-evidence reader could not run');
|
|
4122
|
+
// The new clause echoes a DRAFT line, so it is a forged-line sink like
|
|
4123
|
+
// every other value that reaches stdout.
|
|
4124
|
+
expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
|
|
4125
|
+
// …and the matched line passes through `safe()`: a C1 control inside the
|
|
4126
|
+
// heading is collapsed rather than echoed into the block message.
|
|
4127
|
+
const nel = String.fromCharCode(0x85);
|
|
4128
|
+
const forgedHeading = `### Verification (a${nel}b)`;
|
|
4129
|
+
const forgedContent = PROMISED.map((heading) => heading === VERIFICATION
|
|
4130
|
+
? `${forgedHeading}\n`
|
|
4131
|
+
: `${heading}\n\nA non-blank body under ${heading}.\n`).join('\n');
|
|
4132
|
+
fs.writeFileSync(path.join(dir, 'tolerant-empty.json'), JSON.stringify(specEvidenceArtifact({ output: { content: forgedContent } }), null, 2));
|
|
4133
|
+
const forgedRun = spawnSync('sh', ['./pre-commit'], {
|
|
4134
|
+
cwd: tmpDir,
|
|
4135
|
+
encoding: 'utf-8',
|
|
4136
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
4137
|
+
});
|
|
4138
|
+
expect(forgedRun.status).toBe(1);
|
|
4139
|
+
expect(forgedRun.stdout).toContain(`has an empty heading ${VERIFICATION} (matched as ### Verification (a?b))`);
|
|
4140
|
+
expect(forgedRun.stdout).not.toContain(nel);
|
|
4141
|
+
expect(forgedRun.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
|
|
4142
|
+
});
|
|
2627
4143
|
});
|
|
2628
4144
|
//# sourceMappingURL=install-hooks.test.js.map
|