@mmnto/cli 1.104.0 → 1.106.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.
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
6
  import * as totem from '@mmnto/totem';
6
7
  import { readLedgerEvents, saveCompiledRules, TotemError, TotemParseError } from '@mmnto/totem';
@@ -1120,10 +1121,18 @@ describe('--ast-parse-mode lenient', () => {
1120
1121
  });
1121
1122
  // totem-context: helper has no OrExit suffix; the rule misclassifies test-fixture builders that return literal objects
1122
1123
  function makeAstRuleAndDiff() {
1124
+ // fileGlobs target a REGISTERED extension on purpose. These tests mock
1125
+ // `applyAstRulesToAdditions` wholesale — the fixture rule exists only to make
1126
+ // `astRules.length > 0` so the mocked pipeline is reached, and the rust
1127
+ // wording lives in the mocked error, not the rule. A `**/*.rs` glob would now
1128
+ // be condemned by the rule-load target-mismatch guard
1129
+ // (mmnto-ai/totem-strategy#971, Prop 309 Class 7) before the mock is ever
1130
+ // consulted, which would test the guard instead of the parse-mode routing
1131
+ // these cases exist for. The guard's own coverage lives in its describe block.
1123
1132
  const astRule = makeRule('', 'rust pattern', 'No unsafe', {
1124
1133
  engine: 'ast-grep',
1125
1134
  astGrepPattern: 'unsafe { $$$ }',
1126
- fileGlobs: ['**/*.rs'],
1135
+ fileGlobs: ['**/*.ts'],
1127
1136
  });
1128
1137
  saveCompiledRules(path.join(tmpDir, TOTEM_DIR, 'compiled-rules.json'), [astRule]);
1129
1138
  const diff = `diff --git a/src/lib.rs b/src/lib.rs
@@ -1354,4 +1363,534 @@ describe('--ast-parse-mode lenient', () => {
1354
1363
  expect(result.output).not.toContain('Frozen-lesson');
1355
1364
  });
1356
1365
  });
1366
+ // ─── Corpus-bearing zero-rules hard-error (mmnto-ai/totem-strategy#971, Prop 309) ──
1367
+ //
1368
+ // A repo that carries a lesson corpus but loads ZERO compiled rules has its
1369
+ // entire enforcement gate silently disarmed behind a green exit. `totem lint`
1370
+ // must fail loud in that case (induced-failure triple below), while still
1371
+ // preserving the legitimate empty-corpus skip (mmnto-ai/totem#1831) and the
1372
+ // archived-in-place zero-active-rules lifecycle state (the controls).
1373
+ describe('corpus-bearing zero-rules hard-error (mmnto-ai/totem-strategy#971)', () => {
1374
+ let tmpDir;
1375
+ beforeEach(() => {
1376
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-rcr-corpus-'));
1377
+ fs.mkdirSync(path.join(tmpDir, TOTEM_DIR), { recursive: true });
1378
+ });
1379
+ afterEach(() => {
1380
+ cleanTmpDir(tmpDir);
1381
+ });
1382
+ /** Write a real lesson `.md` under `.totem/lessons/` so the repo is corpus-bearing. */
1383
+ function writeLesson(dir, name = 'baseline.md') {
1384
+ const lessonsDir = path.join(dir, TOTEM_DIR, 'lessons');
1385
+ fs.mkdirSync(lessonsDir, { recursive: true });
1386
+ fs.writeFileSync(path.join(lessonsDir, name), '# A lesson\n\nBody.\n');
1387
+ }
1388
+ /** A config whose only lesson-kind target matches `.totem/lessons/*.md`. */
1389
+ function lessonConfig(glob = '.totem/lessons/*.md') {
1390
+ return { targets: [{ glob, type: 'lesson', strategy: 'markdown-heading' }] };
1391
+ }
1392
+ const cleanDiff = () => makeDiff('src/app.ts', ' const x = 1;');
1393
+ // ── Induced-failure triple ──────────────────────────
1394
+ it('hard-errors when the manifest is missing but the repo carries lessons', async () => {
1395
+ writeLesson(tmpDir); // corpus-bearing; no compiled-rules.json written
1396
+ await expect(runCompiledRules({
1397
+ diff: cleanDiff(),
1398
+ cwd: tmpDir,
1399
+ totemDir: TOTEM_DIR,
1400
+ format: 'text',
1401
+ tag: 'Test',
1402
+ config: lessonConfig(),
1403
+ })).rejects.toThrow(/enforcement disarmed/i);
1404
+ });
1405
+ it('hard-errors on a truncated manifest instead of passing vacuously', async () => {
1406
+ writeLesson(tmpDir);
1407
+ // Truncated mid-array — JSON.parse throws SyntaxError, which core reports via
1408
+ // onWarn and returns []. Unmodified code passes vacuously here; the fix throws.
1409
+ fs.writeFileSync(path.join(tmpDir, TOTEM_DIR, 'compiled-rules.json'), '{"version":1,"rules":[');
1410
+ await expect(runCompiledRules({
1411
+ diff: cleanDiff(),
1412
+ cwd: tmpDir,
1413
+ totemDir: TOTEM_DIR,
1414
+ format: 'text',
1415
+ tag: 'Test',
1416
+ config: lessonConfig(),
1417
+ })).rejects.toThrow(/enforcement disarmed/i);
1418
+ });
1419
+ it('hard-errors on an EISDIR I/O fault and surfaces the load-warning accounting text', async () => {
1420
+ writeLesson(tmpDir);
1421
+ // Replace the manifest with a DIRECTORY at the same path — the portable,
1422
+ // Windows-safe I/O fault (readFileSync throws EISDIR on win32 and posix).
1423
+ fs.mkdirSync(path.join(tmpDir, TOTEM_DIR, 'compiled-rules.json'), { recursive: true });
1424
+ let message = '';
1425
+ try {
1426
+ await runCompiledRules({
1427
+ diff: cleanDiff(),
1428
+ cwd: tmpDir,
1429
+ totemDir: TOTEM_DIR,
1430
+ format: 'text',
1431
+ tag: 'Test',
1432
+ config: lessonConfig(),
1433
+ });
1434
+ }
1435
+ catch (err) {
1436
+ message = err instanceof Error ? err.message : String(err);
1437
+ }
1438
+ expect(message).toMatch(/enforcement disarmed/i);
1439
+ // The onWarn accounting text (dropped before the fix) is surfaced in the throw.
1440
+ expect(message).toContain('Could not load compiled rules');
1441
+ });
1442
+ // ── Controls ────────────────────────────────────────
1443
+ it('control: an empty-corpus repo (no lesson files) with a missing manifest still exits clean (mmnto-ai/totem#1831)', async () => {
1444
+ // Config DECLARES a lesson target, but no lesson files exist on disk, so the
1445
+ // discriminator treats the repo as empty-corpus — the info-skip is preserved.
1446
+ const result = await runCompiledRules({
1447
+ diff: cleanDiff(),
1448
+ cwd: tmpDir,
1449
+ totemDir: TOTEM_DIR,
1450
+ format: 'text',
1451
+ tag: 'Test',
1452
+ config: lessonConfig(),
1453
+ });
1454
+ expect(result.violations).toHaveLength(0);
1455
+ expect(result.rules).toHaveLength(0);
1456
+ expect(result.output).toBe('');
1457
+ });
1458
+ it('control: a corpus-bearing repo whose manifest holds only archived rules exits clean with a zero-active-rules message', async () => {
1459
+ writeLesson(tmpDir);
1460
+ // Valid manifest, present and parseable, but every rule is inert (archived).
1461
+ // loadCompiledRules filters it to [] with no warning — a legitimate lifecycle
1462
+ // state, NOT a disarmed gate.
1463
+ writeRules(tmpDir, [
1464
+ makeRule('console\\.log', 'No console', 'No console', { status: 'archived' }),
1465
+ ]);
1466
+ const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
1467
+ try {
1468
+ const result = await runCompiledRules({
1469
+ diff: cleanDiff(),
1470
+ cwd: tmpDir,
1471
+ totemDir: TOTEM_DIR,
1472
+ format: 'text',
1473
+ tag: 'Test',
1474
+ config: lessonConfig(),
1475
+ });
1476
+ expect(result.rules).toHaveLength(0);
1477
+ expect(result.violations).toHaveLength(0);
1478
+ // Join ALL args per call — coupling to log.info's internal arity would
1479
+ // silently break this assertion if the tag and message ever split.
1480
+ const messages = stderrSpy.mock.calls.map((c) => c.join(' '));
1481
+ expect(messages.find((m) => m.includes('zero ACTIVE rules'))).toBeDefined();
1482
+ }
1483
+ finally {
1484
+ stderrSpy.mockRestore();
1485
+ }
1486
+ });
1487
+ it('control: a repo with valid active rules is unaffected by the corpus-bearing gate', async () => {
1488
+ writeLesson(tmpDir);
1489
+ writeRules(tmpDir, [makeRule('neverMatchXYZ123', 'no match', 'No match rule')]);
1490
+ const result = await runCompiledRules({
1491
+ diff: cleanDiff(),
1492
+ cwd: tmpDir,
1493
+ totemDir: TOTEM_DIR,
1494
+ format: 'json',
1495
+ tag: 'Test',
1496
+ config: lessonConfig(),
1497
+ });
1498
+ expect(result.rules).toHaveLength(1);
1499
+ expect(result.violations).toHaveLength(0);
1500
+ expect(JSON.parse(result.output).pass).toBe(true);
1501
+ });
1502
+ it('control: a config-less caller with a truncated manifest surfaces the load warning but never hard-errors (the shield-estimate opt-out contract)', async () => {
1503
+ writeLesson(tmpDir);
1504
+ fs.writeFileSync(path.join(tmpDir, TOTEM_DIR, 'compiled-rules.json'), '{"version":1,"rules":[');
1505
+ const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
1506
+ try {
1507
+ // No `config` option — the caller opts out of the corpus-bearing hard
1508
+ // error (e.g. `shield estimate`). The accounting line must still render:
1509
+ // the opt-out covers the exit code, not the disclosure.
1510
+ const result = await runCompiledRules({
1511
+ diff: cleanDiff(),
1512
+ cwd: tmpDir,
1513
+ totemDir: TOTEM_DIR,
1514
+ format: 'text',
1515
+ tag: 'Test',
1516
+ });
1517
+ expect(result.rules).toHaveLength(0);
1518
+ expect(result.violations).toHaveLength(0);
1519
+ // Join ALL args per call — see the arity note in the archived-rules control.
1520
+ const messages = stderrSpy.mock.calls.map((c) => c.join(' '));
1521
+ expect(messages.find((m) => m.includes('Could not load compiled rules'))).toBeDefined();
1522
+ }
1523
+ finally {
1524
+ stderrSpy.mockRestore();
1525
+ }
1526
+ });
1527
+ it('hard-errors via a concrete (non-wildcard) lesson target — the aggregated .totem/lessons.md shape', async () => {
1528
+ // The production config declares BOTH a wildcard dir target and a concrete
1529
+ // aggregated-file target; this exercises the statSync existence branch (and
1530
+ // mixed-target iteration) that the wildcard-walk tests never reach.
1531
+ fs.writeFileSync(path.join(tmpDir, TOTEM_DIR, 'lessons.md'), '# Lessons\n\n## One\n\nBody.\n');
1532
+ await expect(runCompiledRules({
1533
+ diff: cleanDiff(),
1534
+ cwd: tmpDir,
1535
+ totemDir: TOTEM_DIR,
1536
+ format: 'text',
1537
+ tag: 'Test',
1538
+ config: {
1539
+ targets: [
1540
+ { glob: '.totem/lessons/*.md', type: 'lesson', strategy: 'markdown-heading' },
1541
+ { glob: '.totem/lessons.md', type: 'lesson', strategy: 'markdown-heading' },
1542
+ ],
1543
+ },
1544
+ })).rejects.toThrow(/enforcement disarmed/i);
1545
+ });
1546
+ });
1547
+ // ─── Target-mismatched rule hard-error (mmnto-ai/totem-strategy#971, Prop 309 Class 7) ──
1548
+ //
1549
+ // An AST rule whose declared `fileGlobs` ALL target extensions with no
1550
+ // registered Tree-sitter language can never execute. Worse, it does not fail
1551
+ // quietly: `rule-engine.ts` throws the moment a diff contains a file the rule's
1552
+ // globs claim, and that throw aborts the entire lint. Before this guard the
1553
+ // failure was diff-dependent — green on every run until the unlucky one. The
1554
+ // guard moves it to rule-LOAD time so it fires deterministically.
1555
+ describe('target-mismatched rule hard-error (mmnto-ai/totem-strategy#971, Prop 309 Class 7)', () => {
1556
+ let tmpDir;
1557
+ beforeEach(() => {
1558
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-rcr-target-'));
1559
+ fs.mkdirSync(path.join(tmpDir, TOTEM_DIR), { recursive: true });
1560
+ });
1561
+ afterEach(() => {
1562
+ cleanTmpDir(tmpDir);
1563
+ });
1564
+ /** The observed specimen's shape: ast-grep scoped entirely to `.json` files. */
1565
+ function mismatchedAstGrepRule(overrides = {}) {
1566
+ return makeRule('', 'No inline secrets in agent config', 'No inline secrets', {
1567
+ lessonHash: 'target-mismatch-astgrep',
1568
+ engine: 'ast-grep',
1569
+ astGrepPattern: 'pair($KEY, $VALUE)',
1570
+ fileGlobs: ['**/.mcp.json', '**/.cursor/mcp.json'],
1571
+ ...overrides,
1572
+ });
1573
+ }
1574
+ const cleanDiff = () => makeDiff('src/app.ts', ' const x = 1;');
1575
+ // ── Induced failure ─────────────────────────────────
1576
+ it('hard-errors at load when every positive glob of an ast-grep rule targets an unregistered extension', async () => {
1577
+ writeRules(tmpDir, [mismatchedAstGrepRule()]);
1578
+ await expect(runCompiledRules({
1579
+ diff: cleanDiff(),
1580
+ cwd: tmpDir,
1581
+ totemDir: TOTEM_DIR,
1582
+ format: 'text',
1583
+ tag: 'Test',
1584
+ })).rejects.toThrow(/enforcement disarmed/i);
1585
+ });
1586
+ it('names the lessonHash, the engine, every glob, the unregistered extension, and the archive-or-fix remedy', async () => {
1587
+ writeRules(tmpDir, [mismatchedAstGrepRule()]);
1588
+ let message = '';
1589
+ let hint = '';
1590
+ try {
1591
+ await runCompiledRules({
1592
+ diff: cleanDiff(),
1593
+ cwd: tmpDir,
1594
+ totemDir: TOTEM_DIR,
1595
+ format: 'text',
1596
+ tag: 'Test',
1597
+ });
1598
+ }
1599
+ catch (err) {
1600
+ message = err instanceof Error ? err.message : String(err);
1601
+ hint = err instanceof TotemError ? err.recoveryHint : '';
1602
+ }
1603
+ // Everything the operator needs to act without opening the manifest.
1604
+ expect(message).toContain('target-mismatch-astgrep');
1605
+ expect(message).toContain('No inline secrets');
1606
+ expect(message).toContain("engine 'ast-grep'");
1607
+ expect(message).toContain('**/.mcp.json');
1608
+ expect(message).toContain('**/.cursor/mcp.json');
1609
+ expect(message).toContain('.json');
1610
+ // The detonation mechanism is named, not just the mismatch.
1611
+ expect(message).toContain('abort the entire lint');
1612
+ // Both remedies, plus the registry snapshot that makes "fix the globs" actionable.
1613
+ expect(hint).toContain("status: 'archived'");
1614
+ expect(hint).toContain('fileGlobs');
1615
+ expect(hint).toContain('.tsx');
1616
+ });
1617
+ it("hard-errors for the engine:'ast' variant — the abort seam unions both AST engines", async () => {
1618
+ // rule-engine.ts searches `allAstRules` (Tree-sitter 'ast' ∪ 'ast-grep') for
1619
+ // a rule claiming the unparseable file, so an 'ast' rule arms the identical
1620
+ // landmine and must be validated identically.
1621
+ writeRules(tmpDir, [
1622
+ makeRule('', 'No secrets in settings', 'No secrets in settings', {
1623
+ lessonHash: 'target-mismatch-ast',
1624
+ engine: 'ast',
1625
+ astQuery: '(pair) @p',
1626
+ fileGlobs: ['**/settings.json'],
1627
+ }),
1628
+ ]);
1629
+ let message = '';
1630
+ try {
1631
+ await runCompiledRules({
1632
+ diff: cleanDiff(),
1633
+ cwd: tmpDir,
1634
+ totemDir: TOTEM_DIR,
1635
+ format: 'text',
1636
+ tag: 'Test',
1637
+ });
1638
+ }
1639
+ catch (err) {
1640
+ message = err instanceof Error ? err.message : String(err);
1641
+ }
1642
+ expect(message).toMatch(/enforcement disarmed/i);
1643
+ expect(message).toContain('target-mismatch-ast');
1644
+ expect(message).toContain("engine 'ast'");
1645
+ });
1646
+ it("fires on a diff that touches NONE of the rule's globs — deterministic at load, not on first contact", async () => {
1647
+ // The whole point of the hardening. The diff is a .ts file; the broken rule
1648
+ // is scoped to .json. Pre-guard this run was green and the landmine stayed
1649
+ // armed for whichever future diff happened to touch a .json file.
1650
+ writeRules(tmpDir, [mismatchedAstGrepRule()]);
1651
+ await expect(runCompiledRules({
1652
+ diff: makeDiff('src/unrelated.ts', ' const unrelated = true;'),
1653
+ cwd: tmpDir,
1654
+ totemDir: TOTEM_DIR,
1655
+ format: 'json',
1656
+ tag: 'Test',
1657
+ })).rejects.toThrow(/enforcement disarmed/i);
1658
+ });
1659
+ it('reports every offender in one deterministic failure rather than stopping at the first', async () => {
1660
+ writeRules(tmpDir, [
1661
+ mismatchedAstGrepRule({ lessonHash: 'offender-one' }),
1662
+ mismatchedAstGrepRule({ lessonHash: 'offender-two', fileGlobs: ['config/*.yaml'] }),
1663
+ ]);
1664
+ let message = '';
1665
+ try {
1666
+ await runCompiledRules({
1667
+ diff: cleanDiff(),
1668
+ cwd: tmpDir,
1669
+ totemDir: TOTEM_DIR,
1670
+ format: 'text',
1671
+ tag: 'Test',
1672
+ });
1673
+ }
1674
+ catch (err) {
1675
+ message = err instanceof Error ? err.message : String(err);
1676
+ }
1677
+ expect(message).toContain('2 active AST rule(s)');
1678
+ expect(message).toContain('offender-one');
1679
+ expect(message).toContain('offender-two');
1680
+ expect(message).toContain('.yaml');
1681
+ });
1682
+ // ── Operator escape (mmnto-ai/totem#1982) ───────────
1683
+ it('degrades to a warning under --ast-parse-mode lenient instead of hard-erroring', async () => {
1684
+ // Without this, the guard would remove the ONLY escape for a repo whose
1685
+ // rules target a pack language it has not installed — `.rs` here, exactly
1686
+ // the shape @mmnto/pack-rust-architecture provides.
1687
+ writeRules(tmpDir, [
1688
+ mismatchedAstGrepRule({ lessonHash: 'pack-language-rule', fileGlobs: ['**/*.rs'] }),
1689
+ ]);
1690
+ const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
1691
+ try {
1692
+ const result = await runCompiledRules({
1693
+ diff: cleanDiff(),
1694
+ cwd: tmpDir,
1695
+ totemDir: TOTEM_DIR,
1696
+ format: 'json',
1697
+ tag: 'Test',
1698
+ astParseMode: 'lenient',
1699
+ });
1700
+ expect(result.rules).toHaveLength(1);
1701
+ expect(JSON.parse(result.output).pass).toBe(true);
1702
+ // The disclosure survives the exit-code escape: lenient covers the failure,
1703
+ // never the accounting.
1704
+ const messages = stderrSpy.mock.calls.map((c) => c.join(' '));
1705
+ const warning = messages.find((m) => m.includes('target-mismatched'));
1706
+ expect(warning).toBeDefined();
1707
+ expect(warning).toContain('pack-language-rule');
1708
+ expect(warning).toContain('.rs');
1709
+ expect(warning).toContain('totem sync --packs-only');
1710
+ }
1711
+ finally {
1712
+ stderrSpy.mockRestore();
1713
+ }
1714
+ });
1715
+ it('strict mode is the default — an omitted astParseMode still hard-errors', async () => {
1716
+ writeRules(tmpDir, [mismatchedAstGrepRule({ fileGlobs: ['**/*.rs'] })]);
1717
+ await expect(runCompiledRules({
1718
+ diff: cleanDiff(),
1719
+ cwd: tmpDir,
1720
+ totemDir: TOTEM_DIR,
1721
+ format: 'text',
1722
+ tag: 'Test',
1723
+ })).rejects.toThrow(/enforcement disarmed/i);
1724
+ });
1725
+ // ── Negative controls ───────────────────────────────
1726
+ it('control: the same rule with status:archived loads clean — archiving is the offered remedy', async () => {
1727
+ // Closes the loop on the offered fix: loadCompiledRules drops inert rules
1728
+ // before the guard ever sees them, so following the hint actually works.
1729
+ writeRules(tmpDir, [mismatchedAstGrepRule({ status: 'archived' })]);
1730
+ const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
1731
+ try {
1732
+ const result = await runCompiledRules({
1733
+ diff: cleanDiff(),
1734
+ cwd: tmpDir,
1735
+ totemDir: TOTEM_DIR,
1736
+ format: 'text',
1737
+ tag: 'Test',
1738
+ });
1739
+ expect(result.rules).toHaveLength(0);
1740
+ expect(result.violations).toHaveLength(0);
1741
+ }
1742
+ finally {
1743
+ stderrSpy.mockRestore();
1744
+ }
1745
+ });
1746
+ it('control: a globless ast-grep rule loads clean — the documented Lang.Tsx fallback stays permitted', async () => {
1747
+ // Unscoped pre-1.16 rules legitimately carry no fileGlobs, and
1748
+ // rule-engine.ts only throws for a rule with non-empty globs, so a globless
1749
+ // rule cannot arm the landmine.
1750
+ writeRules(tmpDir, [
1751
+ makeRule('', 'No foo log', 'No foo log', {
1752
+ engine: 'ast-grep',
1753
+ astGrepPattern: 'console.log("foo")',
1754
+ }),
1755
+ ]);
1756
+ const result = await runCompiledRules({
1757
+ diff: cleanDiff(),
1758
+ cwd: tmpDir,
1759
+ totemDir: TOTEM_DIR,
1760
+ format: 'json',
1761
+ tag: 'Test',
1762
+ });
1763
+ expect(result.rules).toHaveLength(1);
1764
+ expect(JSON.parse(result.output).pass).toBe(true);
1765
+ });
1766
+ it('control: one resolvable glob among unresolvable ones loads clean', async () => {
1767
+ // The rule CAN execute — the condemnation requires that not one positive
1768
+ // glob resolves.
1769
+ writeRules(tmpDir, [mismatchedAstGrepRule({ fileGlobs: ['**/*.json', '**/*.ts'] })]);
1770
+ const result = await runCompiledRules({
1771
+ diff: cleanDiff(),
1772
+ cwd: tmpDir,
1773
+ totemDir: TOTEM_DIR,
1774
+ format: 'json',
1775
+ tag: 'Test',
1776
+ });
1777
+ expect(result.rules).toHaveLength(1);
1778
+ expect(JSON.parse(result.output).pass).toBe(true);
1779
+ });
1780
+ it('control: extensionless positive globs (src/**) load clean — absence of evidence is not proof of mismatch', async () => {
1781
+ // TRAILING_EXT_RE extracts nothing from `src/**` or `**/*.{ts,json}`, but at
1782
+ // run time both match .ts files and dispatch fine. Condemning them would
1783
+ // hard-error legitimately broad rules.
1784
+ writeRules(tmpDir, [
1785
+ mismatchedAstGrepRule({ lessonHash: 'broad-one', fileGlobs: ['src/**'] }),
1786
+ mismatchedAstGrepRule({ lessonHash: 'broad-two', fileGlobs: ['**/*.{ts,json}'] }),
1787
+ mismatchedAstGrepRule({ lessonHash: 'broad-three', fileGlobs: ['packages/**/*'] }),
1788
+ ]);
1789
+ const result = await runCompiledRules({
1790
+ diff: cleanDiff(),
1791
+ cwd: tmpDir,
1792
+ totemDir: TOTEM_DIR,
1793
+ format: 'json',
1794
+ tag: 'Test',
1795
+ });
1796
+ expect(result.rules).toHaveLength(3);
1797
+ expect(JSON.parse(result.output).pass).toBe(true);
1798
+ });
1799
+ // ── .mts/.cts registration (mmnto-ai/totem#2513) ────
1800
+ it('control: an ast-grep rule scoped entirely to .mts/.cts loads clean — both are registered built-ins', async () => {
1801
+ // Before #2513 registered them, `.mts`/`.cts` were TOTAL mismatches and this
1802
+ // guard condemned an all-.mts rule at load. They resolve to the typescript
1803
+ // grammar now, so the guard must let them through.
1804
+ writeRules(tmpDir, [
1805
+ mismatchedAstGrepRule({ lessonHash: 'mts-only', fileGlobs: ['**/*.mts'] }),
1806
+ mismatchedAstGrepRule({ lessonHash: 'cts-only', fileGlobs: ['**/*.cts'] }),
1807
+ ]);
1808
+ const result = await runCompiledRules({
1809
+ diff: cleanDiff(),
1810
+ cwd: tmpDir,
1811
+ totemDir: TOTEM_DIR,
1812
+ format: 'json',
1813
+ tag: 'Test',
1814
+ });
1815
+ expect(result.rules).toHaveLength(2);
1816
+ expect(JSON.parse(result.output).pass).toBe(true);
1817
+ });
1818
+ it('still condemns a rule scoped entirely to a dead dummy extension — registration did not soften the guard', async () => {
1819
+ // The partial-permit semantics this guard ships with are unchanged by
1820
+ // #2513: an extension NO language covers is still a load-time hard error.
1821
+ writeRules(tmpDir, [
1822
+ mismatchedAstGrepRule({ lessonHash: 'dead-ext-only', fileGlobs: ['**/*.zzznotalang'] }),
1823
+ ]);
1824
+ let message = '';
1825
+ try {
1826
+ await runCompiledRules({
1827
+ diff: cleanDiff(),
1828
+ cwd: tmpDir,
1829
+ totemDir: TOTEM_DIR,
1830
+ format: 'text',
1831
+ tag: 'Test',
1832
+ });
1833
+ }
1834
+ catch (err) {
1835
+ message = err instanceof Error ? err.message : String(err);
1836
+ }
1837
+ expect(message).toMatch(/enforcement disarmed/i);
1838
+ expect(message).toContain('dead-ext-only');
1839
+ expect(message).toContain('.zzznotalang');
1840
+ });
1841
+ it('control: a REGEX rule scoped entirely to .json is untouched — the guard is AST-only', async () => {
1842
+ // Regex rules never dispatch through the Tree-sitter language registry, so
1843
+ // .json scoping is completely legitimate for them.
1844
+ writeRules(tmpDir, [
1845
+ makeRule('"apiKey"', 'No inline keys', 'No inline keys', {
1846
+ fileGlobs: ['**/.mcp.json'],
1847
+ }),
1848
+ ]);
1849
+ const result = await runCompiledRules({
1850
+ diff: cleanDiff(),
1851
+ cwd: tmpDir,
1852
+ totemDir: TOTEM_DIR,
1853
+ format: 'json',
1854
+ tag: 'Test',
1855
+ });
1856
+ expect(result.rules).toHaveLength(1);
1857
+ expect(JSON.parse(result.output).pass).toBe(true);
1858
+ });
1859
+ it('control: an all-negation glob list loads clean — it matches every file, including resolvable ones', async () => {
1860
+ writeRules(tmpDir, [mismatchedAstGrepRule({ fileGlobs: ['!**/*.json'] })]);
1861
+ const result = await runCompiledRules({
1862
+ diff: cleanDiff(),
1863
+ cwd: tmpDir,
1864
+ totemDir: TOTEM_DIR,
1865
+ format: 'json',
1866
+ tag: 'Test',
1867
+ });
1868
+ expect(result.rules).toHaveLength(1);
1869
+ expect(JSON.parse(result.output).pass).toBe(true);
1870
+ });
1871
+ // ── The real corpus ─────────────────────────────────
1872
+ it("the repository's own .totem/compiled-rules.json loads clean through the guard", async () => {
1873
+ // Regression anchor for the corpus itself: both known specimens are archived,
1874
+ // so the live manifest must pass. Copied into the tmp dir so the run's metric
1875
+ // and ledger writes never touch the real .totem.
1876
+ const realManifest = fileURLToPath(new URL('../../../../.totem/compiled-rules.json', import.meta.url));
1877
+ // A missing fixture would make every assertion below vacuous — fail loudly
1878
+ // instead of silently skipping.
1879
+ expect(fs.existsSync(realManifest)).toBe(true);
1880
+ fs.copyFileSync(realManifest, path.join(tmpDir, TOTEM_DIR, 'compiled-rules.json'));
1881
+ // A .txt path no compiled rule scopes to, so the run exercises rule LOAD
1882
+ // (where the guard lives) without dispatching AST rules against fixtures
1883
+ // that do not exist in the tmp dir.
1884
+ const result = await runCompiledRules({
1885
+ diff: makeDiff('docs/example.txt', 'A plain note.'),
1886
+ cwd: tmpDir,
1887
+ totemDir: TOTEM_DIR,
1888
+ format: 'json',
1889
+ tag: 'Test',
1890
+ });
1891
+ // Non-vacuity: the guard actually had globbed AST rules to inspect.
1892
+ const globbedAstRules = result.rules.filter((r) => (r.engine === 'ast-grep' || r.engine === 'ast') && r.fileGlobs && r.fileGlobs.length > 0);
1893
+ expect(globbedAstRules.length).toBeGreaterThan(0);
1894
+ });
1895
+ });
1357
1896
  //# sourceMappingURL=run-compiled-rules.test.js.map