@mmnto/cli 1.111.1 → 1.112.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/doctor-estate.d.ts +38 -0
- package/dist/commands/doctor-estate.d.ts.map +1 -0
- package/dist/commands/doctor-estate.js +234 -0
- package/dist/commands/doctor-estate.js.map +1 -0
- package/dist/commands/doctor-estate.test.d.ts +10 -0
- package/dist/commands/doctor-estate.test.d.ts.map +1 -0
- package/dist/commands/doctor-estate.test.js +459 -0
- package/dist/commands/doctor-estate.test.js.map +1 -0
- package/dist/commands/doctor.d.ts +41 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +103 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +216 -11
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/index.js +21 -3
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -4,7 +4,7 @@ import * as os from 'node:os';
|
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
6
|
import { cleanTmpDir } from '../test-utils.js';
|
|
7
|
-
import { AGENTS_MD_REDIRECT_PATTERN, BYPASS_THRESHOLD, checkAgentsMdCanonical, checkCompiledRules, checkConfig, checkEmbeddingConfig, checkFreezes, checkGitHooks, checkGrandfatheredRules, checkIndex, checkLinkedIndexes, checkOllama, checkSecretLeaks, checkSecretsFileTracked, checkStaleRules, checkStrategyRoot, checkUpgradeCandidates, CLAUDE_MD_REDIRECT_MAX_BYTES, doctorCommand, doctorGateFailed, findLegacyGrandfatheredRules, findStaleRules, MIN_CONTEXT_EVENTS, MIN_EVENTS, NON_CODE_THRESHOLD, resolveStrictTier, runSelfHealing, V_1_13_0_SHIP_DATE_ISO, } from './doctor.js';
|
|
7
|
+
import { AGENTS_MD_REDIRECT_PATTERN, BYPASS_THRESHOLD, checkAgentsMdCanonical, checkCompiledRules, checkConfig, checkEmbeddingConfig, checkEstate, checkFreezes, checkGitHooks, checkGrandfatheredRules, checkIndex, checkLinkedIndexes, checkOllama, checkSecretLeaks, checkSecretsFileTracked, checkStaleRules, checkStrategyRoot, checkUpgradeCandidates, CLAUDE_MD_REDIRECT_MAX_BYTES, doctorCommand, doctorGateFailed, findLegacyGrandfatheredRules, findStaleRules, MIN_CONTEXT_EVENTS, MIN_EVENTS, NON_CODE_THRESHOLD, resolveStrictTier, runSelfHealing, V_1_13_0_SHIP_DATE_ISO, } from './doctor.js';
|
|
8
8
|
// ─── Helpers ────────────────────────────────────────────
|
|
9
9
|
function makeTmpDir() {
|
|
10
10
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'totem-doctor-'));
|
|
@@ -314,7 +314,15 @@ const EXPECTED_DIAGNOSTIC_NAMES = [
|
|
|
314
314
|
'Stale Rules',
|
|
315
315
|
'Grandfathered Rules',
|
|
316
316
|
'Freeze state',
|
|
317
|
+
'Estate',
|
|
317
318
|
];
|
|
319
|
+
/**
|
|
320
|
+
* The ambient `Estate` row reads the real user-level registry and shells git at
|
|
321
|
+
* every repo listed there. Every `doctorCommand` call in this suite passes an
|
|
322
|
+
* EMPTY registry through the seam so the suite stays hermetic — same reason the
|
|
323
|
+
* Ollama probe's `fetch` is mocked above.
|
|
324
|
+
*/
|
|
325
|
+
const HERMETIC = { estateSeamsForTest: { registry: {} } };
|
|
318
326
|
describe('doctorCommand', () => {
|
|
319
327
|
let tmpDir;
|
|
320
328
|
let originalCwd;
|
|
@@ -339,12 +347,12 @@ describe('doctorCommand', () => {
|
|
|
339
347
|
vi.restoreAllMocks();
|
|
340
348
|
});
|
|
341
349
|
it('runs without throwing', async () => {
|
|
342
|
-
const results = await doctorCommand();
|
|
350
|
+
const results = await doctorCommand(HERMETIC);
|
|
343
351
|
expect(results).toBeDefined();
|
|
344
352
|
expect(results).toHaveLength(EXPECTED_DIAGNOSTIC_NAMES.length);
|
|
345
353
|
});
|
|
346
354
|
it('returns correct check names', async () => {
|
|
347
|
-
const results = await doctorCommand();
|
|
355
|
+
const results = await doctorCommand(HERMETIC);
|
|
348
356
|
const names = results.map((r) => r.name);
|
|
349
357
|
expect(names).toEqual(expect.arrayContaining([...EXPECTED_DIAGNOSTIC_NAMES]));
|
|
350
358
|
});
|
|
@@ -372,7 +380,7 @@ describe('doctorCommand output', () => {
|
|
|
372
380
|
vi.restoreAllMocks();
|
|
373
381
|
});
|
|
374
382
|
it('outputs all check names in console output', async () => {
|
|
375
|
-
await doctorCommand();
|
|
383
|
+
await doctorCommand(HERMETIC);
|
|
376
384
|
const output = stderrSpy.mock.calls.map((args) => String(args[0])).join('\n');
|
|
377
385
|
expect(output).toContain('Config');
|
|
378
386
|
expect(output).toContain('Compiled Rules');
|
|
@@ -387,7 +395,7 @@ describe('doctorCommand output', () => {
|
|
|
387
395
|
expect(output).toContain('Stale Rules');
|
|
388
396
|
});
|
|
389
397
|
it('outputs summary line with pass/warn/fail counts', async () => {
|
|
390
|
-
await doctorCommand();
|
|
398
|
+
await doctorCommand(HERMETIC);
|
|
391
399
|
const output = stderrSpy.mock.calls.map((args) => String(args[0])).join('\n');
|
|
392
400
|
expect(output).toMatch(/\d+ passed/);
|
|
393
401
|
expect(output).toMatch(/\d+ warnings/);
|
|
@@ -429,7 +437,7 @@ describe('doctorCommand strict mode contract', () => {
|
|
|
429
437
|
// Seed a guaranteed failure: no config means checkConfig returns `fail`.
|
|
430
438
|
fs.unlinkSync(path.join(tmpDir, 'totem.config.ts'));
|
|
431
439
|
process.exitCode = undefined;
|
|
432
|
-
const results = await doctorCommand({ strict: true });
|
|
440
|
+
const results = await doctorCommand({ ...HERMETIC, strict: true });
|
|
433
441
|
expect(results.some((r) => r.status === 'fail')).toBe(true);
|
|
434
442
|
expect(process.exitCode).toBeUndefined();
|
|
435
443
|
});
|
|
@@ -437,14 +445,14 @@ describe('doctorCommand strict mode contract', () => {
|
|
|
437
445
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((_) => {
|
|
438
446
|
throw new Error('process.exit should not be called by doctorCommand');
|
|
439
447
|
}));
|
|
440
|
-
await doctorCommand({ strict: true });
|
|
441
|
-
await doctorCommand({ strict: false });
|
|
442
|
-
await doctorCommand();
|
|
448
|
+
await doctorCommand({ ...HERMETIC, strict: true });
|
|
449
|
+
await doctorCommand({ ...HERMETIC, strict: false });
|
|
450
|
+
await doctorCommand(HERMETIC);
|
|
443
451
|
expect(exitSpy).not.toHaveBeenCalled();
|
|
444
452
|
});
|
|
445
453
|
it('returns the same DiagnosticResult[] shape regardless of strict flag', async () => {
|
|
446
|
-
const resultsStrict = await doctorCommand({ strict: true });
|
|
447
|
-
const resultsLoose = await doctorCommand({ strict: false });
|
|
454
|
+
const resultsStrict = await doctorCommand({ ...HERMETIC, strict: true });
|
|
455
|
+
const resultsLoose = await doctorCommand({ ...HERMETIC, strict: false });
|
|
448
456
|
expect(resultsStrict.map((r) => r.name)).toEqual(resultsLoose.map((r) => r.name));
|
|
449
457
|
});
|
|
450
458
|
});
|
|
@@ -492,6 +500,203 @@ describe('doctorGateFailed', () => {
|
|
|
492
500
|
expect(doctorGateFailed(onlySkip, 'fail')).toBe(false);
|
|
493
501
|
expect(doctorGateFailed(onlySkip, 'warn')).toBe(false);
|
|
494
502
|
});
|
|
503
|
+
// Sensor-class rows report but never gate (mmnto-ai/totem#2580 ruled scope).
|
|
504
|
+
// The exemption rides the ROW, so widening the tier later cannot give a
|
|
505
|
+
// sensor teeth by accident.
|
|
506
|
+
it('a gateExempt warn row does not gate even under the warn tier', () => {
|
|
507
|
+
const estateWarn = [
|
|
508
|
+
...clean,
|
|
509
|
+
{ name: 'Estate', status: 'warn', message: '', gateExempt: true },
|
|
510
|
+
];
|
|
511
|
+
expect(doctorGateFailed(estateWarn, 'warn')).toBe(false);
|
|
512
|
+
expect(doctorGateFailed(estateWarn, 'fail')).toBe(false);
|
|
513
|
+
});
|
|
514
|
+
it('a non-exempt warn row alongside an exempt one still gates under the warn tier', () => {
|
|
515
|
+
const mixed = [
|
|
516
|
+
{ name: 'Estate', status: 'warn', message: '', gateExempt: true },
|
|
517
|
+
{ name: 'C', status: 'warn', message: '' },
|
|
518
|
+
];
|
|
519
|
+
expect(doctorGateFailed(mixed, 'warn')).toBe(true);
|
|
520
|
+
expect(doctorGateFailed(mixed, 'fail')).toBe(false);
|
|
521
|
+
});
|
|
522
|
+
// The exemption is scoped to ADVISORY statuses. A fail is a wiring failure
|
|
523
|
+
// and no row may hide one — sensor rows never emit `fail`, so the narrowing
|
|
524
|
+
// costs them nothing and closes the mislabelled-row hole.
|
|
525
|
+
it('a gateExempt FAIL row still gates in both tiers', () => {
|
|
526
|
+
const exemptFail = [
|
|
527
|
+
...clean,
|
|
528
|
+
{ name: 'Sensor', status: 'fail', message: '', gateExempt: true },
|
|
529
|
+
];
|
|
530
|
+
expect(doctorGateFailed(exemptFail, 'fail')).toBe(true);
|
|
531
|
+
expect(doctorGateFailed(exemptFail, 'warn')).toBe(true);
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
// ─── Ambient estate row (mmnto-ai/totem#2580) ───────────
|
|
535
|
+
describe('checkEstate', () => {
|
|
536
|
+
const registryOf = (...paths) => Object.fromEntries(paths.map((p) => [
|
|
537
|
+
p,
|
|
538
|
+
{ path: p, chunkCount: 1, lastSync: '2026-08-01T00:00:00.000Z', embedder: 'test' },
|
|
539
|
+
]));
|
|
540
|
+
let estateDir;
|
|
541
|
+
beforeEach(() => {
|
|
542
|
+
estateDir = fs.realpathSync(makeTmpDir());
|
|
543
|
+
});
|
|
544
|
+
afterEach(() => {
|
|
545
|
+
cleanTmpDir(estateDir);
|
|
546
|
+
});
|
|
547
|
+
/** Canned git for `repo`; every other path answers as its own toplevel. */
|
|
548
|
+
function seams(repo, opts = {}) {
|
|
549
|
+
const listing = [
|
|
550
|
+
`worktree ${repo.split(path.sep).join('/')}`,
|
|
551
|
+
`HEAD ${'a'.repeat(40)}`,
|
|
552
|
+
'branch refs/heads/main',
|
|
553
|
+
'',
|
|
554
|
+
].join('\n');
|
|
555
|
+
const fold = (p) => process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p);
|
|
556
|
+
const toplevels = new Map(Object.entries(opts.toplevels ?? {}).map(([k, v]) => [fold(k), v]));
|
|
557
|
+
const failToplevel = new Set((opts.failToplevel ?? []).map(fold));
|
|
558
|
+
return {
|
|
559
|
+
registry: registryOf(repo),
|
|
560
|
+
now: Date.parse('2026-08-05T12:00:00.000Z'),
|
|
561
|
+
safeExec: ((_command, args = []) => {
|
|
562
|
+
if (opts.throws === true)
|
|
563
|
+
throw new Error('git exploded');
|
|
564
|
+
const cwd = args[2] ?? '';
|
|
565
|
+
const verb = args.slice(3);
|
|
566
|
+
if (verb[0] === 'rev-parse' && verb[1] === '--show-toplevel') {
|
|
567
|
+
if (failToplevel.has(fold(cwd)))
|
|
568
|
+
throw new Error('not a git repository');
|
|
569
|
+
return (toplevels.get(fold(cwd)) ?? path.resolve(cwd)).split(path.sep).join('/');
|
|
570
|
+
}
|
|
571
|
+
if (verb[0] === 'worktree') {
|
|
572
|
+
if (fold(cwd) !== fold(repo))
|
|
573
|
+
throw new Error('not a git repository');
|
|
574
|
+
return listing;
|
|
575
|
+
}
|
|
576
|
+
if (verb[0] === 'rev-parse')
|
|
577
|
+
return 'origin/main';
|
|
578
|
+
return '';
|
|
579
|
+
}),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
it('skips when nothing is registered', async () => {
|
|
583
|
+
const result = await checkEstate({ registry: {} });
|
|
584
|
+
expect(result.status).toBe('skip');
|
|
585
|
+
expect(result.message).toContain('No registered repos');
|
|
586
|
+
});
|
|
587
|
+
// A corrupt registry must not collapse into the clean "nothing registered"
|
|
588
|
+
// skip — the ambient row is the surface operators actually see, and it is
|
|
589
|
+
// the one that would hide the failure. Drives the REAL readRegistry (no
|
|
590
|
+
// registry seam) against a temp home holding an unparseable registry.json.
|
|
591
|
+
it('warns (not skip) when the registry exists but cannot be read', async () => {
|
|
592
|
+
const home = fs.realpathSync(makeTmpDir());
|
|
593
|
+
fs.mkdirSync(path.join(home, '.totem'), { recursive: true });
|
|
594
|
+
fs.writeFileSync(path.join(home, '.totem', 'registry.json'), '{ not json', 'utf-8');
|
|
595
|
+
const prevHome = process.env['HOME'];
|
|
596
|
+
const prevProfile = process.env['USERPROFILE'];
|
|
597
|
+
process.env['HOME'] = home;
|
|
598
|
+
process.env['USERPROFILE'] = home;
|
|
599
|
+
try {
|
|
600
|
+
const result = await checkEstate();
|
|
601
|
+
expect(result.status).toBe('warn');
|
|
602
|
+
expect(result.message).toContain('Registry unreadable');
|
|
603
|
+
expect(result.gateExempt).toBe(true);
|
|
604
|
+
}
|
|
605
|
+
finally {
|
|
606
|
+
if (prevHome === undefined)
|
|
607
|
+
delete process.env['HOME'];
|
|
608
|
+
else
|
|
609
|
+
process.env['HOME'] = prevHome;
|
|
610
|
+
if (prevProfile === undefined)
|
|
611
|
+
delete process.env['USERPROFILE'];
|
|
612
|
+
else
|
|
613
|
+
process.env['USERPROFILE'] = prevProfile;
|
|
614
|
+
cleanTmpDir(home);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
it('passes quietly on a clean estate, naming missing and unscannable counts', async () => {
|
|
618
|
+
const repo = path.join(estateDir, 'repo');
|
|
619
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
620
|
+
const gone = path.join(estateDir, 'vanished');
|
|
621
|
+
const base = seams(repo);
|
|
622
|
+
const result = await checkEstate({
|
|
623
|
+
...base,
|
|
624
|
+
registry: registryOf(repo, gone),
|
|
625
|
+
});
|
|
626
|
+
expect(result.status).toBe('pass');
|
|
627
|
+
// The denominator is what was ENUMERATED — an entry that was missing, not a
|
|
628
|
+
// git root, or unprobeable was never looked inside and must not inflate it.
|
|
629
|
+
expect(result.message).toContain('1 enumerated repo(s)');
|
|
630
|
+
expect(result.message).toContain('(1 missing)');
|
|
631
|
+
expect(result.message).toContain('0 linked worktree(s)');
|
|
632
|
+
expect(result.gateExempt).toBe(true);
|
|
633
|
+
});
|
|
634
|
+
it('names the not-git-root and unprobeable counts too', async () => {
|
|
635
|
+
const repo = path.join(estateDir, 'repo');
|
|
636
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
637
|
+
const inside = path.join(repo, 'packages');
|
|
638
|
+
fs.mkdirSync(inside, { recursive: true });
|
|
639
|
+
const broken = path.join(estateDir, 'broken');
|
|
640
|
+
fs.mkdirSync(broken, { recursive: true });
|
|
641
|
+
const result = await checkEstate({
|
|
642
|
+
...seams(repo, { toplevels: { [inside]: repo }, failToplevel: [broken] }),
|
|
643
|
+
registry: registryOf(repo, inside, broken),
|
|
644
|
+
});
|
|
645
|
+
expect(result.status).toBe('pass');
|
|
646
|
+
expect(result.message).toContain('1 enumerated repo(s)');
|
|
647
|
+
expect(result.message).toContain('1 not-git-root');
|
|
648
|
+
expect(result.message).toContain('1 unprobeable');
|
|
649
|
+
});
|
|
650
|
+
it('warns with counts and the --estate remediation when husks exist', async () => {
|
|
651
|
+
const repo = path.join(estateDir, 'repo');
|
|
652
|
+
fs.mkdirSync(path.join(repo, '.claude', 'worktrees', 'agent-a'), { recursive: true });
|
|
653
|
+
fs.mkdirSync(path.join(repo, '.claude', 'worktrees', 'agent-b'), { recursive: true });
|
|
654
|
+
const result = await checkEstate(seams(repo));
|
|
655
|
+
expect(result.status).toBe('warn');
|
|
656
|
+
expect(result.message).toContain('2 husk candidate(s)');
|
|
657
|
+
expect(result.remediation).toContain('totem doctor --estate');
|
|
658
|
+
expect(result.gateExempt).toBe(true);
|
|
659
|
+
});
|
|
660
|
+
// A git that fails on every invocation is NOT a scan crash: the scan is
|
|
661
|
+
// fail-soft per probe, so this lands as unscannable rows and the row still
|
|
662
|
+
// reports. Asserted so the two failure classes stay distinguishable.
|
|
663
|
+
it('keeps reporting when every git probe fails, naming the unscannable count', async () => {
|
|
664
|
+
const repo = path.join(estateDir, 'repo');
|
|
665
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
666
|
+
const result = await checkEstate(seams(repo, { throws: true }));
|
|
667
|
+
expect(result.status).toBe('pass');
|
|
668
|
+
expect(result.message).toContain('1 probe(s) unscannable');
|
|
669
|
+
});
|
|
670
|
+
it('warns (never throws) on a crash-class scan failure', async () => {
|
|
671
|
+
const repo = path.join(estateDir, 'repo');
|
|
672
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
673
|
+
// A malformed registry is the reachable crash class: EVERY exec call site
|
|
674
|
+
// inside the scan is wrapped, so even a broken git seam degrades to
|
|
675
|
+
// unscannable rows (asserted above) rather than throwing. What the catch
|
|
676
|
+
// exists for is a crash BEFORE or AROUND the probes — a bad registry
|
|
677
|
+
// shape, or the dynamic core import failing.
|
|
678
|
+
const result = await checkEstate({ ...seams(repo), registry: { bad: null } });
|
|
679
|
+
expect(result.status).toBe('warn');
|
|
680
|
+
expect(result.message).toContain('Estate scan failed');
|
|
681
|
+
});
|
|
682
|
+
it('marks every row gateExempt so the sensor can never gate', async () => {
|
|
683
|
+
const repo = path.join(estateDir, 'repo');
|
|
684
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
685
|
+
const huskRepo = path.join(estateDir, 'husk-repo');
|
|
686
|
+
fs.mkdirSync(path.join(huskRepo, '.claude', 'worktrees', 'agent-a'), { recursive: true });
|
|
687
|
+
// All FOUR real paths: skip / pass / husk-warn / crash-warn.
|
|
688
|
+
const rows = [
|
|
689
|
+
await checkEstate({ registry: {} }),
|
|
690
|
+
await checkEstate(seams(repo)),
|
|
691
|
+
await checkEstate(seams(huskRepo)),
|
|
692
|
+
await checkEstate({ ...seams(repo), registry: { bad: null } }),
|
|
693
|
+
];
|
|
694
|
+
expect(rows.map((r) => r.status)).toEqual(['skip', 'pass', 'warn', 'warn']);
|
|
695
|
+
expect(rows[2].message).toContain('husk candidate(s)');
|
|
696
|
+
for (const row of rows)
|
|
697
|
+
expect(row.gateExempt).toBe(true);
|
|
698
|
+
expect(doctorGateFailed(rows, 'warn')).toBe(false);
|
|
699
|
+
});
|
|
495
700
|
});
|
|
496
701
|
// ─── Secrets file tracking check ────────────────────────
|
|
497
702
|
describe('checkSecretsFileTracked', () => {
|