@mmnto/cli 1.122.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.
Files changed (43) hide show
  1. package/dist/commands/config-drift.test.js +73 -1
  2. package/dist/commands/config-drift.test.js.map +1 -1
  3. package/dist/commands/init-templates.d.ts +6 -6
  4. package/dist/commands/init-templates.d.ts.map +1 -1
  5. package/dist/commands/init-templates.js +8 -7
  6. package/dist/commands/init-templates.js.map +1 -1
  7. package/dist/commands/init.test.js +18 -5
  8. package/dist/commands/init.test.js.map +1 -1
  9. package/dist/commands/install-hooks.d.ts.map +1 -1
  10. package/dist/commands/install-hooks.js +58 -0
  11. package/dist/commands/install-hooks.js.map +1 -1
  12. package/dist/commands/install-hooks.test.js +460 -5
  13. package/dist/commands/install-hooks.test.js.map +1 -1
  14. package/dist/commands/legs.d.ts +259 -0
  15. package/dist/commands/legs.d.ts.map +1 -0
  16. package/dist/commands/legs.js +623 -0
  17. package/dist/commands/legs.js.map +1 -0
  18. package/dist/commands/legs.test.d.ts +20 -0
  19. package/dist/commands/legs.test.d.ts.map +1 -0
  20. package/dist/commands/legs.test.js +1004 -0
  21. package/dist/commands/legs.test.js.map +1 -0
  22. package/dist/commands/pre-push-gate-matrix.test.js +14 -0
  23. package/dist/commands/pre-push-gate-matrix.test.js.map +1 -1
  24. package/dist/commands/review-fan.d.ts +40 -1
  25. package/dist/commands/review-fan.d.ts.map +1 -1
  26. package/dist/commands/review-fan.js +108 -3
  27. package/dist/commands/review-fan.js.map +1 -1
  28. package/dist/commands/review-fan.test.js +444 -6
  29. package/dist/commands/review-fan.test.js.map +1 -1
  30. package/dist/commands/shield-covariate.test.js +19 -2
  31. package/dist/commands/shield-covariate.test.js.map +1 -1
  32. package/dist/commands/shield-nonreview.test.js +140 -1
  33. package/dist/commands/shield-nonreview.test.js.map +1 -1
  34. package/dist/commands/shield.d.ts.map +1 -1
  35. package/dist/commands/shield.js +53 -1
  36. package/dist/commands/shield.js.map +1 -1
  37. package/dist/git.d.ts +19 -0
  38. package/dist/git.d.ts.map +1 -1
  39. package/dist/git.js +13 -4
  40. package/dist/git.js.map +1 -1
  41. package/dist/index.js +40 -0
  42. package/dist/index.js.map +1 -1
  43. package/package.json +2 -2
@@ -1,13 +1,14 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import { createHash } 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';
5
6
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
- import { computeLineageKey, computeVerdictArtifactContentHash, deriveCacheEligible, deriveSettled, findLatestVerdictForLineage, listVerdictArtifacts, readLedgerEvents, renderCovariateLine, TotemConfigError, VERDICT_ARTIFACT_SCHEMA_VERSION, } from '@mmnto/totem';
7
+ import { computeLineageKey, computeVerdictArtifactContentHash, deriveCacheEligible, deriveSettled, findLatestVerdictForLineage, LEG_DEPOSIT_SCHEMA_VERSION, listVerdictArtifacts, readLedgerEvents, renderCovariateLine, saveLegDeposit, TotemConfigError, VERDICT_ARTIFACT_SCHEMA_VERSION, } from '@mmnto/totem';
7
8
  import { EMPTY_SHARED } from '../exemptions/exemption-schema.js';
8
9
  import { OrchestratorInvokeError } from '../orchestrators/orchestrator.js';
9
10
  import { cleanTmpDir } from '../test-utils.js';
10
- import { assembleVerdict, assertFanFlagsSupported, buildDiffScope, classifyRejectedLane, printCovariateLine, resolveLineage, runLane, runReviewFan, validateReviewLanes, } from './review-fan.js';
11
+ import { assembleVerdict, assertFanFlagsSupported, buildDiffScope, classifyRejectedLane, printCovariateLine, resolveLegFieldForHead, resolveLineage, runLane, runReviewFan, validateReviewLanes, } from './review-fan.js';
11
12
  import { MAX_DIFF_CHARS } from './shield-templates.js';
