@mmnto/cli 1.121.0 → 1.123.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/artifact-vocabulary.d.ts +23 -0
- package/dist/artifact-vocabulary.d.ts.map +1 -0
- package/dist/artifact-vocabulary.js +23 -0
- package/dist/artifact-vocabulary.js.map +1 -0
- package/dist/commands/config-drift.test.js +89 -1
- package/dist/commands/config-drift.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +6 -6
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +8 -7
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.test.js +18 -5
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +241 -17
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/install-hooks.test.js +1019 -23
- package/dist/commands/install-hooks.test.js.map +1 -1
- package/dist/commands/legs.d.ts +259 -0
- package/dist/commands/legs.d.ts.map +1 -0
- package/dist/commands/legs.js +623 -0
- package/dist/commands/legs.js.map +1 -0
- package/dist/commands/legs.test.d.ts +20 -0
- package/dist/commands/legs.test.d.ts.map +1 -0
- package/dist/commands/legs.test.js +1004 -0
- package/dist/commands/legs.test.js.map +1 -0
- package/dist/commands/pre-push-gate-matrix.test.js +14 -0
- package/dist/commands/pre-push-gate-matrix.test.js.map +1 -1
- package/dist/commands/review-fan.d.ts +40 -1
- package/dist/commands/review-fan.d.ts.map +1 -1
- package/dist/commands/review-fan.js +108 -3
- package/dist/commands/review-fan.js.map +1 -1
- package/dist/commands/review-fan.test.js +444 -6
- package/dist/commands/review-fan.test.js.map +1 -1
- package/dist/commands/shield-covariate.test.js +19 -2
- package/dist/commands/shield-covariate.test.js.map +1 -1
- package/dist/commands/shield-nonreview.test.js +140 -1
- package/dist/commands/shield-nonreview.test.js.map +1 -1
- package/dist/commands/shield.d.ts.map +1 -1
- package/dist/commands/shield.js +53 -1
- package/dist/commands/shield.js.map +1 -1
- package/dist/commands/spec-cli-wiring.test.d.ts +19 -0
- package/dist/commands/spec-cli-wiring.test.d.ts.map +1 -0
- package/dist/commands/spec-cli-wiring.test.js +90 -0
- package/dist/commands/spec-cli-wiring.test.js.map +1 -0
- package/dist/commands/spec-templates.d.ts +18 -0
- package/dist/commands/spec-templates.d.ts.map +1 -1
- package/dist/commands/spec-templates.js +21 -0
- package/dist/commands/spec-templates.js.map +1 -1
- package/dist/commands/spec.d.ts +168 -1
- package/dist/commands/spec.d.ts.map +1 -1
- package/dist/commands/spec.js +448 -7
- package/dist/commands/spec.js.map +1 -1
- package/dist/commands/spec.test.js +903 -20
- package/dist/commands/spec.test.js.map +1 -1
- package/dist/git.d.ts +19 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +13 -4
- package/dist/git.js.map +1 -1
- package/dist/index.js +47 -2
- package/dist/index.js.map +1 -1
- package/dist/services/run-artifacts.d.ts +12 -1
- package/dist/services/run-artifacts.d.ts.map +1 -1
- package/dist/services/run-artifacts.js +48 -3
- package/dist/services/run-artifacts.js.map +1 -1
- package/dist/services/run-artifacts.test.js +97 -1
- package/dist/services/run-artifacts.test.js.map +1 -1
- package/dist/utils.d.ts +22 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +4 -0
- package/dist/utils.js.map +1 -1
- package/dist/utils.test.js +65 -1
- package/dist/utils.test.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { execSync, spawnSync } from 'node:child_process';
|
|
1
|
+
import { execFileSync, execSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import * as crypto from 'node:crypto';
|
|
2
3
|
import * as fs from 'node:fs';
|
|
3
4
|
import * as os from 'node:os';
|
|
4
5
|
import * as path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
5
7
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
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';
|
|
6
9
|
import { cleanTmpDir } from '../test-utils.js';
|
|
7
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';
|
|
8
12
|
// The default render options every pre-#2692 positional call implied
|
|
9
13
|
// (mmnto-ai/totem#2692 C2 — the builders take a REQUIRED options object now, so
|
|
10
14
|
// each site states the tier/totemDir/fallbackCmd it always assumed). Spread and
|
|
@@ -14,6 +18,84 @@ const RENDER = {
|
|
|
14
18
|
totemDir: '.totem',
|
|
15
19
|
fallbackCmd: 'pnpm dlx @mmnto/cli',
|
|
16
20
|
};
|
|
21
|
+
// ─── Anchored-evidence fixtures (mmnto-ai/totem#2700) ───
|
|
22
|
+
//
|
|
23
|
+
// Since #2700 an artifact is evidence only when it is ANCHORED and its SUBJECT
|
|
24
|
+
// carries a shape. These build the smallest artifact that satisfies each arm,
|
|
25
|
+
// so a test that means to exercise something else (age, newest-wins, torn
|
|
26
|
+
// files) does not accidentally exercise the shape check.
|
|
27
|
+
/** A draft that satisfies the TEMPLATE shape: every required heading with a body. */
|
|
28
|
+
const TEMPLATE_DRAFT = SPEC_REQUIRED_SECTIONS.map((heading) => `${heading}\n\nA non-blank body under ${heading}.\n`).join('\n');
|
|
29
|
+
/** A draft that satisfies only the looser DOCUMENT shape — one heading with a body. */
|
|
30
|
+
const DOCUMENT_DRAFT = '## A hand-shaped section\n\nA body under it.\n';
|
|
31
|
+
/** The minimal ISSUE-anchored, template-shaped run artifact the gate accepts. */
|
|
32
|
+
function specEvidenceArtifact(overrides = {}) {
|
|
33
|
+
return {
|
|
34
|
+
admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_BUILTIN } },
|
|
35
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_ISSUE, ref: '#2700' } },
|
|
36
|
+
output: { content: TEMPLATE_DRAFT },
|
|
37
|
+
createdAt: new Date().toISOString(),
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The fixture wrapped in the fields every run artifact must carry and the
|
|
43
|
+
* reader never touches — so the whole thing can be parsed by the REAL schema.
|
|
44
|
+
*/
|
|
45
|
+
function asRunArtifact(fixture) {
|
|
46
|
+
const grounding = fixture['grounding'];
|
|
47
|
+
const output = fixture['output'];
|
|
48
|
+
return {
|
|
49
|
+
...fixture,
|
|
50
|
+
schemaVersion: RUN_ARTIFACT_SCHEMA_VERSION,
|
|
51
|
+
inputBundle: { maskedPrompt: 'the masked prompt' },
|
|
52
|
+
inputHash: 'a'.repeat(64),
|
|
53
|
+
grounding: { hash: 'b'.repeat(64), provenanceSummary: 'similarity-only:1', ...grounding },
|
|
54
|
+
backend: {
|
|
55
|
+
provider: 'gemini',
|
|
56
|
+
model: 'gemini-3-flash-preview',
|
|
57
|
+
qualifiedModel: 'gemini:gemini-3-flash-preview',
|
|
58
|
+
admissionClass: 'completion_only',
|
|
59
|
+
taskProfile: 'Spec',
|
|
60
|
+
},
|
|
61
|
+
output: { metrics: { durationMs: 500 }, ...output },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// The hook's reader is a rendered `node -e` string: it walks the artifact by
|
|
65
|
+
// HAND-SPELLED paths (`grounding.anchor`, `admission.runMetadata.promptSource`,
|
|
66
|
+
// `output.content`) that no type checker sees. These tests bind the PASS
|
|
67
|
+
// fixtures those paths are exercised against to the real core schema, so a
|
|
68
|
+
// rename or a shape change on the writer's side breaks a test here rather than
|
|
69
|
+
// silently leaving the hook reading a field that no longer exists.
|
|
70
|
+
describe('the anchored-evidence fixtures parse as real run artifacts (mmnto-ai/totem#2700)', () => {
|
|
71
|
+
it('the issue-anchored PASS fixture round-trips through RunArtifactSchema with its three read paths intact', () => {
|
|
72
|
+
const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact()));
|
|
73
|
+
expect(parsed.grounding.anchor).toEqual({ kind: GROUNDING_ANCHOR_ISSUE, ref: '#2700' });
|
|
74
|
+
expect(parsed.admission?.runMetadata?.promptSource).toBe(PROMPT_SOURCE_BUILTIN);
|
|
75
|
+
expect(parsed.output.content).toBe(TEMPLATE_DRAFT);
|
|
76
|
+
});
|
|
77
|
+
it('the record-anchored PASS fixture round-trips, sha256 and all', () => {
|
|
78
|
+
const sha256 = 'a'.repeat(64);
|
|
79
|
+
const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact({
|
|
80
|
+
grounding: {
|
|
81
|
+
anchor: { kind: GROUNDING_ANCHOR_RECORD, ref: '.totem/specs/2700.md', sha256 },
|
|
82
|
+
},
|
|
83
|
+
})));
|
|
84
|
+
expect(parsed.grounding.anchor).toEqual({
|
|
85
|
+
kind: GROUNDING_ANCHOR_RECORD,
|
|
86
|
+
ref: '.totem/specs/2700.md',
|
|
87
|
+
sha256,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
it('the override-prompt PASS fixture round-trips with promptSource "override"', () => {
|
|
91
|
+
const parsed = RunArtifactSchema.parse(asRunArtifact(specEvidenceArtifact({
|
|
92
|
+
admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE } },
|
|
93
|
+
output: { content: DOCUMENT_DRAFT },
|
|
94
|
+
})));
|
|
95
|
+
expect(parsed.admission?.runMetadata?.promptSource).toBe(PROMPT_SOURCE_OVERRIDE);
|
|
96
|
+
expect(parsed.output.content).toBe(DOCUMENT_DRAFT);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
17
99
|
describe('detectTotemPrefix', () => {
|
|
18
100
|
let tmpDir;
|
|
19
101
|
beforeEach(() => {
|
|
@@ -1315,6 +1397,30 @@ describe('buildPreCommitHook with strict tier', () => {
|
|
|
1315
1397
|
expect(hook).toContain('spec evidence:');
|
|
1316
1398
|
expect(hook).not.toContain('(legacy marker)');
|
|
1317
1399
|
});
|
|
1400
|
+
// mmnto-ai/totem#2700 — the reader's vocabulary is RENDERED from the one
|
|
1401
|
+
// canonical constant, never re-spelled in the hook text, so a change to the
|
|
1402
|
+
// writer's spelling can never leave the gate reading a stale word.
|
|
1403
|
+
it('renders SPEC_REQUIRED_SECTIONS and the anchor vocabulary as JSON literals', () => {
|
|
1404
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1405
|
+
expect(hook).toContain(`const REQUIRED = ${JSON.stringify(SPEC_REQUIRED_SECTIONS)};`);
|
|
1406
|
+
expect(hook).toContain(`const KIND_ISSUE = ${JSON.stringify(GROUNDING_ANCHOR_ISSUE)};`);
|
|
1407
|
+
expect(hook).toContain(`const KIND_RECORD = ${JSON.stringify(GROUNDING_ANCHOR_RECORD)};`);
|
|
1408
|
+
expect(hook).toContain(`const PROMPT_OVERRIDE = ${JSON.stringify(PROMPT_SOURCE_OVERRIDE)};`);
|
|
1409
|
+
});
|
|
1410
|
+
it('carries the exit-3 arm distinctly from the exit-2 and reader-failure arms', () => {
|
|
1411
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1412
|
+
expect(hook).toContain('elif [ "$reader_status" = "3" ]; then');
|
|
1413
|
+
expect(hook).toContain(`echo "[Totem] BLOCKED: $spec_evidence — run 'totem spec <issue>' or 'totem spec --from <record>' (add --fresh if the response is cached) (strict mode)"`);
|
|
1414
|
+
// The two pre-existing arms keep their exact text.
|
|
1415
|
+
expect(hook).toContain('no totem spec run artifact under .totem/artifacts/runs/');
|
|
1416
|
+
expect(hook).toContain('the spec-evidence reader could not run (node exit status');
|
|
1417
|
+
});
|
|
1418
|
+
it('the reader body carries no single quote (it lives inside a single-quoted node -e word)', () => {
|
|
1419
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1420
|
+
const reader = hook.split("node -e '")[1]?.split("\n' 2>/dev/null)")[0] ?? '';
|
|
1421
|
+
expect(reader.length).toBeGreaterThan(0);
|
|
1422
|
+
expect(reader).not.toContain("'");
|
|
1423
|
+
});
|
|
1318
1424
|
});
|
|
1319
1425
|
// The strict block EXECUTED under sh (mmnto-ai/totem#2690): the string checks
|
|
1320
1426
|
// above pin the text; these pin the behavior the gate exists for. `git init` +
|
|
@@ -1382,10 +1488,7 @@ describe('buildPreCommitHook strict evidence — executed under sh (mmnto-ai/tot
|
|
|
1382
1488
|
expect(r.stdout).toContain('no totem spec run artifact under .totem/artifacts/runs/');
|
|
1383
1489
|
});
|
|
1384
1490
|
it.skipIf(!shellOk)('passes on a spec run artifact and prints the evidence line with its age', () => {
|
|
1385
|
-
writeRun('a.json',
|
|
1386
|
-
admission: { runMetadata: { caller: 'spec' } },
|
|
1387
|
-
createdAt: new Date().toISOString(),
|
|
1388
|
-
});
|
|
1491
|
+
writeRun('a.json', specEvidenceArtifact());
|
|
1389
1492
|
const r = runHook();
|
|
1390
1493
|
expect(r.status).toBe(0);
|
|
1391
1494
|
expect(r.stdout).toContain('[Totem] spec evidence: .totem/artifacts/runs/a.json (');
|
|
@@ -1423,10 +1526,7 @@ describe('buildPreCommitHook strict evidence — executed under sh (mmnto-ai/tot
|
|
|
1423
1526
|
});
|
|
1424
1527
|
it.skipIf(!shellOk)('skips a torn (unparseable) artifact and still passes on a valid one beside it', () => {
|
|
1425
1528
|
writeRun('torn.json', '{"admission": {"runMetadata": {"caller": "spec"');
|
|
1426
|
-
writeRun('ok.json',
|
|
1427
|
-
admission: { runMetadata: { caller: 'spec' } },
|
|
1428
|
-
createdAt: new Date().toISOString(),
|
|
1429
|
-
});
|
|
1529
|
+
writeRun('ok.json', specEvidenceArtifact());
|
|
1430
1530
|
const r = runHook();
|
|
1431
1531
|
expect(r.status).toBe(0);
|
|
1432
1532
|
expect(r.stdout).toContain('.totem/artifacts/runs/ok.json');
|
|
@@ -1437,18 +1537,463 @@ describe('buildPreCommitHook strict evidence — executed under sh (mmnto-ai/tot
|
|
|
1437
1537
|
expect(r.status).toBe(1);
|
|
1438
1538
|
});
|
|
1439
1539
|
it.skipIf(!shellOk)('names the NEWEST spec artifact by its own createdAt', () => {
|
|
1440
|
-
writeRun('old.json', {
|
|
1441
|
-
|
|
1442
|
-
|
|
1540
|
+
writeRun('old.json', specEvidenceArtifact({ createdAt: '2026-01-01T00:00:00.000Z' }));
|
|
1541
|
+
writeRun('new.json', specEvidenceArtifact());
|
|
1542
|
+
const r = runHook();
|
|
1543
|
+
expect(r.status).toBe(0);
|
|
1544
|
+
expect(r.stdout).toContain('.totem/artifacts/runs/new.json');
|
|
1545
|
+
expect(r.stdout).not.toContain('old.json');
|
|
1546
|
+
});
|
|
1547
|
+
});
|
|
1548
|
+
// The mmnto-ai/totem#2700 arm, EXECUTED: an artifact is evidence only when it
|
|
1549
|
+
// is ANCHORED (issue | record) and its SUBJECT carries a shape. Every negation
|
|
1550
|
+
// must BLOCK with its OWN named reason — never with the "no artifact" line and
|
|
1551
|
+
// never as a reader failure — because the reason is the whole cure.
|
|
1552
|
+
describe('buildPreCommitHook anchored evidence — executed under sh (mmnto-ai/totem#2700)', () => {
|
|
1553
|
+
const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
|
|
1554
|
+
/**
|
|
1555
|
+
* Whether this platform (and this account) can make a symlink — a Windows
|
|
1556
|
+
* account without SeCreateSymbolicLinkPrivilege cannot. Probed ONCE, outside
|
|
1557
|
+
* any test, so the skip is a real capability check rather than a swallowed
|
|
1558
|
+
* failure inside the assertion.
|
|
1559
|
+
*/
|
|
1560
|
+
const symlinkable = (() => {
|
|
1561
|
+
const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-link-probe-'));
|
|
1562
|
+
const target = path.join(probeDir, 'target');
|
|
1563
|
+
fs.writeFileSync(target, 'probe');
|
|
1564
|
+
let ok = false;
|
|
1565
|
+
try {
|
|
1566
|
+
fs.symlinkSync(target, path.join(probeDir, 'sym'));
|
|
1567
|
+
ok = true;
|
|
1568
|
+
}
|
|
1569
|
+
catch (err) {
|
|
1570
|
+
void err;
|
|
1571
|
+
ok = false;
|
|
1572
|
+
}
|
|
1573
|
+
cleanTmpDir(probeDir);
|
|
1574
|
+
return ok;
|
|
1575
|
+
})();
|
|
1576
|
+
let tmpDir;
|
|
1577
|
+
/** A directory OUTSIDE the worktree — the target of the escaping-symlink case. */
|
|
1578
|
+
let outsideDir;
|
|
1579
|
+
beforeEach(() => {
|
|
1580
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-anchor-'));
|
|
1581
|
+
outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-outside-'));
|
|
1582
|
+
execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
|
|
1583
|
+
execSync('git checkout -q -b feat/anchored', { cwd: tmpDir, stdio: 'ignore' });
|
|
1584
|
+
fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook(RENDER));
|
|
1585
|
+
});
|
|
1586
|
+
afterEach(() => {
|
|
1587
|
+
cleanTmpDir(tmpDir);
|
|
1588
|
+
cleanTmpDir(outsideDir);
|
|
1589
|
+
});
|
|
1590
|
+
function runHook() {
|
|
1591
|
+
const r = spawnSync('sh', ['./pre-commit'], {
|
|
1592
|
+
cwd: tmpDir,
|
|
1593
|
+
encoding: 'utf-8',
|
|
1594
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
1443
1595
|
});
|
|
1444
|
-
|
|
1596
|
+
return { status: r.status, stdout: r.stdout };
|
|
1597
|
+
}
|
|
1598
|
+
function writeRun(name, artifact) {
|
|
1599
|
+
const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
|
|
1600
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1601
|
+
fs.writeFileSync(path.join(dir, name), JSON.stringify(artifact, null, 2));
|
|
1602
|
+
}
|
|
1603
|
+
/** Write a bound record at a repo-relative path and return its sha256. */
|
|
1604
|
+
function writeRecord(relPath, body) {
|
|
1605
|
+
const abs = path.join(tmpDir, ...relPath.split('/'));
|
|
1606
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
1607
|
+
fs.writeFileSync(abs, body, 'utf-8');
|
|
1608
|
+
return crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex');
|
|
1609
|
+
}
|
|
1610
|
+
/** Every BLOCKED arm must be distinguishable from the other two arms. */
|
|
1611
|
+
function expectDistinctBlock(stdout) {
|
|
1612
|
+
expect(stdout).not.toContain('no totem spec run artifact');
|
|
1613
|
+
expect(stdout).not.toContain('the spec-evidence reader could not run');
|
|
1614
|
+
}
|
|
1615
|
+
// ── The migration invariant ──
|
|
1616
|
+
it.skipIf(!shellOk)('a PRE-EXISTING artifact (no grounding.anchor) BLOCKS with the `predates` reason, not the no-artifact line', () => {
|
|
1617
|
+
writeRun('legacy.json', {
|
|
1445
1618
|
admission: { runMetadata: { caller: 'spec' } },
|
|
1619
|
+
output: { content: TEMPLATE_DRAFT },
|
|
1446
1620
|
createdAt: new Date().toISOString(),
|
|
1447
1621
|
});
|
|
1448
1622
|
const r = runHook();
|
|
1623
|
+
expect(r.status).toBe(1);
|
|
1624
|
+
expect(r.stdout).toContain('predates the anchored-evidence rule (no grounding.anchor)');
|
|
1625
|
+
expect(r.stdout).toContain('.totem/artifacts/runs/legacy.json');
|
|
1626
|
+
expectDistinctBlock(r.stdout);
|
|
1627
|
+
});
|
|
1628
|
+
// ── The unanchored kinds ──
|
|
1629
|
+
it.skipIf(!shellOk)('a `free-text` anchor BLOCKS, naming the kind and the topic', () => {
|
|
1630
|
+
writeRun('ft.json', specEvidenceArtifact({
|
|
1631
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'a loose slug' } },
|
|
1632
|
+
}));
|
|
1633
|
+
const r = runHook();
|
|
1634
|
+
expect(r.status).toBe(1);
|
|
1635
|
+
expect(r.stdout).toContain(`is anchored ${GROUNDING_ANCHOR_FREE_TEXT} (a loose slug)`);
|
|
1636
|
+
expect(r.stdout).toContain('not gate evidence');
|
|
1637
|
+
expectDistinctBlock(r.stdout);
|
|
1638
|
+
});
|
|
1639
|
+
it.skipIf(!shellOk)('a `mixed` anchor BLOCKS, naming the kind and the ref', () => {
|
|
1640
|
+
writeRun('mx.json', specEvidenceArtifact({
|
|
1641
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_MIXED, ref: '#2700 | a loose slug' } },
|
|
1642
|
+
}));
|
|
1643
|
+
const r = runHook();
|
|
1644
|
+
expect(r.status).toBe(1);
|
|
1645
|
+
expect(r.stdout).toContain(`is anchored ${GROUNDING_ANCHOR_MIXED} (#2700 | a loose slug)`);
|
|
1646
|
+
expectDistinctBlock(r.stdout);
|
|
1647
|
+
});
|
|
1648
|
+
it.skipIf(!shellOk)('both cures are named on every BLOCKED arm', () => {
|
|
1649
|
+
writeRun('ft.json', specEvidenceArtifact({
|
|
1650
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
|
|
1651
|
+
}));
|
|
1652
|
+
const r = runHook();
|
|
1653
|
+
expect(r.stdout).toContain("run 'totem spec <issue>' or 'totem spec --from <record>'");
|
|
1654
|
+
});
|
|
1655
|
+
// ── The TEMPLATE shape (issue anchor, built-in prompt) ──
|
|
1656
|
+
it.skipIf(!shellOk)('the one-token draft (a lone newline) BLOCKS on the first missing heading', () => {
|
|
1657
|
+
writeRun('thin.json', specEvidenceArtifact({ output: { content: '\n' } }));
|
|
1658
|
+
const r = runHook();
|
|
1659
|
+
expect(r.status).toBe(1);
|
|
1660
|
+
expect(r.stdout).toContain('is missing heading ### Problem Statement');
|
|
1661
|
+
expectDistinctBlock(r.stdout);
|
|
1662
|
+
});
|
|
1663
|
+
it.skipIf(!shellOk)('both required headings present over EMPTY bodies BLOCK, naming the empty heading', () => {
|
|
1664
|
+
writeRun('empty.json', specEvidenceArtifact({
|
|
1665
|
+
output: { content: '### Problem Statement\n\n### Implementation Tasks\n' },
|
|
1666
|
+
}));
|
|
1667
|
+
const r = runHook();
|
|
1668
|
+
expect(r.status).toBe(1);
|
|
1669
|
+
expect(r.stdout).toContain('has an empty heading ### Problem Statement');
|
|
1670
|
+
expectDistinctBlock(r.stdout);
|
|
1671
|
+
});
|
|
1672
|
+
it.skipIf(!shellOk)('a second required heading left empty BLOCKS, naming THAT heading', () => {
|
|
1673
|
+
writeRun('half.json', specEvidenceArtifact({
|
|
1674
|
+
output: {
|
|
1675
|
+
content: '### Problem Statement\n\nA real body.\n\n### Implementation Tasks\n',
|
|
1676
|
+
},
|
|
1677
|
+
}));
|
|
1678
|
+
const r = runHook();
|
|
1679
|
+
expect(r.status).toBe(1);
|
|
1680
|
+
expect(r.stdout).toContain('has an empty heading ### Implementation Tasks');
|
|
1681
|
+
});
|
|
1682
|
+
it.skipIf(!shellOk)('both required headings with bodies PASS, and the evidence line carries the anchor and the shape', () => {
|
|
1683
|
+
writeRun('ok.json', specEvidenceArtifact());
|
|
1684
|
+
const r = runHook();
|
|
1685
|
+
expect(r.status).toBe(0);
|
|
1686
|
+
expect(r.stdout).toContain('[Totem] spec evidence: .totem/artifacts/runs/ok.json (');
|
|
1687
|
+
expect(r.stdout).toContain(`· anchor ${GROUNDING_ANCHOR_ISSUE} #2700`);
|
|
1688
|
+
expect(r.stdout).toContain('· shape TEMPLATE');
|
|
1689
|
+
});
|
|
1690
|
+
it.skipIf(!shellOk)('a non-string draft on an issue anchor BLOCKS as not-text', () => {
|
|
1691
|
+
writeRun('nt.json', specEvidenceArtifact({ output: { content: { nested: true } } }));
|
|
1692
|
+
const r = runHook();
|
|
1693
|
+
expect(r.status).toBe(1);
|
|
1694
|
+
expect(r.stdout).toContain('the draft is not text');
|
|
1695
|
+
expectDistinctBlock(r.stdout);
|
|
1696
|
+
});
|
|
1697
|
+
// ── The DOCUMENT shape (override prompt on an issue anchor) ──
|
|
1698
|
+
it.skipIf(!shellOk)('an OVERRIDE-prompt draft without the template headings PASSES on one heading with a body', () => {
|
|
1699
|
+
writeRun('ov.json', specEvidenceArtifact({
|
|
1700
|
+
admission: {
|
|
1701
|
+
runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE },
|
|
1702
|
+
},
|
|
1703
|
+
output: { content: DOCUMENT_DRAFT },
|
|
1704
|
+
}));
|
|
1705
|
+
const r = runHook();
|
|
1706
|
+
expect(r.status).toBe(0);
|
|
1707
|
+
expect(r.stdout).toContain('· shape DOCUMENT');
|
|
1708
|
+
});
|
|
1709
|
+
it.skipIf(!shellOk)('an OVERRIDE-prompt draft with no heading at all BLOCKS', () => {
|
|
1710
|
+
writeRun('ovbad.json', specEvidenceArtifact({
|
|
1711
|
+
admission: { runMetadata: { caller: 'spec', promptSource: PROMPT_SOURCE_OVERRIDE } },
|
|
1712
|
+
output: { content: 'just prose, no heading\n' },
|
|
1713
|
+
}));
|
|
1714
|
+
const r = runHook();
|
|
1715
|
+
expect(r.status).toBe(1);
|
|
1716
|
+
expect(r.stdout).toContain('has no heading with a body');
|
|
1717
|
+
expect(r.stdout).toContain('custom prompt');
|
|
1718
|
+
expectDistinctBlock(r.stdout);
|
|
1719
|
+
});
|
|
1720
|
+
// ── The record arm: the SUBJECT is the record's bytes ──
|
|
1721
|
+
function recordArtifact(ref, sha256, draft = TEMPLATE_DRAFT) {
|
|
1722
|
+
return specEvidenceArtifact({
|
|
1723
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_RECORD, ref, sha256 } },
|
|
1724
|
+
output: { content: draft },
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
it.skipIf(!shellOk)('a present record with a heading and a body PASSES, reporting `record sha256 matches`', () => {
|
|
1728
|
+
const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
|
|
1729
|
+
writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
|
|
1730
|
+
const r = runHook();
|
|
1449
1731
|
expect(r.status).toBe(0);
|
|
1450
|
-
expect(r.stdout).toContain(
|
|
1451
|
-
expect(r.stdout).
|
|
1732
|
+
expect(r.stdout).toContain(`· anchor ${GROUNDING_ANCHOR_RECORD} .totem/specs/2700.md`);
|
|
1733
|
+
expect(r.stdout).toContain('· shape DOCUMENT');
|
|
1734
|
+
expect(r.stdout).toContain('· record sha256 matches');
|
|
1735
|
+
});
|
|
1736
|
+
it.skipIf(!shellOk)('a REVISED record still PASSES — the digest is a SENSOR, never a gate', () => {
|
|
1737
|
+
const bound = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
|
|
1738
|
+
writeRun('rec.json', recordArtifact('.totem/specs/2700.md', bound));
|
|
1739
|
+
const now = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract, folded.\n');
|
|
1740
|
+
const r = runHook();
|
|
1741
|
+
expect(r.status).toBe(0);
|
|
1742
|
+
expect(r.stdout).toContain(`record revised since binding (bound ${bound.slice(0, 8)}, now ${now.slice(0, 8)})`);
|
|
1743
|
+
});
|
|
1744
|
+
it.skipIf(!shellOk)('a MISSING record BLOCKS, naming the path it was bound at', () => {
|
|
1745
|
+
writeRun('rec.json', recordArtifact('.totem/specs/gone.md', 'b'.repeat(64)));
|
|
1746
|
+
const r = runHook();
|
|
1747
|
+
expect(r.status).toBe(1);
|
|
1748
|
+
expect(r.stdout).toContain('the bound record is missing at .totem/specs/gone.md');
|
|
1749
|
+
expectDistinctBlock(r.stdout);
|
|
1750
|
+
});
|
|
1751
|
+
it.skipIf(!shellOk)('a record with NO heading-with-a-body BLOCKS', () => {
|
|
1752
|
+
const sha = writeRecord('.totem/specs/2700.md', 'prose with no heading\n');
|
|
1753
|
+
writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
|
|
1754
|
+
const r = runHook();
|
|
1755
|
+
expect(r.status).toBe(1);
|
|
1756
|
+
expect(r.stdout).toContain('the bound record at .totem/specs/2700.md has no heading with a body');
|
|
1757
|
+
expectDistinctBlock(r.stdout);
|
|
1758
|
+
});
|
|
1759
|
+
it.skipIf(!shellOk)('a record whose heading has no body BLOCKS (a heading alone is not a document)', () => {
|
|
1760
|
+
const sha = writeRecord('.totem/specs/2700.md', '# Heading only\n\n## Another heading\n');
|
|
1761
|
+
writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha));
|
|
1762
|
+
const r = runHook();
|
|
1763
|
+
expect(r.status).toBe(1);
|
|
1764
|
+
expect(r.stdout).toContain('has no heading with a body');
|
|
1765
|
+
});
|
|
1766
|
+
it.skipIf(!shellOk)('a THIN draft with a rich record PASSES — the reader never reads output.content on a record anchor', () => {
|
|
1767
|
+
const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
|
|
1768
|
+
writeRun('rec.json', recordArtifact('.totem/specs/2700.md', sha, '\n'));
|
|
1769
|
+
const r = runHook();
|
|
1770
|
+
expect(r.status).toBe(0);
|
|
1771
|
+
expect(r.stdout).toContain('· record sha256 matches');
|
|
1772
|
+
});
|
|
1773
|
+
it.skipIf(!shellOk)('a RICH draft with a missing record BLOCKS — the record is the subject, not the draft', () => {
|
|
1774
|
+
writeRun('rec.json', recordArtifact('.totem/specs/gone.md', 'c'.repeat(64), TEMPLATE_DRAFT));
|
|
1775
|
+
const r = runHook();
|
|
1776
|
+
expect(r.status).toBe(1);
|
|
1777
|
+
expect(r.stdout).toContain('the bound record is missing at');
|
|
1778
|
+
});
|
|
1779
|
+
// ── The body-line boundary is a HEADING, not a leading "#" ──
|
|
1780
|
+
//
|
|
1781
|
+
// `#2700 is the issue.` is prose about an issue number, not a heading: no
|
|
1782
|
+
// space follows the hashes. Reading it as a boundary made a real body look
|
|
1783
|
+
// empty — the exact sentence a totem design record is most likely to open a
|
|
1784
|
+
// section with.
|
|
1785
|
+
it.skipIf(!shellOk)('a body line starting with `#2700` is a BODY on the TEMPLATE arm', () => {
|
|
1786
|
+
const draft = SPEC_REQUIRED_SECTIONS.map((heading) => `${heading}\n\n#2700 is the issue.\n`).join('\n');
|
|
1787
|
+
writeRun('hashbody.json', specEvidenceArtifact({ output: { content: draft } }));
|
|
1788
|
+
const r = runHook();
|
|
1789
|
+
expect(r.status).toBe(0);
|
|
1790
|
+
expect(r.stdout).toContain('· shape TEMPLATE');
|
|
1791
|
+
});
|
|
1792
|
+
it.skipIf(!shellOk)('a body line starting with `#2700` is a BODY on the DOCUMENT arm', () => {
|
|
1793
|
+
const sha = writeRecord('.totem/specs/2700.md', '# Record\n\n#2700 is the issue.\n');
|
|
1794
|
+
writeRun('hashrec.json', recordArtifact('.totem/specs/2700.md', sha));
|
|
1795
|
+
const r = runHook();
|
|
1796
|
+
expect(r.status).toBe(0);
|
|
1797
|
+
expect(r.stdout).toContain('· record sha256 matches');
|
|
1798
|
+
});
|
|
1799
|
+
// ── The DOCUMENT shape tolerates two ordinary authoring bytes ──
|
|
1800
|
+
it.skipIf(!shellOk)('a DRAFT opening with a UTF-8 BOM still reads as a template', () => {
|
|
1801
|
+
// The BOM strip runs on the SUBJECT, before the split — so it covers the
|
|
1802
|
+
// TEMPLATE arm too, where an unstripped BOM would make the first required
|
|
1803
|
+
// heading fail its exact-line match and BLOCK a well-formed draft.
|
|
1804
|
+
const bom = String.fromCharCode(0xfeff);
|
|
1805
|
+
writeRun('bomdraft.json', specEvidenceArtifact({ output: { content: bom + TEMPLATE_DRAFT } }));
|
|
1806
|
+
const r = runHook();
|
|
1807
|
+
expect(r.status).toBe(0);
|
|
1808
|
+
expect(r.stdout).toContain('· shape TEMPLATE');
|
|
1809
|
+
});
|
|
1810
|
+
it.skipIf(!shellOk)('a record opening with a UTF-8 BOM still reads as a document', () => {
|
|
1811
|
+
const bom = String.fromCharCode(0xfeff);
|
|
1812
|
+
const sha = writeRecord('.totem/specs/2700.md', `${bom}# Record\n\nThe ruled contract.\n`);
|
|
1813
|
+
writeRun('bom.json', recordArtifact('.totem/specs/2700.md', sha));
|
|
1814
|
+
const r = runHook();
|
|
1815
|
+
expect(r.status).toBe(0);
|
|
1816
|
+
expect(r.stdout).toContain('· shape DOCUMENT');
|
|
1817
|
+
});
|
|
1818
|
+
it.skipIf(!shellOk)('a heading separated from its text by a TAB is a heading', () => {
|
|
1819
|
+
const sha = writeRecord('.totem/specs/2700.md', '#\tTabbed heading\n\nThe body.\n');
|
|
1820
|
+
writeRun('tab.json', recordArtifact('.totem/specs/2700.md', sha));
|
|
1821
|
+
const r = runHook();
|
|
1822
|
+
expect(r.status).toBe(0);
|
|
1823
|
+
expect(r.stdout).toContain('· shape DOCUMENT');
|
|
1824
|
+
});
|
|
1825
|
+
// ── The artifact is hand-editable: nothing read out of it is trusted text ──
|
|
1826
|
+
it.skipIf(!shellOk)('a newline in anchor.ref cannot forge a second [Totem] line', () => {
|
|
1827
|
+
const forged = `slug${String.fromCharCode(0x0a)}[Totem] spec evidence: forged`;
|
|
1828
|
+
writeRun('inject.json', specEvidenceArtifact({
|
|
1829
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: forged } },
|
|
1830
|
+
}));
|
|
1831
|
+
const r = runHook();
|
|
1832
|
+
expect(r.status).toBe(1);
|
|
1833
|
+
// Exactly one line of the hook's own output claims to be from Totem.
|
|
1834
|
+
expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
|
|
1835
|
+
// The control character is collapsed, not dropped — the ref stays legible.
|
|
1836
|
+
expect(r.stdout).toContain('slug?[Totem] spec evidence: forged');
|
|
1837
|
+
});
|
|
1838
|
+
it.skipIf(!shellOk)('a C1 control (U+0085 NEL) in anchor.ref is collapsed too', () => {
|
|
1839
|
+
// A predicate stopping at 0x7f let the whole C1 band through; NEL is a
|
|
1840
|
+
// LINE BREAK to some terminals and pagers, so it is a forged-line vector
|
|
1841
|
+
// of exactly the same kind as 0x0a.
|
|
1842
|
+
const forged = `slug${String.fromCharCode(0x85)}[Totem] spec evidence: forged`;
|
|
1843
|
+
writeRun('nel.json', specEvidenceArtifact({
|
|
1844
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: forged } },
|
|
1845
|
+
}));
|
|
1846
|
+
const r = runHook();
|
|
1847
|
+
expect(r.status).toBe(1);
|
|
1848
|
+
expect(r.stdout).toContain('slug?[Totem] spec evidence: forged');
|
|
1849
|
+
expect(r.stdout).not.toContain(String.fromCharCode(0x85));
|
|
1850
|
+
});
|
|
1851
|
+
it.skipIf(!shellOk)('a newline in createdAt cannot forge a second [Totem] line on the PASS path', () => {
|
|
1852
|
+
// `createdAt` is read raw off the artifact and ECHOED in the evidence
|
|
1853
|
+
// line: unsanitized, a hand-edited stamp forges a second `[Totem] spec
|
|
1854
|
+
// evidence:` line inside a passing commit — the quietest place to hide
|
|
1855
|
+
// one.
|
|
1856
|
+
const forged = `2026-09-02T00:00:00.000Z${String.fromCharCode(0x0a)}[Totem] spec evidence: FORGED`;
|
|
1857
|
+
writeRun('stamp.json', specEvidenceArtifact({ createdAt: forged }));
|
|
1858
|
+
const r = runHook();
|
|
1859
|
+
expect(r.status).toBe(0);
|
|
1860
|
+
expect(r.stdout.split('\n').filter((line) => line.startsWith('[Totem]'))).toHaveLength(1);
|
|
1861
|
+
expect(r.stdout).toContain('.000Z?[Totem] spec evidence: FORGED');
|
|
1862
|
+
});
|
|
1863
|
+
it.skipIf(!shellOk)('a record ref that is ABSOLUTE is refused as outside the worktree', () => {
|
|
1864
|
+
writeRun('abs.json', recordArtifact('/etc/passwd', 'd'.repeat(64)));
|
|
1865
|
+
const r = runHook();
|
|
1866
|
+
expect(r.status).toBe(1);
|
|
1867
|
+
expect(r.stdout).toContain('the bound record ref is outside the worktree: /etc/passwd');
|
|
1868
|
+
expectDistinctBlock(r.stdout);
|
|
1869
|
+
});
|
|
1870
|
+
it.skipIf(!shellOk)('a record ref whose first segment is `..` is refused as outside the worktree', () => {
|
|
1871
|
+
writeRun('dotdot.json', recordArtifact('../outside.md', 'd'.repeat(64)));
|
|
1872
|
+
const r = runHook();
|
|
1873
|
+
expect(r.status).toBe(1);
|
|
1874
|
+
expect(r.stdout).toContain('the bound record ref is outside the worktree: ../outside.md');
|
|
1875
|
+
expectDistinctBlock(r.stdout);
|
|
1876
|
+
});
|
|
1877
|
+
// ── Containment is decided by RESOLUTION, not by segment 0 ──
|
|
1878
|
+
//
|
|
1879
|
+
// A first-segment test reads `sub/../../OUTSIDE.md` and `./../OUTSIDE.md` as
|
|
1880
|
+
// contained, so the reader would have READ and PASSED a file outside the
|
|
1881
|
+
// worktree. The reader now resolves the ref against `process.cwd()` — the
|
|
1882
|
+
// worktree top git runs hooks from — and refuses anything that lands outside
|
|
1883
|
+
// it, which is also the honest rule for the legitimate mid-path `..`.
|
|
1884
|
+
/** A backslash, built rather than escaped — the mmnto-ai/totem#2692 authoring trap. */
|
|
1885
|
+
const BACKSLASH = String.fromCharCode(0x5c);
|
|
1886
|
+
it.skipIf(!shellOk).each([
|
|
1887
|
+
['sub/../../OUTSIDE.md', 'a `..` past segment 0'],
|
|
1888
|
+
['./../OUTSIDE.md', 'a `.` first segment hiding a `..`'],
|
|
1889
|
+
[
|
|
1890
|
+
`a${BACKSLASH}..${BACKSLASH}..${BACKSLASH}OUTSIDE.md`,
|
|
1891
|
+
'the same escape spelled with backslashes',
|
|
1892
|
+
],
|
|
1893
|
+
])('refuses %s as outside the worktree (%s)', (ref) => {
|
|
1894
|
+
writeRun('escape.json', recordArtifact(ref, 'd'.repeat(64)));
|
|
1895
|
+
const r = runHook();
|
|
1896
|
+
expect(r.status).toBe(1);
|
|
1897
|
+
expect(r.stdout).toContain(`the bound record ref is outside the worktree: ${ref}`);
|
|
1898
|
+
expectDistinctBlock(r.stdout);
|
|
1899
|
+
});
|
|
1900
|
+
it.skipIf(!shellOk)('a mid-path `..` that stays INSIDE the worktree still PASSES — containment, not a segment ban', () => {
|
|
1901
|
+
// `a/` must exist for POSIX path resolution to reach `a/../b.md`.
|
|
1902
|
+
fs.mkdirSync(path.join(tmpDir, 'a'), { recursive: true });
|
|
1903
|
+
const sha = writeRecord('b.md', '# Record\n\nThe ruled contract.\n');
|
|
1904
|
+
writeRun('inside.json', recordArtifact('a/../b.md', sha));
|
|
1905
|
+
const r = runHook();
|
|
1906
|
+
expect(r.status).toBe(0);
|
|
1907
|
+
expect(r.stdout).toContain('· record sha256 matches');
|
|
1908
|
+
});
|
|
1909
|
+
it.skipIf(!shellOk)('a record anchor with NO sha256 BLOCKS — a binding without bytes is not a binding', () => {
|
|
1910
|
+
writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
|
|
1911
|
+
writeRun('nosha.json', specEvidenceArtifact({
|
|
1912
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_RECORD, ref: '.totem/specs/2700.md' } },
|
|
1913
|
+
}));
|
|
1914
|
+
const r = runHook();
|
|
1915
|
+
expect(r.status).toBe(1);
|
|
1916
|
+
expect(r.stdout).toContain('the record anchor carries no sha256 — not evidence');
|
|
1917
|
+
expectDistinctBlock(r.stdout);
|
|
1918
|
+
});
|
|
1919
|
+
it.skipIf(!shellOk)('a record anchor whose sha256 is MALFORMED BLOCKS with its OWN reason, not the absent one', () => {
|
|
1920
|
+
// "No digest" and "a digest that is not a digest" are different repairs:
|
|
1921
|
+
// the first says re-bind, the second says the artifact was edited. One
|
|
1922
|
+
// shared message would send both to the wrong cure.
|
|
1923
|
+
writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
|
|
1924
|
+
writeRun('badsha.json', recordArtifact('.totem/specs/2700.md', 'not-a-digest'));
|
|
1925
|
+
const r = runHook();
|
|
1926
|
+
expect(r.status).toBe(1);
|
|
1927
|
+
expect(r.stdout).toContain('the record anchor sha256 is not a 64-hex digest (not-a-digest) — not evidence');
|
|
1928
|
+
expect(r.stdout).not.toContain('carries no sha256');
|
|
1929
|
+
expectDistinctBlock(r.stdout);
|
|
1930
|
+
});
|
|
1931
|
+
// ── Containment sees THROUGH a link (mmnto-ai/totem#2700) ──
|
|
1932
|
+
//
|
|
1933
|
+
// A symlink inside the worktree is LEXICALLY contained, so the resolve-based
|
|
1934
|
+
// test above reads it as legal — and the reader would open, hash and judge a
|
|
1935
|
+
// file outside the tree. The realpath pair is compared once the ref is known
|
|
1936
|
+
// to exist and BEFORE its bytes are read.
|
|
1937
|
+
it.skipIf(!shellOk || !symlinkable)('a record ref that is a SYMLINK to OUTSIDE the worktree BLOCKS, naming both spellings', () => {
|
|
1938
|
+
const target = path.join(outsideDir, 'record.md');
|
|
1939
|
+
fs.writeFileSync(target, '# Outside\n\nThe ruled contract.\n', 'utf-8');
|
|
1940
|
+
const sha = crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex');
|
|
1941
|
+
fs.symlinkSync(target, path.join(tmpDir, 'linked.md'));
|
|
1942
|
+
// A CORRECT digest, so the block cannot be the sha256 arm firing early.
|
|
1943
|
+
writeRun('symlink-out.json', recordArtifact('linked.md', sha));
|
|
1944
|
+
const r = runHook();
|
|
1945
|
+
expect(r.status).toBe(1);
|
|
1946
|
+
expect(r.stdout).toContain('the bound record resolves outside the worktree: linked.md -> ');
|
|
1947
|
+
expect(r.stdout).toContain(fs.realpathSync.native(target));
|
|
1948
|
+
expectDistinctBlock(r.stdout);
|
|
1949
|
+
});
|
|
1950
|
+
it.skipIf(!shellOk || !symlinkable)('a record ref that is a SYMLINK to an IN-worktree file PASSES — containment, not a link ban', () => {
|
|
1951
|
+
const sha = writeRecord('.totem/specs/2700.md', '# Record\n\nThe ruled contract.\n');
|
|
1952
|
+
fs.symlinkSync(path.join(tmpDir, '.totem', 'specs', '2700.md'), path.join(tmpDir, 'linked.md'));
|
|
1953
|
+
writeRun('symlink-in.json', recordArtifact('linked.md', sha));
|
|
1954
|
+
const r = runHook();
|
|
1955
|
+
expect(r.status).toBe(0);
|
|
1956
|
+
expect(r.stdout).toContain('· record sha256 matches');
|
|
1957
|
+
});
|
|
1958
|
+
// ── The cure names the cache (mmnto-ai/totem#2700 m11) ──
|
|
1959
|
+
it.skipIf(!shellOk)('the BLOCKED cure names --fresh — a cached response mints no artifact', () => {
|
|
1960
|
+
writeRun('ft.json', specEvidenceArtifact({
|
|
1961
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
|
|
1962
|
+
}));
|
|
1963
|
+
const r = runHook();
|
|
1964
|
+
expect(r.stdout).toContain('(add --fresh if the response is cached)');
|
|
1965
|
+
});
|
|
1966
|
+
// ── The other arms stay distinct ──
|
|
1967
|
+
it.skipIf(!shellOk)('exit 3 is NEVER reported as a reader failure', () => {
|
|
1968
|
+
writeRun('ft.json', specEvidenceArtifact({
|
|
1969
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
|
|
1970
|
+
}));
|
|
1971
|
+
const r = runHook();
|
|
1972
|
+
expect(r.status).toBe(1);
|
|
1973
|
+
expect(r.stdout).not.toContain('node exit status');
|
|
1974
|
+
});
|
|
1975
|
+
it.skipIf(!shellOk)('the carried-object substring control still BLOCKS with the no-artifact line', () => {
|
|
1976
|
+
// Only the TOP-LEVEL caller counts: a review artifact that carries a
|
|
1977
|
+
// `caller: spec` object below the top level is not a spec artifact at all,
|
|
1978
|
+
// so the gate reports "none found", not "not evidence".
|
|
1979
|
+
writeRun('r.json', {
|
|
1980
|
+
admission: { runMetadata: { caller: 'review' } },
|
|
1981
|
+
inputBundle: { runMetadata: { caller: 'spec' } },
|
|
1982
|
+
createdAt: new Date().toISOString(),
|
|
1983
|
+
});
|
|
1984
|
+
const r = runHook();
|
|
1985
|
+
expect(r.status).toBe(1);
|
|
1986
|
+
expect(r.stdout).toContain('no totem spec run artifact');
|
|
1987
|
+
});
|
|
1988
|
+
it.skipIf(!shellOk)('the retired hand-set marker still rescues nothing', () => {
|
|
1989
|
+
fs.mkdirSync(path.join(tmpDir, '.totem', 'cache'), { recursive: true });
|
|
1990
|
+
fs.writeFileSync(path.join(tmpDir, '.totem', 'cache', '.spec-completed'), '');
|
|
1991
|
+
writeRun('ft.json', specEvidenceArtifact({
|
|
1992
|
+
grounding: { anchor: { kind: GROUNDING_ANCHOR_FREE_TEXT, ref: 'slug' } },
|
|
1993
|
+
}));
|
|
1994
|
+
const r = runHook();
|
|
1995
|
+
expect(r.status).toBe(1);
|
|
1996
|
+
expect(r.stdout).not.toContain('spec evidence:');
|
|
1452
1997
|
});
|
|
1453
1998
|
});
|
|
1454
1999
|
// The mmnto-ai/totem#2692 keystone, EXECUTED: under a custom `totemDir` the
|
|
@@ -1485,10 +2030,7 @@ describe('buildPreCommitHook strict evidence under a CUSTOM totemDir — execute
|
|
|
1485
2030
|
fs.mkdirSync(dir, { recursive: true });
|
|
1486
2031
|
fs.writeFileSync(path.join(dir, name), JSON.stringify(artifact, null, 2));
|
|
1487
2032
|
}
|
|
1488
|
-
const specArtifact = () => (
|
|
1489
|
-
admission: { runMetadata: { caller: 'spec' } },
|
|
1490
|
-
createdAt: new Date().toISOString(),
|
|
1491
|
-
});
|
|
2033
|
+
const specArtifact = () => specEvidenceArtifact();
|
|
1492
2034
|
it.skipIf(!shellOk)('PASSES on evidence written under the configured totemDir', () => {
|
|
1493
2035
|
writeRunUnder(CUSTOM_TOTEM_DIR, 'a.json', specArtifact());
|
|
1494
2036
|
const r = runHook();
|
|
@@ -1565,9 +2107,16 @@ describe('buildPrePushHook with strict tier', () => {
|
|
|
1565
2107
|
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1566
2108
|
// Find the position of the doctor --strict invocation and the surrounding
|
|
1567
2109
|
// guard; assert the invocation lives INSIDE the agent-or-tier block.
|
|
1568
|
-
const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
|
|
1569
2110
|
const doctorIdx = hook.indexOf('$TOTEM_CMD doctor --strict');
|
|
1570
|
-
|
|
2111
|
+
// Anchored on the guard that ENCLOSES this invocation: since
|
|
2112
|
+
// mmnto-ai/totem#2698 the pre-push hook carries more than one agent/strict
|
|
2113
|
+
// guard (the review-leg floor arm has its own, and it is rendered first),
|
|
2114
|
+
// so a bare first-match lookup would measure the wrong block.
|
|
2115
|
+
const guardIdx = hook.lastIndexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]', doctorIdx);
|
|
2116
|
+
// Anchored to a LINE, not a substring: a bare `fi` also matches the one
|
|
2117
|
+
// inside "findings" in the shield block's comment, which sits between the
|
|
2118
|
+
// invocation and the real closing `fi` (CodeRabbit on PR mmnto-ai/totem#2745).
|
|
2119
|
+
const fiCloseIdx = hook.indexOf(`${String.fromCharCode(10)} fi`, doctorIdx);
|
|
1571
2120
|
expect(guardIdx).toBeGreaterThan(-1);
|
|
1572
2121
|
expect(doctorIdx).toBeGreaterThan(guardIdx);
|
|
1573
2122
|
expect(doctorIdx).toBeLessThan(fiCloseIdx);
|
|
@@ -1599,9 +2148,16 @@ describe('buildPrePushHook with standard tier', () => {
|
|
|
1599
2148
|
// standard, so the guard branch never enters.
|
|
1600
2149
|
it('emits doctor --strict gated by agent/strict guard (no unconditional fire in standard tier)', () => {
|
|
1601
2150
|
const hook = buildPrePushHook(RENDER);
|
|
1602
|
-
const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
|
|
1603
2151
|
const doctorIdx = hook.indexOf('$TOTEM_CMD doctor --strict');
|
|
1604
|
-
|
|
2152
|
+
// Anchored on the guard that ENCLOSES this invocation: since
|
|
2153
|
+
// mmnto-ai/totem#2698 the pre-push hook carries more than one agent/strict
|
|
2154
|
+
// guard (the review-leg floor arm has its own, and it is rendered first),
|
|
2155
|
+
// so a bare first-match lookup would measure the wrong block.
|
|
2156
|
+
const guardIdx = hook.lastIndexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]', doctorIdx);
|
|
2157
|
+
// Anchored to a LINE, not a substring: a bare `fi` also matches the one
|
|
2158
|
+
// inside "findings" in the shield block's comment, which sits between the
|
|
2159
|
+
// invocation and the real closing `fi` (CodeRabbit on PR mmnto-ai/totem#2745).
|
|
2160
|
+
const fiCloseIdx = hook.indexOf(`${String.fromCharCode(10)} fi`, doctorIdx);
|
|
1605
2161
|
expect(guardIdx).toBeGreaterThan(-1);
|
|
1606
2162
|
expect(doctorIdx).toBeGreaterThan(guardIdx);
|
|
1607
2163
|
expect(doctorIdx).toBeLessThan(fiCloseIdx);
|
|
@@ -1629,4 +2185,444 @@ describe('agent detection uses POSIX syntax', () => {
|
|
|
1629
2185
|
expect(hook).toMatch(/^#!\/bin\/sh\n/);
|
|
1630
2186
|
});
|
|
1631
2187
|
});
|
|
2188
|
+
// ─── The strict pre-push review-leg floor arm (mmnto-ai/totem#2698) ──────────
|
|
2189
|
+
describe('buildPrePushHook — the review-leg floor arm (mmnto-ai/totem#2698)', () => {
|
|
2190
|
+
const hook = buildPrePushHook(RENDER);
|
|
2191
|
+
it("probes the VERB's own help for an option only that verb has", () => {
|
|
2192
|
+
expect(hook).toContain(`if $TOTEM_CMD legs gate --help 2>/dev/null | grep -q -- '--advisory'; then`);
|
|
2193
|
+
// NOT the group's help for the word `gate` (mmnto-ai/totem#2698 fold 2): a
|
|
2194
|
+
// CLI predating `totem legs` answers any `legs …` with its curated
|
|
2195
|
+
// TOP-LEVEL help and exits 0, so that grep reads a moving surface and the
|
|
2196
|
+
// queued `merge-gate` verb (mmnto-ai/totem#2708) would satisfy it.
|
|
2197
|
+
expect(hook).not.toContain(`$TOTEM_CMD legs --help`);
|
|
2198
|
+
});
|
|
2199
|
+
it('invokes the bare gate on strict and the advisory form otherwise', () => {
|
|
2200
|
+
expect(hook).toContain('$TOTEM_CMD legs gate\n');
|
|
2201
|
+
expect(hook).toContain('$TOTEM_CMD legs gate --advisory');
|
|
2202
|
+
expect(hook).toContain('legs_status=$?');
|
|
2203
|
+
});
|
|
2204
|
+
it('blocks on exit 3 and on any other non-zero with DISTINCT lines', () => {
|
|
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, strict mode)"`);
|
|
2206
|
+
expect(hook).toContain('echo "[Totem] BLOCKED: the legs gate could not derive (totem legs gate exit status $legs_status) — fix the checkout and retry (strict mode)"');
|
|
2207
|
+
expect(hook).toContain('if [ "$legs_status" = "3" ]; then');
|
|
2208
|
+
expect(hook).toContain('elif [ "$legs_status" != "0" ]; then');
|
|
2209
|
+
});
|
|
2210
|
+
it('FAILS CLOSED on strict when the resolved CLI lacks the verb, compat-opens otherwise', () => {
|
|
2211
|
+
// The OPPOSITE of the `--gate` / `--scope-to-diff` precedent, by ruling
|
|
2212
|
+
// (mmnto-ai/totem#2698 OQ2): those flags degrade to a form that still runs;
|
|
2213
|
+
// 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`);
|
|
2215
|
+
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
|
+
});
|
|
2217
|
+
it('runs BEFORE the shield block and inside the $TOTEM_CMD guard', () => {
|
|
2218
|
+
const cmdGuardIdx = hook.indexOf('if [ -n "$TOTEM_CMD" ]; then');
|
|
2219
|
+
const legsIdx = hook.indexOf('$TOTEM_CMD legs gate --help');
|
|
2220
|
+
const shieldIdx = hook.indexOf('Running shield gate (strict mode)');
|
|
2221
|
+
expect(cmdGuardIdx).toBeGreaterThan(-1);
|
|
2222
|
+
expect(legsIdx).toBeGreaterThan(cmdGuardIdx);
|
|
2223
|
+
// Slotted first so a legs-owed push is not paid for with the slow review gate.
|
|
2224
|
+
expect(legsIdx).toBeLessThan(shieldIdx);
|
|
2225
|
+
});
|
|
2226
|
+
it('gates the blocking arms on the agent/strict guard', () => {
|
|
2227
|
+
const legsIdx = hook.indexOf('$TOTEM_CMD legs gate --help');
|
|
2228
|
+
const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]', legsIdx);
|
|
2229
|
+
const gateIdx = hook.indexOf('$TOTEM_CMD legs gate\n');
|
|
2230
|
+
expect(guardIdx).toBeGreaterThan(legsIdx);
|
|
2231
|
+
expect(gateIdx).toBeGreaterThan(guardIdx);
|
|
2232
|
+
});
|
|
2233
|
+
});
|
|
2234
|
+
// The arm, EXECUTED. String assertions alone are satisfiable without the
|
|
2235
|
+
// behavior, and the exit-code mapping IS the contract here. The stub `totem` is
|
|
2236
|
+
// the FIRST entry on an isolated PATH and records its argv, so every case
|
|
2237
|
+
// proves the hook actually reached it — an un-isolated PATH or a stub on the
|
|
2238
|
+
// wrong stream is the false-green shape this suite exists to exclude.
|
|
2239
|
+
describe('the review-leg floor arm executed under sh (mmnto-ai/totem#2698)', () => {
|
|
2240
|
+
const shellOk = spawnSync('sh', ['-c', 'exit 0'], { encoding: 'utf-8' }).status === 0;
|
|
2241
|
+
let tmpDir;
|
|
2242
|
+
let repoDir;
|
|
2243
|
+
let binDir;
|
|
2244
|
+
let logPath;
|
|
2245
|
+
beforeEach(() => {
|
|
2246
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-hook-'));
|
|
2247
|
+
repoDir = path.join(tmpDir, 'repo');
|
|
2248
|
+
binDir = path.join(tmpDir, 'stub-bin');
|
|
2249
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
2250
|
+
fs.mkdirSync(binDir);
|
|
2251
|
+
// Forward slashes: the stub is read by `sh`, and a Windows path's
|
|
2252
|
+
// backslashes would not survive the shell's quoting.
|
|
2253
|
+
logPath = path.join(tmpDir, 'totem-invocations.log').split(path.sep).join('/');
|
|
2254
|
+
});
|
|
2255
|
+
afterEach(() => {
|
|
2256
|
+
cleanTmpDir(tmpDir);
|
|
2257
|
+
});
|
|
2258
|
+
/**
|
|
2259
|
+
* A stub `totem` that records every invocation, answers the `legs --help`
|
|
2260
|
+
* probe with the given help text, and exits `gateExit` for `legs gate`. The
|
|
2261
|
+
* bare repo dir clears every earlier hook gate (no manifest, no compiled
|
|
2262
|
+
* rules, no lockfile, no package.json), so the legs arm and the shield block
|
|
2263
|
+
* are the only live ones.
|
|
2264
|
+
*/
|
|
2265
|
+
function writeStub(params) {
|
|
2266
|
+
const stub = [
|
|
2267
|
+
'#!/bin/sh',
|
|
2268
|
+
`printf '%s\\n' "$*" >> "${logPath}"`,
|
|
2269
|
+
// The help arm is tested FIRST and on THREE words: since
|
|
2270
|
+
// mmnto-ai/totem#2698 fold 2 the probe is `legs gate --help`, which also
|
|
2271
|
+
// matches the gate arm's first two.
|
|
2272
|
+
'if [ "$1" = "legs" ] && [ "$2" = "gate" ] && [ "$3" = "--help" ]; then',
|
|
2273
|
+
" cat <<'TOTEM_STUB_HELP'",
|
|
2274
|
+
params.helpText,
|
|
2275
|
+
'TOTEM_STUB_HELP',
|
|
2276
|
+
` exit ${params.helpExit ?? 0}`,
|
|
2277
|
+
'fi',
|
|
2278
|
+
'if [ "$1" = "legs" ] && [ "$2" = "gate" ]; then',
|
|
2279
|
+
' echo "[Totem] legs: stub gate line"',
|
|
2280
|
+
` exit ${params.gateExit ?? 0}`,
|
|
2281
|
+
'fi',
|
|
2282
|
+
'exit 0',
|
|
2283
|
+
'',
|
|
2284
|
+
].join('\n');
|
|
2285
|
+
fs.writeFileSync(path.join(binDir, 'totem'), stub, { mode: 0o755 });
|
|
2286
|
+
}
|
|
2287
|
+
/**
|
|
2288
|
+
* The verb's OWN help, and the top-level help an older CLI answers with.
|
|
2289
|
+
*
|
|
2290
|
+
* The unsupported fixture is a HOSTILE VARIANT of the 1.122.0 shape, not
|
|
2291
|
+
* that shape verbatim: the measured seven-command top-level help contains no
|
|
2292
|
+
* `gate` at all, so today's probe does not false-pass on it. This fixture
|
|
2293
|
+
* INJECTS a `merge-gate` entry (mmnto-ai/totem#2708 is queued for that list),
|
|
2294
|
+
* so the OLD group-level grep would false-pass and the verb-specific probe
|
|
2295
|
+
* must not. The defect it guards is latent, and this is what keeps it so.
|
|
2296
|
+
*/
|
|
2297
|
+
const HELP_WITH_GATE = 'Usage: totem legs gate [options]\n\nOptions:\n --advisory Print the same lines for every state\n -h, --help display help for command';
|
|
2298
|
+
const HELP_WITHOUT_GATE = 'Totem: local-first toolkit\n\nUsage: totem [command]\n\nCommands:\n init Initialize Totem\n lint Run compiled rules\n merge-gate Gate a merge';
|
|
2299
|
+
function runHook(options) {
|
|
2300
|
+
fs.writeFileSync(path.join(repoDir, 'pre-push'), buildPrePushHook({ ...RENDER, tier: options.tier }));
|
|
2301
|
+
// Every agent-detection variable is cleared explicitly: this suite runs
|
|
2302
|
+
// inside an agent session, and an inherited CLAUDE_CODE_AGENT would make
|
|
2303
|
+
// the standard-tier cases silently exercise the strict arm.
|
|
2304
|
+
const env = {
|
|
2305
|
+
...process.env,
|
|
2306
|
+
PATH: [binDir, process.env['PATH'] ?? ''].join(path.delimiter),
|
|
2307
|
+
};
|
|
2308
|
+
delete env['CLAUDE_CODE_AGENT'];
|
|
2309
|
+
delete env['CLAUDE_VERSION'];
|
|
2310
|
+
delete env['CURSOR_TRACE_ID'];
|
|
2311
|
+
if (options.agent)
|
|
2312
|
+
env['CLAUDE_CODE_AGENT'] = '1';
|
|
2313
|
+
const result = spawnSync('sh', ['./pre-push'], { cwd: repoDir, env, encoding: 'utf-8' });
|
|
2314
|
+
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
2315
|
+
}
|
|
2316
|
+
/** Every recorded stub invocation, in order. Empty when it was never reached. */
|
|
2317
|
+
const invocations = () => {
|
|
2318
|
+
const native = logPath.split('/').join(path.sep);
|
|
2319
|
+
return fs.existsSync(native) ? fs.readFileSync(native, 'utf-8').trim().split('\n') : [];
|
|
2320
|
+
};
|
|
2321
|
+
it.skipIf(!shellOk)('probe UNSUPPORTED + strict: blocks with the upgrade line', () => {
|
|
2322
|
+
writeStub({ helpText: HELP_WITHOUT_GATE });
|
|
2323
|
+
const r = runHook({ tier: 'strict', agent: false });
|
|
2324
|
+
expect(invocations()).toContain('legs gate --help');
|
|
2325
|
+
expect(r.status).toBe(1);
|
|
2326
|
+
expect(r.stderr).toContain("this hook expects 'totem legs gate'");
|
|
2327
|
+
expect(r.stderr).toContain('npm i -g @mmnto/cli@latest');
|
|
2328
|
+
// Fail-closed means the gate was never invoked, not that it passed.
|
|
2329
|
+
expect(invocations()).not.toContain('legs gate');
|
|
2330
|
+
});
|
|
2331
|
+
it.skipIf(!shellOk)('probe UNSUPPORTED + standard: passes with the compat line on stderr', () => {
|
|
2332
|
+
writeStub({ helpText: HELP_WITHOUT_GATE });
|
|
2333
|
+
const r = runHook({ tier: 'standard', agent: false });
|
|
2334
|
+
expect(invocations()).toContain('legs gate --help');
|
|
2335
|
+
expect(r.status).toBe(0);
|
|
2336
|
+
expect(r.stderr).toContain("Hook running without the legs gate (CLI predates 'totem legs')");
|
|
2337
|
+
});
|
|
2338
|
+
it.skipIf(!shellOk)('gate exit 3 + strict: blocks with the legs-owed line and the cure', () => {
|
|
2339
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 3 });
|
|
2340
|
+
const r = runHook({ tier: 'strict', agent: false });
|
|
2341
|
+
expect(invocations()).toContain('legs gate');
|
|
2342
|
+
expect(r.status).toBe(1);
|
|
2343
|
+
expect(r.stdout).toContain('[Totem] BLOCKED: this push is legs-owed');
|
|
2344
|
+
expect(r.stdout).toContain('totem legs deposit --sha HEAD --from <findings.json>');
|
|
2345
|
+
// The gate's own line is not swallowed.
|
|
2346
|
+
expect(r.stdout).toContain('[Totem] legs: stub gate line');
|
|
2347
|
+
});
|
|
2348
|
+
it.skipIf(!shellOk)('gate exit 2 + strict: blocks with the DISTINCT not-derived line', () => {
|
|
2349
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 2 });
|
|
2350
|
+
const r = runHook({ tier: 'strict', agent: false });
|
|
2351
|
+
expect(invocations()).toContain('legs gate');
|
|
2352
|
+
expect(r.status).toBe(1);
|
|
2353
|
+
expect(r.stdout).toContain('the legs gate could not derive (totem legs gate exit status 2)');
|
|
2354
|
+
expect(r.stdout).not.toContain('this push is legs-owed');
|
|
2355
|
+
});
|
|
2356
|
+
it.skipIf(!shellOk)('gate exit 0 + strict: the hook proceeds PAST the arm', () => {
|
|
2357
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 0 });
|
|
2358
|
+
const r = runHook({ tier: 'strict', agent: false });
|
|
2359
|
+
expect(invocations()).toContain('legs gate');
|
|
2360
|
+
// The shield block sits after the arm — reaching it proves the arm passed.
|
|
2361
|
+
expect(invocations()).toContain('doctor --strict');
|
|
2362
|
+
expect(r.status).toBe(0);
|
|
2363
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
2364
|
+
});
|
|
2365
|
+
it.skipIf(!shellOk)('an AGENT on the standard tier still takes the strict arm', () => {
|
|
2366
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 3 });
|
|
2367
|
+
const r = runHook({ tier: 'standard', agent: true });
|
|
2368
|
+
expect(invocations()).toContain('legs gate');
|
|
2369
|
+
expect(invocations()).not.toContain('legs gate --advisory');
|
|
2370
|
+
expect(r.status).toBe(1);
|
|
2371
|
+
});
|
|
2372
|
+
it.skipIf(!shellOk)('standard tier, no agent: --advisory is passed and exit 3 does NOT block', () => {
|
|
2373
|
+
writeStub({ helpText: HELP_WITH_GATE, gateExit: 3 });
|
|
2374
|
+
const r = runHook({ tier: 'standard', agent: false });
|
|
2375
|
+
// Read from the recorded argv, not from the hook text: the flag has to
|
|
2376
|
+
// have reached the CLI, not merely appear in the template.
|
|
2377
|
+
expect(invocations()).toContain('legs gate --advisory');
|
|
2378
|
+
expect(invocations()).not.toContain('legs gate');
|
|
2379
|
+
expect(r.status).toBe(0);
|
|
2380
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
2381
|
+
});
|
|
2382
|
+
});
|
|
2383
|
+
// The arm COMPOSED with the real gate (mmnto-ai/totem#2698 fold 2, MATERIAL).
|
|
2384
|
+
//
|
|
2385
|
+
// The stub suite above proves the hook's exit MAPPING — it scripts the gate's
|
|
2386
|
+
// status, so the gate's own derivation is never exercised through the hook, and
|
|
2387
|
+
// a gate that stopped returning 3 for an owed-and-unanswered push would leave
|
|
2388
|
+
// every one of those tests green. This suite closes that: the shim on the
|
|
2389
|
+
// isolated PATH DELEGATES `legs` to the real built CLI, so each case runs the
|
|
2390
|
+
// whole composition — predicate, store, ancestry, exit code, hook arm, hook
|
|
2391
|
+
// line. Everything after the arm (the shield block's `doctor --strict` and
|
|
2392
|
+
// `review --gate`) is a different gate with its own coverage and stays stubbed,
|
|
2393
|
+
// so this suite's verdict never depends on state it does not control.
|
|
2394
|
+
//
|
|
2395
|
+
// COVERAGE DECLARATION (Q5). It needs `packages/cli/dist/index.js`, which
|
|
2396
|
+
// exists after this repo's CI builds before it tests (`pnpm -r build` precedes
|
|
2397
|
+
// the test run); when it does not, every case SKIPS rather than passing
|
|
2398
|
+
// vacuously, and the skip is visible in the reporter. It runs wherever `sh`
|
|
2399
|
+
// resolves: win32 through git-bash, plus the ubuntu and macos CI legs. The stub
|
|
2400
|
+
// suite above is retained and carries the mapping, including the states this
|
|
2401
|
+
// suite cannot reach through a real CLI (a build that predates the verb).
|
|
2402
|
+
//
|
|
2403
|
+
// One case inside it is POSIX-only, and says so at its own site: a quote- and
|
|
2404
|
+
// backslash-bearing owed path cannot exist on NTFS. Every other case, the
|
|
2405
|
+
// non-ASCII one included, runs on win32 too.
|
|
2406
|
+
describe('the review-leg floor arm COMPOSED with the real gate (mmnto-ai/totem#2698)', () => {
|
|
2407
|
+
/** Built, never authored as an escape (the banked decode trap). */
|
|
2408
|
+
const NL = String.fromCharCode(10);
|
|
2409
|
+
/**
|
|
2410
|
+
* A NON-ASCII owed path (mmnto-ai/totem#2698 fold 4). git C-quotes this name
|
|
2411
|
+
* on BOTH surfaces the gate reads, in two different ways, so an un-normalized
|
|
2412
|
+
* pair reports a covering leg as covering nothing. Driven here through the
|
|
2413
|
+
* REAL CLI, where the argv and the quoting actually happen.
|
|
2414
|
+
*/
|
|
2415
|
+
const ACCENTED = `docs/caf${String.fromCharCode(0xe9)}.md`;
|
|
2416
|
+
/** The two-character sequence `printf` needs in the shim, likewise built. */
|
|
2417
|
+
const BACKSLASH_N = String.fromCharCode(92) + 'n';
|
|
2418
|
+
const DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../dist/index.js');
|
|
2419
|
+
const shellOk = spawnSync('sh', ['-c', 'exit 0'], { encoding: 'utf-8' }).status === 0;
|
|
2420
|
+
const composedOk = shellOk && fs.existsSync(DIST);
|
|
2421
|
+
/**
|
|
2422
|
+
* The REAL working directory, restored after each case: `cross-spawn` (which
|
|
2423
|
+
* the CLI's `safeExec` goes through) chdirs into a child's cwd and restores
|
|
2424
|
+
* with `process.cwd()`, which on Windows would otherwise leave this process
|
|
2425
|
+
* holding the temp repo open and fail teardown.
|
|
2426
|
+
*/
|
|
2427
|
+
const realCwd = process.cwd();
|
|
2428
|
+
let tmpDir;
|
|
2429
|
+
let repoDir;
|
|
2430
|
+
let binDir;
|
|
2431
|
+
let logPath;
|
|
2432
|
+
let baseSha;
|
|
2433
|
+
let ancestorSha;
|
|
2434
|
+
let headSha;
|
|
2435
|
+
function git(...args) {
|
|
2436
|
+
return execFileSync('git', args, { cwd: repoDir, encoding: 'utf-8' });
|
|
2437
|
+
}
|
|
2438
|
+
beforeEach(() => {
|
|
2439
|
+
if (!composedOk)
|
|
2440
|
+
return;
|
|
2441
|
+
tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-legs-composed-')));
|
|
2442
|
+
repoDir = path.join(tmpDir, 'repo');
|
|
2443
|
+
binDir = path.join(tmpDir, 'stub-bin');
|
|
2444
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
2445
|
+
fs.mkdirSync(binDir);
|
|
2446
|
+
logPath = path.join(tmpDir, 'totem-invocations.log').split(path.sep).join('/');
|
|
2447
|
+
git('init', '-q', '-b', 'main');
|
|
2448
|
+
git('config', 'user.email', 'leg@example.test');
|
|
2449
|
+
git('config', 'user.name', 'Leg');
|
|
2450
|
+
// The minimal config the schema accepts, declaring ONE glob so the
|
|
2451
|
+
// owed / not-owed axis of this suite is unambiguous.
|
|
2452
|
+
fs.writeFileSync(path.join(repoDir, 'totem.config.ts'), [
|
|
2453
|
+
'export default {',
|
|
2454
|
+
" targets: [{ glob: 'docs/**/*.md', type: 'spec', strategy: 'markdown-heading' }],",
|
|
2455
|
+
" hooks: { legsOwed: { globs: ['docs/**'] } },",
|
|
2456
|
+
'};',
|
|
2457
|
+
'',
|
|
2458
|
+
].join(NL));
|
|
2459
|
+
fs.writeFileSync(path.join(repoDir, 'src.ts'), 'export const a = 1;' + NL);
|
|
2460
|
+
git('add', '.');
|
|
2461
|
+
git('commit', '-q', '-m', 'base');
|
|
2462
|
+
baseSha = git('rev-parse', 'HEAD').trim();
|
|
2463
|
+
// The branch under test: it touches the declared glob, so it is legs-owed.
|
|
2464
|
+
git('checkout', '-q', '-b', 'feat/owed');
|
|
2465
|
+
fs.mkdirSync(path.join(repoDir, 'docs'), { recursive: true });
|
|
2466
|
+
fs.writeFileSync(path.join(repoDir, 'docs', 'note.md'), '# a judgment-dense page' + NL);
|
|
2467
|
+
fs.writeFileSync(path.join(repoDir, ...ACCENTED.split('/')), '# an accented page' + NL);
|
|
2468
|
+
git('add', '-A', 'docs');
|
|
2469
|
+
git('commit', '-q', '-m', 'a docs change');
|
|
2470
|
+
// C1 — the head a leg could read, and an ANCESTOR of the head being pushed.
|
|
2471
|
+
ancestorSha = git('rev-parse', 'HEAD').trim();
|
|
2472
|
+
// C2 — one commit further, touching nothing owed, so the owed set is still
|
|
2473
|
+
// C1's two docs pages and a deposit at C1 covers all of them. Without it
|
|
2474
|
+
// that deposit would be EXACT, credited by construction, and the reach
|
|
2475
|
+
// probe this suite exists to exercise would never run.
|
|
2476
|
+
fs.writeFileSync(path.join(repoDir, 'src.ts'), 'export const a = 2;' + NL);
|
|
2477
|
+
git('add', 'src.ts');
|
|
2478
|
+
git('commit', '-q', '-m', 'a code-only change');
|
|
2479
|
+
headSha = git('rev-parse', 'HEAD').trim();
|
|
2480
|
+
// The shim IS the real CLI for `legs`, recorded either way. First on PATH,
|
|
2481
|
+
// so the hook's resolve block finds it as a plain `totem` (the temp repo has
|
|
2482
|
+
// no package.json, no node_modules and no workspace file, so no earlier
|
|
2483
|
+
// resolution tier can win).
|
|
2484
|
+
fs.writeFileSync(path.join(binDir, 'totem'), [
|
|
2485
|
+
'#!/bin/sh',
|
|
2486
|
+
`printf '%s${BACKSLASH_N}' "$*" >> "${logPath}"`,
|
|
2487
|
+
'if [ "$1" = "legs" ]; then',
|
|
2488
|
+
` exec node "${DIST.split(path.sep).join('/')}" "$@"`,
|
|
2489
|
+
'fi',
|
|
2490
|
+
'exit 0',
|
|
2491
|
+
'',
|
|
2492
|
+
].join(NL), { mode: 0o755 });
|
|
2493
|
+
});
|
|
2494
|
+
afterEach(() => {
|
|
2495
|
+
if (!composedOk)
|
|
2496
|
+
return;
|
|
2497
|
+
process.chdir(realCwd);
|
|
2498
|
+
cleanTmpDir(tmpDir);
|
|
2499
|
+
});
|
|
2500
|
+
function runHook() {
|
|
2501
|
+
fs.writeFileSync(path.join(repoDir, 'pre-push'), buildPrePushHook({ ...RENDER, tier: 'strict' }));
|
|
2502
|
+
const env = {
|
|
2503
|
+
...process.env,
|
|
2504
|
+
PATH: [binDir, process.env['PATH'] ?? ''].join(path.delimiter),
|
|
2505
|
+
};
|
|
2506
|
+
delete env['CLAUDE_CODE_AGENT'];
|
|
2507
|
+
delete env['CLAUDE_VERSION'];
|
|
2508
|
+
delete env['CURSOR_TRACE_ID'];
|
|
2509
|
+
const result = spawnSync('sh', ['./pre-push'], { cwd: repoDir, env, encoding: 'utf-8' });
|
|
2510
|
+
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
2511
|
+
}
|
|
2512
|
+
const invocations = () => {
|
|
2513
|
+
const native = logPath.split('/').join(path.sep);
|
|
2514
|
+
return fs.existsSync(native) ? fs.readFileSync(native, 'utf-8').trim().split(NL) : [];
|
|
2515
|
+
};
|
|
2516
|
+
/** Deposit through the REAL writer, at `sha`. */
|
|
2517
|
+
function deposit(sha) {
|
|
2518
|
+
fs.writeFileSync(path.join(repoDir, 'findings.json'), JSON.stringify({
|
|
2519
|
+
readAt: new Date().toISOString(),
|
|
2520
|
+
findings: [
|
|
2521
|
+
{
|
|
2522
|
+
id: 'f1',
|
|
2523
|
+
severity: 'MATERIAL',
|
|
2524
|
+
file: 'docs/note.md',
|
|
2525
|
+
line: 1,
|
|
2526
|
+
claim: 'the page overstates the floor',
|
|
2527
|
+
counterexample: 'the gate never reads severities',
|
|
2528
|
+
},
|
|
2529
|
+
],
|
|
2530
|
+
folded: ['f1'],
|
|
2531
|
+
verdict: 'one material finding, folded',
|
|
2532
|
+
}));
|
|
2533
|
+
const result = spawnSync('node', [DIST, 'legs', 'deposit', '--sha', sha, '--from', 'findings.json'], { cwd: repoDir, encoding: 'utf-8' });
|
|
2534
|
+
// Anchored, with the writer's own stderr as the failure message: the
|
|
2535
|
+
// previous `toContain('0:')` also passed on a status of 10 or 130.
|
|
2536
|
+
expect(result.status, result.stderr ?? '').toBe(0);
|
|
2537
|
+
}
|
|
2538
|
+
it.skipIf(!composedOk)('OWED with no deposit: the hook blocks, and BOTH lines are present', () => {
|
|
2539
|
+
const r = runHook();
|
|
2540
|
+
expect(invocations()).toContain('legs gate');
|
|
2541
|
+
expect(r.status).toBe(1);
|
|
2542
|
+
// The GATE's own line, derived end to end from the real predicate + store.
|
|
2543
|
+
// Both owed paths are named, the accented one spelled as it is on disk.
|
|
2544
|
+
expect(r.stdout).toContain(`[Totem] BLOCKED: this push is legs-owed (docs/** → ${ACCENTED}, docs/** → docs/note.md)`);
|
|
2545
|
+
expect(r.stdout).toContain(`for head ${headSha.slice(0, 8)}`);
|
|
2546
|
+
// And the HOOK's own cure line, which only the strict arm emits.
|
|
2547
|
+
expect(r.stdout).toContain("run the leg, then 'totem legs deposit --sha HEAD --from <findings.json>'");
|
|
2548
|
+
});
|
|
2549
|
+
it.skipIf(!composedOk)('OWED with an ANCESTOR deposit that covers the owed set: the hook passes', () => {
|
|
2550
|
+
deposit(ancestorSha);
|
|
2551
|
+
const r = runHook();
|
|
2552
|
+
expect(invocations()).toContain('legs gate');
|
|
2553
|
+
expect(r.stdout).toContain('[Totem] legs evidence: .totem/artifacts/legs/');
|
|
2554
|
+
// ANCESTOR, so the reach probe actually RAN: this is the only composed
|
|
2555
|
+
// state that drives `git diff --name-only -z base...<deposit>` end to
|
|
2556
|
+
// end. Depositing at the head took core's exact branch, which credits
|
|
2557
|
+
// coverage by construction and never probes (mmnto-ai/totem#2698 fold 5).
|
|
2558
|
+
expect(r.stdout).toContain(`· head ${headSha.slice(0, 8)} · nearest ancestor, +1 commits since the leg read ·`);
|
|
2559
|
+
// 2/2 with an accented owed path among them: the measured result of
|
|
2560
|
+
// reading both sides raw.
|
|
2561
|
+
expect(r.stdout).toContain('· covers 2/2 owed paths ·');
|
|
2562
|
+
expect(r.stdout).toContain('blocking=0 material=1 folded=1');
|
|
2563
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
2564
|
+
expect(r.status).toBe(0);
|
|
2565
|
+
});
|
|
2566
|
+
it.skipIf(!composedOk)('OWED with a deposit on a SIBLING branch: the hook blocks and the gate calls it stale', () => {
|
|
2567
|
+
// A commit that is not an ancestor of HEAD: a second branch off the base.
|
|
2568
|
+
git('checkout', '-q', '-b', 'feat/sibling', baseSha);
|
|
2569
|
+
fs.writeFileSync(path.join(repoDir, 'other.ts'), 'export const b = 2;' + NL);
|
|
2570
|
+
git('add', 'other.ts');
|
|
2571
|
+
git('commit', '-q', '-m', 'a sibling commit');
|
|
2572
|
+
const siblingSha = git('rev-parse', 'HEAD').trim();
|
|
2573
|
+
git('checkout', '-q', 'feat/owed');
|
|
2574
|
+
deposit(siblingSha);
|
|
2575
|
+
const r = runHook();
|
|
2576
|
+
expect(invocations()).toContain('legs gate');
|
|
2577
|
+
expect(r.status).toBe(1);
|
|
2578
|
+
expect(r.stdout).toContain(`[Totem] legs: stale deposit ${siblingSha.slice(0, 8)}: not an ancestor of head`);
|
|
2579
|
+
expect(r.stdout).toContain('[Totem] BLOCKED: this push is legs-owed');
|
|
2580
|
+
});
|
|
2581
|
+
it.skipIf(!composedOk)('OWED with a MERGE-BASE deposit: ancestor, but it covers none of the owed paths', () => {
|
|
2582
|
+
// The fold-3 exhibit, end to end: a deposit at the base is a real
|
|
2583
|
+
// ancestor one commit behind, so ancestry alone passed it — while the leg
|
|
2584
|
+
// that wrote it saw none of the diff this push proposes.
|
|
2585
|
+
deposit(baseSha);
|
|
2586
|
+
const r = runHook();
|
|
2587
|
+
expect(invocations()).toContain('legs gate');
|
|
2588
|
+
expect(r.status).toBe(1);
|
|
2589
|
+
expect(r.stdout).toContain(`[Totem] legs: stale deposit ${baseSha.slice(0, 8)}: covers none of the owed paths (the deposit predates every owed change)`);
|
|
2590
|
+
expect(r.stdout).toContain('[Totem] BLOCKED: this push is legs-owed');
|
|
2591
|
+
// And it is NOT reported as an ancestry failure — the reason is the cure.
|
|
2592
|
+
expect(r.stdout).not.toContain('not an ancestor of head');
|
|
2593
|
+
});
|
|
2594
|
+
// POSIX-only, and only for these two names: NTFS refuses a double quote and a
|
|
2595
|
+
// backslash in a filename, while Linux and macOS — where the strict-tier
|
|
2596
|
+
// population runs — allow both, and git C-quotes both in the `diff --git`
|
|
2597
|
+
// headers the owed set used to be parsed from. This drives them through the
|
|
2598
|
+
// REAL CLI and the generated hook (mmnto-ai/totem#2698 fold 5).
|
|
2599
|
+
it.skipIf(!composedOk || process.platform === 'win32')('OWED on quote- and backslash-bearing paths: the hook names them as they are on disk', () => {
|
|
2600
|
+
const quoted = `docs/a${String.fromCharCode(34)}quoted${String.fromCharCode(34)}.md`;
|
|
2601
|
+
const backslashed = `docs/back${String.fromCharCode(92)}slash.md`;
|
|
2602
|
+
fs.writeFileSync(path.join(repoDir, ...quoted.split('/')), '# quoted' + NL);
|
|
2603
|
+
fs.writeFileSync(path.join(repoDir, ...backslashed.split('/')), '# backslashed' + NL);
|
|
2604
|
+
git('add', '-A', 'docs');
|
|
2605
|
+
git('commit', '-q', '-m', 'the hostile pages');
|
|
2606
|
+
const r = runHook();
|
|
2607
|
+
expect(invocations()).toContain('legs gate');
|
|
2608
|
+
expect(r.status).toBe(1);
|
|
2609
|
+
// Raw in the basis. Escaped names here would be the fold-4 defect, and
|
|
2610
|
+
// the decoder that fold shipped could not have reached either of these.
|
|
2611
|
+
expect(r.stdout).toContain(quoted);
|
|
2612
|
+
expect(r.stdout).toContain(backslashed);
|
|
2613
|
+
expect(r.stdout).not.toContain(String.fromCharCode(92) + '"');
|
|
2614
|
+
});
|
|
2615
|
+
it.skipIf(!composedOk)('NOT OWED: the hook passes and the gate says what it judged', () => {
|
|
2616
|
+
// A branch whose only change misses the one declared glob.
|
|
2617
|
+
git('checkout', '-q', '-b', 'feat/not-owed', baseSha);
|
|
2618
|
+
fs.writeFileSync(path.join(repoDir, 'src.ts'), 'export const a = 2;' + NL);
|
|
2619
|
+
git('add', 'src.ts');
|
|
2620
|
+
git('commit', '-q', '-m', 'a code-only change');
|
|
2621
|
+
const r = runHook();
|
|
2622
|
+
expect(invocations()).toContain('legs gate');
|
|
2623
|
+
expect(r.status).toBe(0);
|
|
2624
|
+
expect(r.stdout).toContain('[Totem] legs: not owed — no changed path matched hooks.legsOwed.globs (1 globs; head');
|
|
2625
|
+
expect(r.stdout).not.toContain('BLOCKED');
|
|
2626
|
+
});
|
|
2627
|
+
});
|
|
1632
2628
|
//# sourceMappingURL=install-hooks.test.js.map
|