@mmnto/cli 1.17.0 → 1.17.1

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.
@@ -306,4 +306,574 @@ describe('shieldCommand --estimate incompatibility guard', () => {
306
306
  await expect(shieldCommand({ estimate: true, suppress: [] })).resolves.toBeUndefined();
307
307
  });
308
308
  });
309
+ // ─── Pattern-history overlay (mmnto-ai/totem#1731) ────────
310
+ // Helpers shared across the overlay tests. The substrate's `tokenizeForJaccard`
311
+ // drops stopwords + tokens of length ≤ 2 (see
312
+ // `packages/core/src/recurrence-stats.ts:STOPWORDS`), so fixture tokens are
313
+ // chosen 4+ chars and outside that stoplist.
314
+ function writeSubstrate(tmpDir, payload, totemDirName = '.totem', fileName = 'recurrence-stats.json') {
315
+ const totemDir = path.join(tmpDir, totemDirName);
316
+ fs.mkdirSync(totemDir, { recursive: true });
317
+ const filePath = path.join(totemDir, fileName);
318
+ fs.writeFileSync(filePath, typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2), 'utf-8');
319
+ return filePath;
320
+ }
321
+ function makeSubstratePayload(patterns) {
322
+ return {
323
+ version: 1,
324
+ lastUpdated: '2026-04-29T00:00:00.000Z',
325
+ thresholdApplied: 5,
326
+ historyDepth: 50,
327
+ prsScanned: ['1700', '1710'],
328
+ patterns: patterns.map((p) => ({
329
+ signature: p.signature,
330
+ tool: 'coderabbit',
331
+ severityBucket: 'medium',
332
+ occurrences: p.occurrences ?? 3,
333
+ prs: p.prs ?? ['1700', '1710'],
334
+ sampleBodies: p.sampleBodies,
335
+ firstSeen: '2026-04-01T00:00:00.000Z',
336
+ lastSeen: '2026-04-28T00:00:00.000Z',
337
+ paths: [],
338
+ coveredByRule: false,
339
+ })),
340
+ coveredPatterns: [],
341
+ };
342
+ }
343
+ function diffWithAdditions(addedLines) {
344
+ const header = 'diff --git a/foo.ts b/foo.ts\n--- a/foo.ts\n+++ b/foo.ts\n@@ -1 +1,2 @@\n existing\n';
345
+ return header + addedLines.map((l) => `+${l}`).join('\n') + '\n';
346
+ }
347
+ function infoLines() {
348
+ return mockLog.info.mock.calls.map((c) => String(c[1] ?? ''));
349
+ }
350
+ function dimLines() {
351
+ return mockLog.dim.mock.calls.map((c) => String(c[1] ?? ''));
352
+ }
353
+ function warnLines() {
354
+ return mockLog.warn.mock.calls.map((c) => String(c[1] ?? ''));
355
+ }
356
+ describe('runEstimate — pattern-history overlay', () => {
357
+ let tmpDir;
358
+ let originalCwd;
359
+ beforeEach(() => {
360
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-history-'));
361
+ originalCwd = process.cwd();
362
+ mockGetDiffForReview.mockReset();
363
+ mockRunCompiledRules.mockReset();
364
+ mockRunCompiledRules.mockResolvedValue(runCompiledRulesPassResult());
365
+ for (const fn of Object.values(mockLog)) {
366
+ fn.mockReset();
367
+ }
368
+ });
369
+ afterEach(() => {
370
+ process.chdir(originalCwd);
371
+ cleanTmpDir(tmpDir);
372
+ });
373
+ // ─── Default-on / opt-out semantics ─────────────────
374
+ it('skips the overlay entirely when options.history === false (--no-history)', async () => {
375
+ writeSubstrate(tmpDir, makeSubstratePayload([
376
+ {
377
+ signature: 'sig-aaaa',
378
+ sampleBodies: ['avoid using async-storage in render-path components'],
379
+ },
380
+ ]));
381
+ mockGetDiffForReview.mockResolvedValue(diffResult({
382
+ diff: diffWithAdditions(['avoid async-storage render-path components']),
383
+ }));
384
+ const { runEstimate } = await import('./shield-estimate.js');
385
+ await runEstimate({ estimate: true, history: false }, makeConfig(), tmpDir, tmpDir);
386
+ const all = [...infoLines(), ...dimLines(), ...warnLines()];
387
+ expect(all.some((l) => /Pattern-history/i.test(l))).toBe(false);
388
+ });
389
+ it('runs the overlay by default when options.history is undefined', async () => {
390
+ writeSubstrate(tmpDir, makeSubstratePayload([
391
+ {
392
+ signature: 'sig-bbbb',
393
+ sampleBodies: ['avoid using async-storage in render-path components'],
394
+ },
395
+ ]));
396
+ mockGetDiffForReview.mockResolvedValue(diffResult({
397
+ diff: diffWithAdditions(['avoid async-storage render-path components everywhere']),
398
+ }));
399
+ const { runEstimate } = await import('./shield-estimate.js');
400
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
401
+ const all = [...infoLines(), ...dimLines(), ...warnLines()];
402
+ expect(all.some((l) => /Pattern-history/i.test(l))).toBe(true);
403
+ });
404
+ // ─── Substrate-missing / malformed degradation ──────
405
+ it('emits a single dim hint when recurrence-stats.json is missing', async () => {
406
+ mockGetDiffForReview.mockResolvedValue(diffResult());
407
+ const { runEstimate } = await import('./shield-estimate.js');
408
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
409
+ const hints = dimLines().filter((l) => /Pattern-history layer skipped/.test(l));
410
+ expect(hints).toHaveLength(1);
411
+ expect(hints[0]).toMatch(/totem stats --pattern-recurrence/);
412
+ // Warn surface is untouched by the missing-substrate path.
413
+ expect(warnLines()).toHaveLength(0);
414
+ });
415
+ it('emits a single warn line when recurrence-stats.json is malformed JSON', async () => {
416
+ writeSubstrate(tmpDir, '{ "not": valid json'); // garbage
417
+ mockGetDiffForReview.mockResolvedValue(diffResult());
418
+ const { runEstimate } = await import('./shield-estimate.js');
419
+ await expect(runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir)).resolves.toBeUndefined();
420
+ const warns = warnLines().filter((l) => /Pattern-history layer skipped/.test(l));
421
+ expect(warns).toHaveLength(1);
422
+ });
423
+ it('emits a single warn line when recurrence-stats.json fails the schema projection', async () => {
424
+ // CR mmnto-ai/totem#1739 R4 nitpick: build a VALID substrate first,
425
+ // then corrupt only the projected field we want to lock. The prior
426
+ // shape `{ version: 1, patterns: [{ signature: 42 }] }` was malformed
427
+ // in multiple ways, so the test stayed green even if the projection
428
+ // stopped validating signature and rejected some other gap.
429
+ const payload = makeSubstratePayload([
430
+ {
431
+ signature: 'sig-will-be-corrupted',
432
+ sampleBodies: ['avoid using async-storage in render-path components'],
433
+ },
434
+ ]);
435
+ payload.patterns[0].signature = 42;
436
+ writeSubstrate(tmpDir, payload);
437
+ mockGetDiffForReview.mockResolvedValue(diffResult());
438
+ const { runEstimate } = await import('./shield-estimate.js');
439
+ await expect(runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir)).resolves.toBeUndefined();
440
+ const warns = warnLines().filter((l) => /Pattern-history layer skipped/.test(l));
441
+ expect(warns).toHaveLength(1);
442
+ });
443
+ // ─── CR mmnto-ai/totem#1739 R4 (Minor) — empty prs[] regression ─
444
+ it('skips patterns with empty prs[] and renders other patterns normally', async () => {
445
+ // Substrate-by-construction always emits ≥1 PR per cluster (see
446
+ // `runRecurrenceStats`), but the projection schema does not enforce
447
+ // `prs.min(1)`. A tampered substrate with `prs: []` would otherwise
448
+ // render as "in PRs (containment: 0.83)" — a no-signal stanza. The
449
+ // overlay skips such patterns at match time so the graceful-degrade
450
+ // contract holds without converting it into a parse failure.
451
+ writeSubstrate(tmpDir, makeSubstratePayload([
452
+ {
453
+ signature: 'sig-empty-prs-skipped',
454
+ prs: [],
455
+ sampleBodies: ['avoid using async-storage in render-path components'],
456
+ },
457
+ {
458
+ signature: 'sig-normal-rendered',
459
+ prs: ['1700', '1710'],
460
+ sampleBodies: ['avoid using async-storage in render-path components'],
461
+ },
462
+ ]));
463
+ mockGetDiffForReview.mockResolvedValue(diffResult({
464
+ diff: diffWithAdditions(['avoid async-storage render-path components']),
465
+ }));
466
+ const { runEstimate } = await import('./shield-estimate.js');
467
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
468
+ const lines = infoLines();
469
+ expect(lines.some((l) => /sig-empty-prs-skipped/.test(l))).toBe(false);
470
+ expect(lines.some((l) => /sig-normal-rendered/.test(l))).toBe(true);
471
+ });
472
+ // ─── Containment-coefficient asymmetry — the load-bearing test ──
473
+ it('uses asymmetric containment so a small pattern matches a much larger diff', async () => {
474
+ // Pattern has 5 unique significant tokens. Diff contains all 5 PLUS
475
+ // ~500 unrelated tokens. Containment is 5/5 = 1.0; whole-diff
476
+ // Jaccard would be 5 / (5 + 500) ≈ 0.01 — well below 0.4.
477
+ const patternBody = 'foobar quuxify slartib mariocart wibblewobble';
478
+ const filler = Array.from({ length: 500 }, (_, i) => `unrelated${i}token`).join(' ');
479
+ writeSubstrate(tmpDir, makeSubstratePayload([{ signature: 'sig-asym', sampleBodies: [patternBody] }]));
480
+ mockGetDiffForReview.mockResolvedValue(diffResult({ diff: diffWithAdditions([`${patternBody} ${filler}`]) }));
481
+ const { runEstimate } = await import('./shield-estimate.js');
482
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
483
+ const lines = infoLines();
484
+ // The containment line for our match must surface with 1.00.
485
+ expect(lines.some((l) => /sig-asym/.test(l) && /containment: 1\.00/.test(l))).toBe(true);
486
+ // Sanity: the same fixture fed through Jaccard would NOT have rendered
487
+ // a match (Jaccard ≈ 5/505 ≈ 0.0099 < 0.4). We assert the contrapositive
488
+ // by computing Jaccard via the substrate helper and asserting < 0.05.
489
+ const { jaccard, tokenizeForJaccard } = await import('@mmnto/totem');
490
+ const j = jaccard(tokenizeForJaccard(patternBody), tokenizeForJaccard(`${patternBody} ${filler}`));
491
+ expect(j).toBeLessThan(0.05);
492
+ });
493
+ // ─── Match-rendering details ────────────────────────
494
+ it('renders the section header with blank separator lines and a per-match block', async () => {
495
+ writeSubstrate(tmpDir, makeSubstratePayload([
496
+ {
497
+ signature: 'sig-cccc',
498
+ occurrences: 7,
499
+ prs: ['1700', '1710', '1720'],
500
+ sampleBodies: ['avoid using async-storage in render-path components consistently'],
501
+ },
502
+ ]));
503
+ mockGetDiffForReview.mockResolvedValue(diffResult({
504
+ diff: diffWithAdditions(['avoid async-storage render-path components consistently']),
505
+ }));
506
+ const { runEstimate } = await import('./shield-estimate.js');
507
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
508
+ const lines = infoLines();
509
+ // Section header + summary line both present, both under [Estimate].
510
+ expect(lines).toContain('─── Pattern-history layer ───');
511
+ expect(lines.some((l) => /1 historical pattern\(s\) match this diff \(uncovered/.test(l))).toBe(true);
512
+ // Blank-line separators (Q4 spec).
513
+ expect(lines.filter((l) => l === '').length).toBeGreaterThanOrEqual(2);
514
+ // PR list rendered with hash prefixes.
515
+ expect(lines.some((l) => /sig-cccc/.test(l) && /#1700, #1710, #1720/.test(l))).toBe(true);
516
+ // Tag is Estimate on every overlay info line.
517
+ for (const call of mockLog.info.mock.calls) {
518
+ expect(call[0]).toBe('Estimate');
519
+ }
520
+ });
521
+ it('truncates the rendered sample body to 120 chars with internal whitespace collapsed', async () => {
522
+ const longBody = ' foobar multi-line sample body '.repeat(20) + 'quuxify slartib trailingstuff';
523
+ writeSubstrate(tmpDir, makeSubstratePayload([{ signature: 'sig-trunc', sampleBodies: [longBody] }]));
524
+ mockGetDiffForReview.mockResolvedValue(diffResult({
525
+ diff: diffWithAdditions(['foobar multi-line sample body quuxify slartib trailingstuff']),
526
+ }));
527
+ const { runEstimate } = await import('./shield-estimate.js');
528
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
529
+ const sampleLine = infoLines().find((l) => /^ {4}".*"$/.test(l));
530
+ expect(sampleLine).toBeDefined();
531
+ const inner = sampleLine.replace(/^ {4}"|"$/g, '');
532
+ // Collapsed internal whitespace — no double spaces.
533
+ expect(inner).not.toMatch(/ {2,}/);
534
+ // 120 chars + ellipsis.
535
+ expect(inner.length).toBeLessThanOrEqual(121); // 120 + the ellipsis
536
+ expect(inner.endsWith('…')).toBe(true);
537
+ });
538
+ it('emits a single dim "0 matches" line when no pattern clears the threshold', async () => {
539
+ writeSubstrate(tmpDir, makeSubstratePayload([
540
+ {
541
+ signature: 'sig-disjoint',
542
+ sampleBodies: ['totally unrelated patternwords nowherenear thedifff'],
543
+ },
544
+ ]));
545
+ mockGetDiffForReview.mockResolvedValue(diffResult({ diff: diffWithAdditions(['something completely orthogonal happening here']) }));
546
+ const { runEstimate } = await import('./shield-estimate.js');
547
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
548
+ const dims = dimLines().filter((l) => /Pattern-history layer: 0 matches/.test(l));
549
+ expect(dims).toHaveLength(1);
550
+ expect(dims[0]).toMatch(/0\.4/);
551
+ });
552
+ // ─── Substrate row hygiene ──────────────────────────
553
+ it('skips patterns whose sampleBodies array is empty', async () => {
554
+ writeSubstrate(tmpDir, makeSubstratePayload([
555
+ // Pattern A — empty bodies; must NOT render.
556
+ { signature: 'sig-empty', sampleBodies: [] },
557
+ // Pattern B — full match; must render.
558
+ {
559
+ signature: 'sig-keeper',
560
+ sampleBodies: ['avoid using async-storage in render-path components'],
561
+ },
562
+ ]));
563
+ mockGetDiffForReview.mockResolvedValue(diffResult({ diff: diffWithAdditions(['avoid async-storage render-path components']) }));
564
+ const { runEstimate } = await import('./shield-estimate.js');
565
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
566
+ const lines = infoLines();
567
+ expect(lines.some((l) => /sig-empty/.test(l))).toBe(false);
568
+ expect(lines.some((l) => /sig-keeper/.test(l))).toBe(true);
569
+ });
570
+ it('skips patterns whose sampleBodies tokenize to an empty significant set', async () => {
571
+ // Substrate `tokenizeForJaccard` drops stopwords + ≤2-char tokens.
572
+ // A body of "the a is to of" yields zero significant tokens —
573
+ // containment is structurally undefined, so we skip.
574
+ writeSubstrate(tmpDir, makeSubstratePayload([
575
+ // Pattern A — pure stopwords; must NOT render.
576
+ { signature: 'sig-stopwords', sampleBodies: ['the a is to of and or'] },
577
+ // Pattern B — full match; must render.
578
+ {
579
+ signature: 'sig-realsignal',
580
+ sampleBodies: ['avoid using async-storage in render-path components'],
581
+ },
582
+ ]));
583
+ mockGetDiffForReview.mockResolvedValue(diffResult({ diff: diffWithAdditions(['avoid async-storage render-path components']) }));
584
+ const { runEstimate } = await import('./shield-estimate.js');
585
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
586
+ const lines = infoLines();
587
+ expect(lines.some((l) => /sig-stopwords/.test(l))).toBe(false);
588
+ expect(lines.some((l) => /sig-realsignal/.test(l))).toBe(true);
589
+ });
590
+ it('does NOT touch coveredPatterns[] — only patterns[] are matched', async () => {
591
+ const payload = {
592
+ version: 1,
593
+ lastUpdated: '2026-04-29T00:00:00.000Z',
594
+ thresholdApplied: 5,
595
+ historyDepth: 50,
596
+ prsScanned: ['1700'],
597
+ patterns: [
598
+ // Uncovered — matchable.
599
+ {
600
+ signature: 'sig-uncovered',
601
+ tool: 'coderabbit',
602
+ severityBucket: 'medium',
603
+ occurrences: 3,
604
+ prs: ['1700'],
605
+ sampleBodies: ['avoid using async-storage in render-path components'],
606
+ firstSeen: '2026-04-01T00:00:00.000Z',
607
+ lastSeen: '2026-04-28T00:00:00.000Z',
608
+ paths: [],
609
+ coveredByRule: false,
610
+ },
611
+ ],
612
+ coveredPatterns: [
613
+ // Already covered — must NOT render even though body matches.
614
+ {
615
+ signature: 'sig-covered',
616
+ tool: 'coderabbit',
617
+ severityBucket: 'medium',
618
+ occurrences: 4,
619
+ prs: ['1701'],
620
+ sampleBodies: ['avoid using async-storage in render-path components'],
621
+ firstSeen: '2026-04-01T00:00:00.000Z',
622
+ lastSeen: '2026-04-28T00:00:00.000Z',
623
+ paths: [],
624
+ coveredByRule: true,
625
+ },
626
+ ],
627
+ };
628
+ writeSubstrate(tmpDir, payload);
629
+ mockGetDiffForReview.mockResolvedValue(diffResult({ diff: diffWithAdditions(['avoid async-storage render-path components']) }));
630
+ const { runEstimate } = await import('./shield-estimate.js');
631
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
632
+ const lines = infoLines();
633
+ expect(lines.some((l) => /sig-uncovered/.test(l))).toBe(true);
634
+ expect(lines.some((l) => /sig-covered/.test(l))).toBe(false);
635
+ });
636
+ // ─── Diff-tokenization edge cases ───────────────────
637
+ it('does not let `+++ b/file.ts` headers poison the diff token pool', async () => {
638
+ // The pattern's only-significant token is the file basename. If the
639
+ // overlay tokenized the `+++ b/foo.ts` header, this would match —
640
+ // it must NOT.
641
+ writeSubstrate(tmpDir, makeSubstratePayload([{ signature: 'sig-poison', sampleBodies: ['poisonword'] }]));
642
+ // Diff with no real additions — only the +++ file header.
643
+ const diffNoAdditions = 'diff --git a/poisonword.ts b/poisonword.ts\n' +
644
+ '--- a/poisonword.ts\n' +
645
+ '+++ b/poisonword.ts\n' +
646
+ '@@ -1 +1 @@\n' +
647
+ ' unchanged\n';
648
+ mockGetDiffForReview.mockResolvedValue(diffResult({ diff: diffNoAdditions }));
649
+ const { runEstimate } = await import('./shield-estimate.js');
650
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
651
+ const lines = infoLines();
652
+ expect(lines.some((l) => /sig-poison/.test(l))).toBe(false);
653
+ });
654
+ it('preserves added lines starting with `++` (the header guard requires a trailing space)', async () => {
655
+ // Pre-push Sonnet pickup on CR R4: `line.startsWith('+++')` would
656
+ // misclassify `+++increment;` (a real addition of `++increment;`) as
657
+ // a file header. The guard now requires `'+++ '` with trailing space,
658
+ // which is the actual unified-diff format for `+++ <path>` headers.
659
+ writeSubstrate(tmpDir, makeSubstratePayload([{ signature: 'sig-plusplus', sampleBodies: ['increment counter'] }]));
660
+ // Real added line of `++increment;` is rendered as `+++increment;` in
661
+ // the unified diff. The header `+++ b/foo.ts` (with space) must still
662
+ // be filtered.
663
+ const diffWithPlusPlus = 'diff --git a/foo.ts b/foo.ts\n' +
664
+ '--- a/foo.ts\n' +
665
+ '+++ b/foo.ts\n' +
666
+ '@@ -1 +1,2 @@\n' +
667
+ ' existing\n' +
668
+ '+++increment counter\n';
669
+ mockGetDiffForReview.mockResolvedValue(diffResult({ diff: diffWithPlusPlus }));
670
+ const { runEstimate } = await import('./shield-estimate.js');
671
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
672
+ const lines = infoLines();
673
+ // Pattern matches because `increment` and `counter` survived tokenization.
674
+ expect(lines.some((l) => /sig-plusplus/.test(l))).toBe(true);
675
+ });
676
+ it('orders matches by containment desc, then signature asc, deterministically', async () => {
677
+ writeSubstrate(tmpDir, makeSubstratePayload([
678
+ // 100% containment (3/3): aaa-pattern.
679
+ {
680
+ signature: 'aaa-pattern',
681
+ sampleBodies: ['lattermost foobaz quuxquux'],
682
+ },
683
+ // 100% containment (3/3): bbb-pattern (same containment as aaa, lex-sorted).
684
+ {
685
+ signature: 'bbb-pattern',
686
+ sampleBodies: ['lattermost foobaz quuxquux'],
687
+ },
688
+ // ~50% containment: ccc-pattern.
689
+ {
690
+ signature: 'ccc-pattern',
691
+ sampleBodies: ['lattermost foobaz quuxquux missing-token-here'],
692
+ },
693
+ ]));
694
+ mockGetDiffForReview.mockResolvedValue(diffResult({ diff: diffWithAdditions(['lattermost foobaz quuxquux']) }));
695
+ const { runEstimate } = await import('./shield-estimate.js');
696
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
697
+ const lines = infoLines();
698
+ const aaaIdx = lines.findIndex((l) => /aaa-pattern/.test(l));
699
+ const bbbIdx = lines.findIndex((l) => /bbb-pattern/.test(l));
700
+ const cccIdx = lines.findIndex((l) => /ccc-pattern/.test(l));
701
+ expect(aaaIdx).toBeGreaterThan(-1);
702
+ expect(bbbIdx).toBeGreaterThan(-1);
703
+ expect(cccIdx).toBeGreaterThan(-1);
704
+ // aaa before bbb (lex tiebreak at same containment), bbb before ccc (higher containment).
705
+ expect(aaaIdx).toBeLessThan(bbbIdx);
706
+ expect(bbbIdx).toBeLessThan(cccIdx);
707
+ });
708
+ // ─── No-LLM defense-in-depth (overlay is a static-source-grep target) ──
709
+ it('overlay source has no LLM imports — extends the no-LLM static-source-grep', async () => {
710
+ const estimatePath = path.join(__dirname, 'shield-estimate.ts');
711
+ const source = fs.readFileSync(estimatePath, 'utf-8');
712
+ // Forbidden import paths and symbols specific to the LLM Verification
713
+ // Layer. Mirrors the mmnto-ai/totem#1714 + #1713 patterns.
714
+ expect(source).not.toMatch(/from ['"]@mmnto\/totem-orchestrator['"]/);
715
+ expect(source).not.toMatch(/getOrchestrator/);
716
+ expect(source).not.toMatch(/\bAnthropic\b/);
717
+ expect(source).not.toMatch(/\bOpenAI\b/);
718
+ expect(source).not.toMatch(/\bgemini\b/i);
719
+ // The mmnto-ai/totem#1714 grep already blocks runOrchestrator /
720
+ // requireEmbedding / LanceStore / createEmbedder / `from '../utils.js'`
721
+ // — re-asserting here keeps the overlay's guard intact under future
722
+ // refactors.
723
+ expect(source).not.toMatch(/runOrchestrator/);
724
+ expect(source).not.toMatch(/requireEmbedding/);
725
+ expect(source).not.toMatch(/LanceStore/);
726
+ expect(source).not.toMatch(/createEmbedder/);
727
+ });
728
+ // CR mmnto-ai/totem#1739 R3 (Major): symbol-only grep can't catch
729
+ // a TRANSITIVE-load regression where the overlay starts importing
730
+ // a benign-looking module that itself pulls the orchestrator graph.
731
+ // Specifically: `cli/src/utils.ts` has a static value-import of
732
+ // `./orchestrators/orchestrator.js`, so any future drift to
733
+ // `await import('../utils.js')` in shield-estimate would silently
734
+ // re-introduce the orchestrator graph onto the estimate path.
735
+ // Module-path bans + a dynamic-import allowlist close that gap.
736
+ it('does not statically or dynamically import any orchestrator-loading module path', async () => {
737
+ const estimatePath = path.join(__dirname, 'shield-estimate.ts');
738
+ const source = fs.readFileSync(estimatePath, 'utf-8');
739
+ // Direct orchestrator imports (static or dynamic).
740
+ expect(source).not.toMatch(/['"]\.\.\/orchestrators\//);
741
+ // Transitive load via utils.ts (which statically imports the orchestrator
742
+ // graph at utils.ts:18). The overlay must route sanitizeForTerminal
743
+ // through `terminal-sanitize.js` instead.
744
+ expect(source).not.toMatch(/['"]\.\.\/utils\.js['"]/);
745
+ // Dynamic-import allowlist — every `await import(...)` in shield-estimate
746
+ // resolves to one of these. Mirrors the retrospect.test.ts:524 guard.
747
+ const allowedDynamicImports = [
748
+ "await import('node:fs')",
749
+ "await import('node:path')",
750
+ "await import('zod')",
751
+ "await import('../ui.js')",
752
+ "await import('../git.js')",
753
+ "await import('./run-compiled-rules.js')",
754
+ "await import('@mmnto/totem')",
755
+ "await import('../terminal-sanitize.js')",
756
+ ];
757
+ let residual = source;
758
+ for (const expected of allowedDynamicImports) {
759
+ // totem-context: stripping allowed dynamic-import substrings to assert no stray imports remain — empty separator is the correct semantics for the source-grep guard.
760
+ residual = residual.split(expected).join('');
761
+ }
762
+ expect(residual).not.toMatch(/await import\(['"][^'"]+['"]\)/);
763
+ });
764
+ // ─── CR mmnto-ai/totem#1739 R1 (Major) — configRoot path resolution ─
765
+ it('resolves the substrate path relative to configRoot, not cwd', async () => {
766
+ // Substrate written at configRoot (project root). cwd is a nested
767
+ // working dir with no `.totem/` of its own. Pre-fix the overlay would
768
+ // probe `<nestedCwd>/.totem/recurrence-stats.json`, miss, and emit
769
+ // the "skipped" hint — disabling the overlay despite the substrate
770
+ // existing at configRoot. Post-fix the overlay resolves against
771
+ // configRoot and finds the substrate.
772
+ const nestedCwd = path.join(tmpDir, 'nested', 'subdir');
773
+ fs.mkdirSync(nestedCwd, { recursive: true });
774
+ writeSubstrate(tmpDir, makeSubstratePayload([
775
+ {
776
+ signature: 'sig-rooted-at-configroot',
777
+ sampleBodies: ['avoid using async-storage in render-path components'],
778
+ },
779
+ ]));
780
+ mockGetDiffForReview.mockResolvedValue(diffResult({
781
+ diff: diffWithAdditions(['avoid async-storage render-path components']),
782
+ }));
783
+ const { runEstimate } = await import('./shield-estimate.js');
784
+ // cwd = nested working dir (substrate-empty), configRoot = project root
785
+ // (substrate lives here).
786
+ await runEstimate({ estimate: true }, makeConfig(), nestedCwd, tmpDir);
787
+ const all = [...infoLines(), ...dimLines()];
788
+ expect(all.some((l) => /sig-rooted-at-configroot/.test(l))).toBe(true);
789
+ // Skipped-hint MUST NOT fire — the substrate was found via configRoot.
790
+ expect(all.some((l) => /Pattern-history layer skipped/.test(l))).toBe(false);
791
+ });
792
+ // ─── CR mmnto-ai/totem#1739 R1 (Major) — terminal-injection defense ─
793
+ it('sanitizes ANSI/control bytes in substrate-derived signature, PR list, and sample body', async () => {
794
+ // A tampered substrate could plant CSI sequences (`\x1b[...]`) that
795
+ // spoof cursor moves or color resets when rendered to stderr. Closes
796
+ // the same class CR caught on `retrospect.ts` PR mmnto-ai/totem#1734.
797
+ const ansi = '\x1b[31mRED\x1b[0m';
798
+ const ctrlC0 = '\x07'; // BEL — a C0 control byte
799
+ const ctrlC1 = '\x9b'; // CSI 8-bit equivalent — a C1 control byte
800
+ writeSubstrate(tmpDir, makeSubstratePayload([
801
+ {
802
+ signature: `sig-${ansi}-aaaa`,
803
+ prs: [`1700${ansi}`, `${ctrlC0}1710`],
804
+ // totem-context: adjacent ${ctrlC0}${ctrlC1} placeholders are an intentional fixture — both C0 (BEL \x07) and C1 (CSI 8-bit \x9b) bytes need to land in the rendered output so the sanitizer test asserts both are stripped. The disjoint-concat rule targets `${a}${b}` token-fusing, not adjacent control-byte fixtures.
805
+ sampleBodies: [`avoid ${ansi} async-storage render-path ${ctrlC0}${ctrlC1} components`],
806
+ },
807
+ ]));
808
+ mockGetDiffForReview.mockResolvedValue(diffResult({
809
+ diff: diffWithAdditions(['avoid async-storage render-path components']),
810
+ }));
811
+ const { runEstimate } = await import('./shield-estimate.js');
812
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
813
+ // No ESC, BEL (C0), or CSI-8bit (C1) bytes survive sanitization.
814
+ const all = [...infoLines(), ...dimLines(), ...warnLines()];
815
+ for (const line of all) {
816
+ expect(line).not.toContain('\x1b');
817
+ expect(line).not.toContain('\x07');
818
+ expect(line).not.toContain('\x9b');
819
+ }
820
+ // The pattern still rendered — sanitization stripped the unsafe bytes,
821
+ // not the surrounding text.
822
+ expect(all.some((l) => /sig-.*-aaaa/.test(l))).toBe(true);
823
+ });
824
+ });
825
+ // ─── Runtime orchestrator spy guard (mirrors retrospect.test.ts:546) ──
826
+ //
827
+ // Static-source inspection alone can be fooled by a transitive import.
828
+ // Mock the orchestrator factory module at runtime; if anything in the
829
+ // estimate-overlay import chain reaches it, the spy fires. We require
830
+ // zero invocations.
831
+ const orchestratorSpy = vi.fn();
832
+ vi.mock('../orchestrators/orchestrator.js', () => ({
833
+ createOrchestrator: (...args) => {
834
+ orchestratorSpy(...args);
835
+ return () => {
836
+ throw new Error('createOrchestrator must NEVER be called from runEstimate');
837
+ };
838
+ },
839
+ resolveOrchestrator: (...args) => {
840
+ orchestratorSpy(...args);
841
+ throw new Error('resolveOrchestrator must NEVER be called from runEstimate');
842
+ },
843
+ }));
844
+ describe('runEstimate — runtime orchestrator spy', () => {
845
+ let tmpDir;
846
+ let originalCwd;
847
+ beforeEach(() => {
848
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-est-spy-'));
849
+ originalCwd = process.cwd();
850
+ orchestratorSpy.mockClear();
851
+ mockGetDiffForReview.mockReset();
852
+ mockRunCompiledRules.mockReset();
853
+ mockRunCompiledRules.mockResolvedValue(runCompiledRulesPassResult());
854
+ for (const fn of Object.values(mockLog)) {
855
+ fn.mockReset();
856
+ }
857
+ });
858
+ afterEach(() => {
859
+ process.chdir(originalCwd);
860
+ cleanTmpDir(tmpDir);
861
+ });
862
+ it('never invokes createOrchestrator or resolveOrchestrator across an end-to-end run with the overlay', async () => {
863
+ // Substrate present + matching pattern — exercises the full overlay
864
+ // path. This is the load-bearing assertion: even with the overlay
865
+ // active, the orchestrator spy stays at 0.
866
+ fs.mkdirSync(path.join(tmpDir, '.totem'), { recursive: true });
867
+ fs.writeFileSync(path.join(tmpDir, '.totem', 'recurrence-stats.json'), JSON.stringify(makeSubstratePayload([
868
+ {
869
+ signature: 'sig-spy',
870
+ sampleBodies: ['avoid using async-storage in render-path components'],
871
+ },
872
+ ]), null, 2), 'utf-8');
873
+ mockGetDiffForReview.mockResolvedValue(diffResult({ diff: diffWithAdditions(['avoid async-storage render-path components']) }));
874
+ const { runEstimate } = await import('./shield-estimate.js');
875
+ await runEstimate({ estimate: true }, makeConfig(), tmpDir, tmpDir);
876
+ expect(orchestratorSpy).toHaveBeenCalledTimes(0);
877
+ });
878
+ });
309
879
  //# sourceMappingURL=shield-estimate.test.js.map