12
13
  // ─── Test fixtures ────────────────────────────────────────────────────────────
13
14
  const hex = (seed) => createHash('sha256').update(seed).digest('hex');
@@ -1418,13 +1419,16 @@ describe('printCovariateLine (rev-5 item 4)', () => {
1418
1419
  diffMeta: { source: 'branch-vs-base', base: 'main' },
1419
1420
  totemDirAbs: tmpDir,
1420
1421
  cwd: tmpDir,
1422
+ globs: [],
1421
1423
  gitExec: git,
1422
1424
  });
1423
- // EXACTLY the core renderer's line, on stdout format v1, byte-for-byte.
1425
+ // EXACTLY the core renderer's line, on stdout, with the format-v1.2 leg
1426
+ // field appended — the v1 text is byte-for-byte unchanged BEFORE the field,
1427
+ // and an empty deposit store renders `leg: none` (mmnto-ai/totem#2698).
1424
1428
  expect(logSpy).toHaveBeenCalledTimes(1);
1425
- expect(logSpy).toHaveBeenCalledWith(renderCovariateLine(verdict));
1429
+ expect(logSpy).toHaveBeenCalledWith(`${renderCovariateLine(verdict)} leg: none`);
1426
1430
  const line = String(logSpy.mock.calls[0][0]);
1427
- expect(line).toMatch(/^local-lane: [0-9a-f]{8} round=0 settled=true lanes=2\/2$/);
1431
+ expect(line).toMatch(/^local-lane: [0-9a-f]{8} round=0 settled=true lanes=2\/2 leg: none$/);
1428
1432
  });
