@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.
- package/dist/commands/docs.test.js +1 -0
- package/dist/commands/docs.test.js.map +1 -1
- package/dist/commands/lint-staleness.d.ts +4 -2
- package/dist/commands/lint-staleness.d.ts.map +1 -1
- package/dist/commands/lint-staleness.js +4 -2
- package/dist/commands/lint-staleness.js.map +1 -1
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +38 -1
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/lint.test.js +72 -0
- package/dist/commands/lint.test.js.map +1 -1
- package/dist/commands/mail-degraded-e4.test.d.ts +43 -0
- package/dist/commands/mail-degraded-e4.test.d.ts.map +1 -0
- package/dist/commands/mail-degraded-e4.test.js +317 -0
- package/dist/commands/mail-degraded-e4.test.js.map +1 -0
- package/dist/commands/run-compiled-rules.d.ts +11 -1
- package/dist/commands/run-compiled-rules.d.ts.map +1 -1
- package/dist/commands/run-compiled-rules.js +271 -14
- package/dist/commands/run-compiled-rules.js.map +1 -1
- package/dist/commands/run-compiled-rules.test.js +540 -1
- package/dist/commands/run-compiled-rules.test.js.map +1 -1
- package/dist/commands/shield-learn.test.js +1 -0
- package/dist/commands/shield-learn.test.js.map +1 -1
- package/dist/utils/pilot.test.js +1 -0
- package/dist/utils/pilot.test.js.map +1 -1
- package/dist/utils.test.js +1 -0
- package/dist/utils.test.js.map +1 -1
- package/package.json +2 -2
|
@@ -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: ['**/*.
|
|
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
|