1429
1433
  it('resolves the LATEST verdict for the lineage (round chain respected)', async () => {
1430
1434
  const git = fakeGit('feature-cov', 'covbase');
@@ -1450,6 +1454,7 @@ describe('printCovariateLine (rev-5 item 4)', () => {
1450
1454
  diffMeta: { source: 'branch-vs-base', base: 'main' },
1451
1455
  totemDirAbs: tmpDir,
1452
1456
  cwd: tmpDir,
1457
+ globs: [],
1453
1458
  gitExec: git,
1454
1459
  });
1455
1460
  expect(String(logSpy.mock.calls[0][0])).toContain('round=1');
@@ -1461,6 +1466,7 @@ describe('printCovariateLine (rev-5 item 4)', () => {
1461
1466
  diffMeta: { source: 'branch-vs-base', base: 'main' },
1462
1467
  totemDirAbs: tmpDir,
1463
1468
  cwd: tmpDir,
1469
+ globs: [],
1464
1470
  gitExec: fakeGit('feature-none', 'nobase'),
1465
1471
  })).resolves.toBeUndefined();
1466
1472
  expect(logSpy).not.toHaveBeenCalled();
@@ -1470,10 +1476,442 @@ describe('printCovariateLine (rev-5 item 4)', () => {
1470
1476
  it('no diff scope (diffMeta null) ⇒ loud sensor message, NO stdout line, clean return', async () => {
1471
1477
  const errSpy = vi.mocked(console.error);
1472
1478
  const logSpy = vi.mocked(console.log);
1473
- await expect(printCovariateLine({ diffMeta: null, totemDirAbs: tmpDir, cwd: tmpDir })).resolves.toBeUndefined();
1479
+ await expect(printCovariateLine({ diffMeta: null, totemDirAbs: tmpDir, cwd: tmpDir, globs: [] })).resolves.toBeUndefined();
1474
1480
  expect(logSpy).not.toHaveBeenCalled();
1475
1481
  const out = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
1476
1482
  expect(out).toMatch(/no diff detected/i);
1477
1483
  });
1478
1484
  });
1485
+ // ─── Covariate format v1.2 — the leg field (mmnto-ai/totem#2698) ──────────────
1486
+ describe('covariate format v1.2: the leg field (mmnto-ai/totem#2698)', () => {
1487
+ /** Restored after each case — see the cross-spawn note in `legs.test.ts`. */
1488
+ const realCwd = process.cwd();
1489
+ let tmpDir;
1490
+ beforeEach(() => {
1491
+ tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'covariate-leg-')));
1492
+ // A REAL repo, because since mmnto-ai/totem#2698 fold 4 the leg field
1493
+ // derives its coverage inputs from HEAD's own branch scope on every review
1494
+ // scope. These cases declare no owed globs, so that scope resolves, owes
1495
+ // nothing, and coverage is vacuously satisfied — which is what keeps them
1496
+ // about the FIELD's shape rather than about the predicate.
1497
+ execFileSync('git', ['init', '-b', 'main'], { cwd: tmpDir, stdio: 'ignore' });
1498
+ execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '--allow-empty', '-m', 'base'], { cwd: tmpDir, stdio: 'ignore' });
1499
+ vi.spyOn(console, 'error').mockImplementation(() => { });
1500
+ vi.spyOn(console, 'log').mockImplementation(() => { });
1501
+ });
1502
+ afterEach(() => {
1503
+ vi.restoreAllMocks();
1504
+ process.chdir(realCwd);
1505
+ cleanTmpDir(tmpDir);
1506
+ });
1507
+ /** A 40-char lowercase hex commit name derived from a seed (the schema's shape). */
1508
+ const sha40 = (seed) => hex(seed).slice(0, 40);
1509
+ /**
1510
+ * A git runner over an IN-MEMORY commit graph. It answers the four lineage
1511
+ * probes `fakeGit` does, plus the three the leg adapter makes — and RECORDS
1512
+ * every ancestry probe, so a test can assert that an exact-match deposit
1513
+ * spends none of them.
1514
+ */
1515
+ function legGit(head, graph = {}) {
1516
+ const ancestryCalls = [];
1517
+ const commits = graph.commits ?? [];
1518
+ const ancestorOf = graph.ancestorOf ?? {};
1519
+ const run = ((args) => {
1520
+ if (args[0] === 'symbolic-ref')
1521
+ return 'feature-leg';
1522
+ if (args[0] === 'rev-parse' && args[1] === '--show-toplevel')
1523
+ return REPO_TOPLEVEL;
1524
+ if (args[0] === 'rev-parse')
1525
+ return head;
1526
+ if (args[0] === 'cat-file') {
1527
+ ancestryCalls.push([...args]);
1528
+ const sha = String(args[2]).replace('^{commit}', '');
1529
+ if (!commits.includes(sha))
1530
+ throw new Error(`fatal: not a commit: ${sha}`);
1531
+ return '';
1532
+ }
1533
+ if (args[0] === 'merge-base' && args[1] === '--is-ancestor') {
1534
+ ancestryCalls.push([...args]);
1535
+ if (!(ancestorOf[String(args[2])] ?? []).includes(String(args[3]))) {
1536
+ throw new Error('not an ancestor');
1537
+ }
1538
+ return '';
1539
+ }
1540
+ if (args[0] === 'merge-base')
1541
+ return 'legbase';
1542
+ if (args[0] === 'rev-list') {
1543
+ ancestryCalls.push([...args]);
1544
+ return String(graph.distance ?? 0);
1545
+ }
1546
+ return '';
1547
+ });
1548
+ run.ancestryCalls = ancestryCalls;
1549
+ return run;
1550
+ }
1551
+ /** Write a deposit through the REAL core writer (never a hand-rolled file). */
1552
+ function deposit(diffSha, findings = [], folded = []) {
1553
+ saveLegDeposit(tmpDir, {
1554
+ schemaVersion: LEG_DEPOSIT_SCHEMA_VERSION,
1555
+ diffSha,
1556
+ readAt: '2026-09-03T04:00:00.000Z',
1557
+ findings,
1558
+ folded,
1559
+ verdict: 'read the diff; two blocking claims folded',
1560
+ });
1561
+ }
1562
+ const finding = (id, severity) => ({
1563
+ id,
1564
+ severity,
1565
+ file: 'packages/cli/src/commands/review-fan.ts',
1566
+ line: 1,
1567
+ claim: `${id} claim`,
1568
+ counterexample: '',
1569
+ });
1570
+ const twoLaneInvoker = () => mapInvoker({
1571
+ 'anthropic:claude-a': completedInvocation({
1572
+ content: wrapVerdict([]),
1573
+ provider: 'anthropic',
1574
+ model: 'claude-a',
1575
+ seed: 'lega',
1576
+ }),
1577
+ 'gemini:g': completedInvocation({
1578
+ content: wrapVerdict([]),
1579
+ provider: 'gemini',
1580
+ model: 'g',
1581
+ seed: 'legb',
1582
+ }),
1583
+ });
1584
+ it('appends the leg field to the VERDICT shape, leaving the v1 text byte-unchanged', async () => {
1585
+ const head = sha40('head-exact');
1586
+ const git = legGit(head);
1587
+ await runReviewFan(makeCtx(tmpDir, ['anthropic:claude-a', 'gemini:g'], twoLaneInvoker(), { gitExec: git }));
1588
+ const verdict = listVerdictArtifacts(tmpDir, noWarn)[0];
1589
+ // Two BLOCKING (both folded) + one MATERIAL: the ruled semantic is that a
1590
+ // folded finding counts in BOTH its severity bucket and `folded`, so
1591
+ // `blocking=2 folded=2` reads "all addressed".
1592
+ deposit(head, [finding('b1', 'BLOCKING'), finding('b2', 'BLOCKING'), finding('m1', 'MATERIAL')], ['b1', 'b2']);
1593
+ const logSpy = vi.mocked(console.log);
1594
+ logSpy.mockClear();
1595
+ await printCovariateLine({
1596
+ diffMeta: { source: 'branch-vs-base', base: 'main' },
1597
+ totemDirAbs: tmpDir,
1598
+ cwd: tmpDir,
1599
+ globs: [],
1600
+ gitExec: git,
1601
+ });
1602
+ const line = String(logSpy.mock.calls[0][0]);
1603
+ const field = `leg: ${head.slice(0, 8)} blocking=2 material=1 folded=2`;
1604
+ expect(line).toBe(`${renderCovariateLine(verdict)} ${field}`);
1605
+ // The v1 prefix is byte-identical: everything before the field is exactly
1606
+ // what v1/v1.1 consumers already parse, and the second token still
1607
+ // discriminates the shape.
1608
+ expect(line.slice(0, line.length - field.length - 1)).toBe(renderCovariateLine(verdict));
1609
+ expect(line.split(' ')[1]).toMatch(/^[0-9a-f]{8}$/);
1610
+ });
1611
+ it('prints the deposit-only head shape `local-lane: none <field>` when no verdict artifact exists', async () => {
1612
+ const head = sha40('head-deposit-only');
1613
+ const git = legGit(head);
1614
+ deposit(head, [finding('b1', 'BLOCKING')], []);
1615
+ const logSpy = vi.mocked(console.log);
1616
+ const errSpy = vi.mocked(console.error);
1617
+ await printCovariateLine({
1618
+ diffMeta: { source: 'branch-vs-base', base: 'main' },
1619
+ totemDirAbs: tmpDir,
1620
+ cwd: tmpDir,
1621
+ globs: [],
1622
+ gitExec: git,
1623
+ });
1624
+ expect(logSpy).toHaveBeenCalledTimes(1);
1625
+ expect(String(logSpy.mock.calls[0][0])).toBe(`local-lane: none leg: ${head.slice(0, 8)} blocking=1 material=0 folded=0`);
1626
+ // The head shape REPLACES the no-verdict sensor: there IS a line to carry.
1627
+ const out = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
1628
+ expect(out).not.toMatch(/no verdict artifact recorded for the current lineage/i);
1629
+ });
1630
+ it('keeps the no-verdict sensor (and prints nothing) when neither a verdict nor a deposit resolves', async () => {
1631
+ const logSpy = vi.mocked(console.log);
1632
+ const errSpy = vi.mocked(console.error);
1633
+ await printCovariateLine({
1634
+ diffMeta: { source: 'branch-vs-base', base: 'main' },
1635
+ totemDirAbs: tmpDir,
1636
+ cwd: tmpDir,
1637
+ globs: [],
1638
+ gitExec: legGit(sha40('head-empty')),
1639
+ });
1640
+ expect(logSpy).not.toHaveBeenCalled();
1641
+ const out = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
1642
+ expect(out).toMatch(/no verdict artifact recorded for the current lineage/i);
1643
+ });
1644
+ it('discloses a corrupt deposit sibling as a Sensor line without changing the field', async () => {
1645
+ const head = sha40('head-corrupt-sibling');
1646
+ const git = legGit(head);
1647
+ deposit(head, [finding('m1', 'MATERIAL')], []);
1648
+ // A sibling that is not a deposit at all. Its NAME is a valid address, so
1649
+ // the loader must reject it on content, not on the filename.
1650
+ const sibling = `${sha40('corrupt-sibling')}.json`;
1651
+ fs.writeFileSync(path.join(tmpDir, 'artifacts', 'legs', sibling), '{ not json', 'utf-8');
1652
+ const logSpy = vi.mocked(console.log);
1653
+ const errSpy = vi.mocked(console.error);
1654
+ await printCovariateLine({
1655
+ diffMeta: { source: 'branch-vs-base', base: 'main' },
1656
+ totemDirAbs: tmpDir,
1657
+ cwd: tmpDir,
1658
+ globs: [],
1659
+ gitExec: git,
1660
+ });
1661
+ const out = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
1662
+ expect(out).toContain(`Sensor: ignoring corrupt leg deposit ${sibling}`);
1663
+ // Loud, and NEVER masking the valid sibling: the field is the good one's.
1664
+ expect(String(logSpy.mock.calls[0][0])).toBe(`local-lane: none leg: ${head.slice(0, 8)} blocking=0 material=1 folded=0`);
1665
+ });
1666
+ it('resolveLegFieldForHead spends NO ancestry probe on an exact match', async () => {
1667
+ const head = sha40('head-no-probe');
1668
+ const git = legGit(head);
1669
+ deposit(head, [], []);
1670
+ const resolution = await resolveLegFieldForHead(tmpDir, tmpDir, git);
1671
+ expect(resolution.field).toBe(`leg: ${head.slice(0, 8)} blocking=0 material=0 folded=0`);
1672
+ expect(resolution.winner?.rank).toBe('exact');
1673
+ expect(resolution.winner?.distance).toBe(0);
1674
+ // `cat-file`, `merge-base --is-ancestor` and `rev-list --count` are the
1675
+ // three probes the adapter can make; an exact match answers the gate's
1676
+ // question without spawning any of them.
1677
+ expect(git.ancestryCalls).toEqual([]);
1678
+ });
1679
+ it('resolveLegFieldForHead accepts an ANCESTOR deposit and reports its distance', async () => {
1680
+ const head = sha40('head-descendant');
1681
+ const base = sha40('base-ancestor');
1682
+ const git = legGit(head, { commits: [base], ancestorOf: { [base]: [head] }, distance: 7 });
1683
+ deposit(base, [finding('b1', 'BLOCKING')], ['b1']);
1684
+ const resolution = await resolveLegFieldForHead(tmpDir, tmpDir, git);
1685
+ expect(resolution.field).toBe(`leg: ${base.slice(0, 8)} blocking=1 material=0 folded=1`);
1686
+ expect(resolution.winner?.rank).toBe('ancestor');
1687
+ expect(resolution.winner?.distance).toBe(7);
1688
+ });
1689
+ it('resolveLegFieldForHead is `leg: none` for a deposit that is not ancestor-or-equal, and for an unresolvable HEAD', async () => {
1690
+ const head = sha40('head-disjoint');
1691
+ const other = sha40('other-branch');
1692
+ const git = legGit(head, { commits: [other] });
1693
+ deposit(other, [], []);
1694
+ expect((await resolveLegFieldForHead(tmpDir, tmpDir, git)).field).toBe('leg: none');
1695
+ // A HEAD that does not resolve at all (not a repo / unborn branch) is
1696
+ // `leg: none`, never a throw on a verb contracted to exit 0.
1697
+ const noHead = (args) => {
1698
+ if (args[0] === 'rev-parse')
1699
+ throw new Error('fatal: not a git repository');
1700
+ return '';
1701
+ };
1702
+ const resolution = await resolveLegFieldForHead(tmpDir, tmpDir, noHead);
1703
+ expect(resolution.field).toBe('leg: none');
1704
+ expect(resolution.winner).toBeUndefined();
1705
+ expect(resolution.corrupt).toEqual([]);
1706
+ });
1707
+ });
1708
+ // ─── Fold 3: the covariate resolves through the GATE's predicate ────────────
1709
+ //
1710
+ // The field must never name a deposit the gate would reject. Both sites route
1711
+ // through the same core resolution with the same coverage inputs, and a scope
1712
+ // that cannot supply them says so instead of implying a check that never ran
1713
+ // (mmnto-ai/totem#2698 fold 3).
1714
+ describe('the leg field honors coverage (mmnto-ai/totem#2698 fold 3)', () => {
1715
+ let tmpDir;
1716
+ beforeEach(() => {
1717
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'legfield-coverage-'));
1718
+ vi.restoreAllMocks();
1719
+ vi.spyOn(console, 'error').mockImplementation(() => { });
1720
+ vi.spyOn(console, 'log').mockImplementation(() => { });
1721
+ });
1722
+ afterEach(() => {
1723
+ vi.restoreAllMocks();
1724
+ cleanTmpDir(tmpDir);
1725
+ });
1726
+ const HEAD_SHA = 'a'.repeat(40);
1727
+ const BASE_SHA = 'b'.repeat(40);
1728
+ /** A runner that answers HEAD, ancestry, and a candidate's own branch diff. */
1729
+ function coverageGit(reach) {
1730
+ return (args) => {
1731
+ if (args[0] === 'rev-parse' && args[1] === 'HEAD')
1732
+ return HEAD_SHA;
1733
+ if (args[0] === 'cat-file')
1734
+ return '';
1735
+ if (args[0] === 'merge-base')
1736
+ return '';
1737
+ if (args[0] === 'rev-list')
1738
+ return '1';
1739
+ if (args[0] === 'diff' && args[1] === '--name-only')
1740
+ return reach.join('\n');
1741
+ return '';
1742
+ };
1743
+ }
1744
+ function depositAt(sha) {
1745
+ saveLegDeposit(tmpDir, {
1746
+ schemaVersion: LEG_DEPOSIT_SCHEMA_VERSION,
1747
+ diffSha: sha,
1748
+ readAt: '2026-09-03T06:00:00.000Z',
1749
+ findings: [],
1750
+ folded: [],
1751
+ verdict: 'read the diff at this head',
1752
+ });
1753
+ }
1754
+ it('a deposit the gate would reject as no-coverage resolves to `leg: none`', async () => {
1755
+ depositAt(BASE_SHA);
1756
+ const resolution = await resolveLegFieldForHead(tmpDir, tmpDir,
1757
+ // Its own branch diff touched nothing this push owes — the merge-base shape.
1758
+ coverageGit(['src/unrelated.ts']), { query: { base: 'main', owedFiles: ['docs/wiki/a.md'] } });
1759
+ expect(resolution.field).toBe('leg: none');
1760
+ expect(resolution.winner).toBeUndefined();
1761
+ });
1762
+ it('the same deposit names itself once it covers an owed path', async () => {
1763
+ depositAt(BASE_SHA);
1764
+ const resolution = await resolveLegFieldForHead(tmpDir, tmpDir, coverageGit(['docs/wiki/a.md']), { query: { base: 'main', owedFiles: ['docs/wiki/a.md'] } });
1765
+ expect(resolution.field).toBe(`leg: ${BASE_SHA.slice(0, 8)} blocking=0 material=0 folded=0`);
1766
+ });
1767
+ it('WITHOUT a coverage query the field is ancestry-only — the pre-fold behavior', async () => {
1768
+ depositAt(BASE_SHA);
1769
+ const resolution = await resolveLegFieldForHead(tmpDir, tmpDir, coverageGit(['src/other.ts']));
1770
+ expect(resolution.field).toBe(`leg: ${BASE_SHA.slice(0, 8)} blocking=0 material=0 folded=0`);
1771
+ });
1772
+ it('an UNDERIVABLE coverage answer is `leg: none`, never an ancestry-only name', async () => {
1773
+ // The fold-4 rule at the seam: an ancestor deposit exists and would have
1774
+ // been named on ancestry alone. Once coverage was ASKED FOR and could not
1775
+ // be derived, the honest answer is none — crediting it would be the field
1776
+ // disagreeing with the gate, which rejects it.
1777
+ depositAt(BASE_SHA);
1778
+ const resolution = await resolveLegFieldForHead(tmpDir, tmpDir, coverageGit(['docs/wiki/a.md']), {
1779
+ reason: 'coverage was not derivable — HEAD has no branch base',
1780
+ });
1781
+ expect(resolution.field).toBe('leg: none');
1782
+ expect(resolution.winner).toBeUndefined();
1783
+ });
1784
+ it('the no-base arm STILL discloses a corrupt deposit (fold 5, F7)', async () => {
1785
+ // The store is a fact about itself: a file that is not a deposit is worth
1786
+ // saying whatever coverage concludes. The first version of this arm
1787
+ // returned early with an empty corrupt list and silenced exactly the sensor
1788
+ // a hand-edited deposit needs.
1789
+ fs.mkdirSync(path.join(tmpDir, 'artifacts', 'legs'), { recursive: true });
1790
+ fs.writeFileSync(path.join(tmpDir, 'artifacts', 'legs', `${'e'.repeat(40)}.json`), 'not a deposit at all');
1791
+ const resolution = await resolveLegFieldForHead(tmpDir, tmpDir, coverageGit([]), {
1792
+ reason: 'coverage was not derivable — HEAD has no branch base',
1793
+ });
1794
+ expect(resolution.field).toBe('leg: none');
1795
+ expect(resolution.winner).toBeUndefined();
1796
+ expect(resolution.corrupt.map((entry) => entry.file)).toEqual([`${'e'.repeat(40)}.json`]);
1797
+ });
1798
+ it('HEAD with no branch base: the covariate prints ONE sensor and no leg name', async () => {
1799
+ // `tmpDir` is not a git repo, so HEAD's branch scope will not resolve.
1800
+ const errSpy = vi.mocked(console.error);
1801
+ depositAt(BASE_SHA);
1802
+ await expect(printCovariateLine({
1803
+ diffMeta: { source: 'staged' },
1804
+ totemDirAbs: tmpDir,
1805
+ cwd: tmpDir,
1806
+ globs: ['docs/**'],
1807
+ gitExec: coverageGit(['docs/wiki/a.md']),
1808
+ })).resolves.toBeUndefined();
1809
+ const out = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
1810
+ expect(out).toContain('coverage was not derivable — HEAD has no branch base');
1811
+ // Article-free, and it never claims an ancestry-only fallback happened.
1812
+ expect(out).not.toContain('for a staged scope');
1813
+ expect(out).not.toContain('ancestry alone');
1814
+ expect(vi.mocked(console.log)).not.toHaveBeenCalled();
1815
+ });
1816
+ // The reproduction, end to end: a REAL repo whose review scope is the default
1817
+ // dirty-tree one, and a deposit at the merge base. Before fold 4 the field
1818
+ // resolved on the review's scope, found no branch base, fell back to ancestry
1819
+ // and NAMED that deposit — the gate rejects it.
1820
+ describe('the field derives coverage from HEAD, whatever the review scope is', () => {
1821
+ /** Built, never authored as an escape (the banked decode trap). */
1822
+ const NL = String.fromCharCode(10);
1823
+ let repoDir;
1824
+ let baseSha;
1825
+ let headSha;
1826
+ const realCwd = process.cwd();
1827
+ beforeEach(() => {
1828
+ repoDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'legfield-headscope-')));
1829
+ const git = (...args) => execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', ...args], { cwd: repoDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
1830
+ git('init', '-b', 'main');
1831
+ fs.writeFileSync(path.join(repoDir, 'src.ts'), 'export const a = 1;');
1832
+ git('add', '.');
1833
+ git('commit', '-m', 'base');
1834
+ baseSha = git('rev-parse', 'HEAD');
1835
+ git('checkout', '-b', 'feat/owed');
1836
+ fs.mkdirSync(path.join(repoDir, 'docs'), { recursive: true });
1837
+ fs.writeFileSync(path.join(repoDir, 'docs', 'page.md'), '# a page');
1838
+ git('add', 'docs/page.md');
1839
+ git('commit', '-m', 'a docs change');
1840
+ headSha = git('rev-parse', 'HEAD');
1841
+ });
1842
+ afterEach(() => {
1843
+ process.chdir(realCwd);
1844
+ cleanTmpDir(repoDir);
1845
+ });
1846
+ function depositIn(dir, sha) {
1847
+ saveLegDeposit(path.join(dir, '.totem'), {
1848
+ schemaVersion: LEG_DEPOSIT_SCHEMA_VERSION,
1849
+ diffSha: sha,
1850
+ readAt: '2026-09-03T06:00:00.000Z',
1851
+ findings: [],
1852
+ folded: [],
1853
+ verdict: 'read the diff at this head',
1854
+ });
1855
+ }
1856
+ async function covariateOn(source) {
1857
+ vi.mocked(console.log).mockClear();
1858
+ await printCovariateLine({
1859
+ diffMeta: { source },
1860
+ totemDirAbs: path.join(repoDir, '.totem'),
1861
+ cwd: repoDir,
1862
+ globs: ['docs/**'],
1863
+ });
1864
+ return vi.mocked(console.log).mock.calls.map((c) => c.join(' '));
1865
+ }
1866
+ it('a MERGE-BASE deposit is `leg: none` on a dirty-tree review scope', async () => {
1867
+ depositIn(repoDir, baseSha);
1868
+ const printed = await covariateOn('uncommitted');
1869
+ // Nothing printed: with no verdict artifact AND no leg, there is no line
1870
+ // — where before the fold this printed `local-lane: none leg: <base8>`.
1871
+ expect(printed.join(' ')).not.toContain(baseSha.slice(0, 8));
1872
+ });
1873
+ it('a LARGE branch diff narrates nothing under [Legs] (fold 5, F2)', async () => {
1874
+ // The truncation warning is advice about an LLM review of this diff, and
1875
+ // a background probe is not going to run one. This branch's own diff is
1876
+ // ~370k chars, so before the guard every covariate run here printed it.
1877
+ const big = Array.from({ length: 4000 }, (_, i) => `line ${i} of a large page`).join(NL);
1878
+ fs.writeFileSync(path.join(repoDir, 'docs', 'big.md'), big);
1879
+ execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'add', '-A'], {
1880
+ cwd: repoDir,
1881
+ stdio: 'ignore',
1882
+ });
1883
+ execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-m', 'a large page'], { cwd: repoDir, stdio: 'ignore' });
1884
+ const errSpy = vi.mocked(console.error);
1885
+ errSpy.mockClear();
1886
+ await printCovariateLine({
1887
+ diffMeta: { source: 'uncommitted' },
1888
+ totemDirAbs: path.join(repoDir, '.totem'),
1889
+ cwd: repoDir,
1890
+ globs: ['docs/**'],
1891
+ });
1892
+ const narration = errSpy.mock.calls.map((c) => c.join(' ')).join(NL);
1893
+ // Every one of the resolver's four narration lines, and this module's own.
1894
+ expect(narration).not.toContain('[Legs]');
1895
+ expect(narration).not.toContain('Diff exceeds');
1896
+ expect(narration).not.toContain('Diff source:');
1897
+ expect(narration).not.toContain('Changed files (');
1898
+ // The control that makes the assertion above non-vacuous: the same diff,
1899
+ // resolved WITHOUT the suppression, does narrate all of it — so the
1900
+ // fixture really does cross the 50k threshold and the option is what
1901
+ // silenced it. Called on the resolver directly, because the legs scope
1902
+ // resolver now suppresses it unconditionally and prints its own line.
1903
+ const { getDiffForReview } = await import('../git.js');
1904
+ errSpy.mockClear();
1905
+ await getDiffForReview({ branch: true }, { ignorePatterns: [], shieldIgnorePatterns: [] }, repoDir, 'Control');
1906
+ const loud = errSpy.mock.calls.map((c) => c.join(' ')).join(NL);
1907
+ expect(loud).toContain('Diff exceeds');
1908
+ expect(loud).toContain('Changed files (');
1909
+ });
1910
+ it('a COVERING deposit is named on the same review scope', async () => {
1911
+ depositIn(repoDir, headSha);
1912
+ const printed = await covariateOn('staged');
1913
+ expect(printed.join(' ')).toContain(`local-lane: none leg: ${headSha.slice(0, 8)}`);
1914
+ });
1915
+ });
1916
+ });
1479
1917
  //# sourceMappingURL=review-fan.test.js.map