@mmnto/cli 1.111.1 → 1.113.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 +43 -0
- package/dist/commands/doctor-estate.d.ts.map +1 -0
- package/dist/commands/doctor-estate.js +262 -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 +611 -0
- package/dist/commands/doctor-estate.test.js.map +1 -0
- package/dist/commands/doctor.d.ts +48 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +171 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +410 -11
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/wt.d.ts +100 -0
- package/dist/commands/wt.d.ts.map +1 -0
- package/dist/commands/wt.js +625 -0
- package/dist/commands/wt.js.map +1 -0
- package/dist/commands/wt.test.d.ts +21 -0
- package/dist/commands/wt.test.d.ts.map +1 -0
- package/dist/commands/wt.test.js +884 -0
- package/dist/commands/wt.test.js.map +1 -0
- package/dist/index.js +76 -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: {}, wtRoots: [] } };
|
|
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,206 @@ 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
|
+
// Pinned so a machine that has actually run `totem wt create` cannot
|
|
562
|
+
// drag its real recorded roots into these assertions (#2580 slice 2).
|
|
563
|
+
wtRoots: [],
|
|
564
|
+
safeExec: ((_command, args = []) => {
|
|
565
|
+
if (opts.throws === true)
|
|
566
|
+
throw new Error('git exploded');
|
|
567
|
+
const cwd = args[2] ?? '';
|
|
568
|
+
const verb = args.slice(3);
|
|
569
|
+
if (verb[0] === 'rev-parse' && verb[1] === '--show-toplevel') {
|
|
570
|
+
if (failToplevel.has(fold(cwd)))
|
|
571
|
+
throw new Error('not a git repository');
|
|
572
|
+
return (toplevels.get(fold(cwd)) ?? path.resolve(cwd)).split(path.sep).join('/');
|
|
573
|
+
}
|
|
574
|
+
if (verb[0] === 'worktree') {
|
|
575
|
+
if (fold(cwd) !== fold(repo))
|
|
576
|
+
throw new Error('not a git repository');
|
|
577
|
+
return listing;
|
|
578
|
+
}
|
|
579
|
+
if (verb[0] === 'rev-parse')
|
|
580
|
+
return 'origin/main';
|
|
581
|
+
return '';
|
|
582
|
+
}),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
it('skips when nothing is registered', async () => {
|
|
586
|
+
const result = await checkEstate({ registry: {}, wtRoots: [] });
|
|
587
|
+
expect(result.status).toBe('skip');
|
|
588
|
+
expect(result.message).toContain('No registered repos');
|
|
589
|
+
});
|
|
590
|
+
// A corrupt registry must not collapse into the clean "nothing registered"
|
|
591
|
+
// skip — the ambient row is the surface operators actually see, and it is
|
|
592
|
+
// the one that would hide the failure. Drives the REAL readRegistry (no
|
|
593
|
+
// registry seam) against a temp home holding an unparseable registry.json.
|
|
594
|
+
it('warns (not skip) when the registry exists but cannot be read', async () => {
|
|
595
|
+
const home = fs.realpathSync(makeTmpDir());
|
|
596
|
+
fs.mkdirSync(path.join(home, '.totem'), { recursive: true });
|
|
597
|
+
fs.writeFileSync(path.join(home, '.totem', 'registry.json'), '{ not json', 'utf-8');
|
|
598
|
+
const prevHome = process.env['HOME'];
|
|
599
|
+
const prevProfile = process.env['USERPROFILE'];
|
|
600
|
+
process.env['HOME'] = home;
|
|
601
|
+
process.env['USERPROFILE'] = home;
|
|
602
|
+
try {
|
|
603
|
+
const result = await checkEstate();
|
|
604
|
+
expect(result.status).toBe('warn');
|
|
605
|
+
expect(result.message).toContain('Registry unreadable');
|
|
606
|
+
expect(result.gateExempt).toBe(true);
|
|
607
|
+
}
|
|
608
|
+
finally {
|
|
609
|
+
if (prevHome === undefined)
|
|
610
|
+
delete process.env['HOME'];
|
|
611
|
+
else
|
|
612
|
+
process.env['HOME'] = prevHome;
|
|
613
|
+
if (prevProfile === undefined)
|
|
614
|
+
delete process.env['USERPROFILE'];
|
|
615
|
+
else
|
|
616
|
+
process.env['USERPROFILE'] = prevProfile;
|
|
617
|
+
cleanTmpDir(home);
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
it('passes quietly on a clean estate, naming missing and unscannable counts', async () => {
|
|
621
|
+
const repo = path.join(estateDir, 'repo');
|
|
622
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
623
|
+
const gone = path.join(estateDir, 'vanished');
|
|
624
|
+
const base = seams(repo);
|
|
625
|
+
const result = await checkEstate({
|
|
626
|
+
...base,
|
|
627
|
+
registry: registryOf(repo, gone),
|
|
628
|
+
});
|
|
629
|
+
expect(result.status).toBe('pass');
|
|
630
|
+
// The denominator is what was ENUMERATED — an entry that was missing, not a
|
|
631
|
+
// git root, or unprobeable was never looked inside and must not inflate it.
|
|
632
|
+
expect(result.message).toContain('1 enumerated repo(s)');
|
|
633
|
+
expect(result.message).toContain('(1 missing)');
|
|
634
|
+
expect(result.message).toContain('0 linked worktree(s)');
|
|
635
|
+
expect(result.gateExempt).toBe(true);
|
|
636
|
+
});
|
|
637
|
+
it('names the not-git-root and unprobeable counts too', async () => {
|
|
638
|
+
const repo = path.join(estateDir, 'repo');
|
|
639
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
640
|
+
const inside = path.join(repo, 'packages');
|
|
641
|
+
fs.mkdirSync(inside, { recursive: true });
|
|
642
|
+
const broken = path.join(estateDir, 'broken');
|
|
643
|
+
fs.mkdirSync(broken, { recursive: true });
|
|
644
|
+
const result = await checkEstate({
|
|
645
|
+
...seams(repo, { toplevels: { [inside]: repo }, failToplevel: [broken] }),
|
|
646
|
+
registry: registryOf(repo, inside, broken),
|
|
647
|
+
});
|
|
648
|
+
expect(result.status).toBe('pass');
|
|
649
|
+
expect(result.message).toContain('1 enumerated repo(s)');
|
|
650
|
+
expect(result.message).toContain('1 not-git-root');
|
|
651
|
+
expect(result.message).toContain('1 unprobeable');
|
|
652
|
+
});
|
|
653
|
+
it('warns with counts and the --estate remediation when husks exist', async () => {
|
|
654
|
+
const repo = path.join(estateDir, 'repo');
|
|
655
|
+
fs.mkdirSync(path.join(repo, '.claude', 'worktrees', 'agent-a'), { recursive: true });
|
|
656
|
+
fs.mkdirSync(path.join(repo, '.claude', 'worktrees', 'agent-b'), { recursive: true });
|
|
657
|
+
const result = await checkEstate(seams(repo));
|
|
658
|
+
expect(result.status).toBe('warn');
|
|
659
|
+
expect(result.message).toContain('2 husk candidate(s)');
|
|
660
|
+
expect(result.remediation).toContain('totem doctor --estate');
|
|
661
|
+
expect(result.gateExempt).toBe(true);
|
|
662
|
+
});
|
|
663
|
+
// A git that fails on every invocation is NOT a scan crash: the scan is
|
|
664
|
+
// fail-soft per probe, so this lands as unscannable rows and the row still
|
|
665
|
+
// reports. Asserted so the two failure classes stay distinguishable.
|
|
666
|
+
it('keeps reporting when every git probe fails, naming the unscannable count', async () => {
|
|
667
|
+
const repo = path.join(estateDir, 'repo');
|
|
668
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
669
|
+
const result = await checkEstate(seams(repo, { throws: true }));
|
|
670
|
+
expect(result.status).toBe('pass');
|
|
671
|
+
expect(result.message).toContain('1 probe(s) unscannable');
|
|
672
|
+
});
|
|
673
|
+
it('warns (never throws) on a crash-class scan failure', async () => {
|
|
674
|
+
const repo = path.join(estateDir, 'repo');
|
|
675
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
676
|
+
// A malformed registry is the reachable crash class: EVERY exec call site
|
|
677
|
+
// inside the scan is wrapped, so even a broken git seam degrades to
|
|
678
|
+
// unscannable rows (asserted above) rather than throwing. What the catch
|
|
679
|
+
// exists for is a crash BEFORE or AROUND the probes — a bad registry
|
|
680
|
+
// shape, or the dynamic core import failing.
|
|
681
|
+
const result = await checkEstate({ ...seams(repo), registry: { bad: null } });
|
|
682
|
+
expect(result.status).toBe('warn');
|
|
683
|
+
expect(result.message).toContain('Estate scan failed');
|
|
684
|
+
});
|
|
685
|
+
it('marks every row gateExempt so the sensor can never gate', async () => {
|
|
686
|
+
const repo = path.join(estateDir, 'repo');
|
|
687
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
688
|
+
const huskRepo = path.join(estateDir, 'husk-repo');
|
|
689
|
+
fs.mkdirSync(path.join(huskRepo, '.claude', 'worktrees', 'agent-a'), { recursive: true });
|
|
690
|
+
// All FOUR real paths: skip / pass / husk-warn / crash-warn.
|
|
691
|
+
const rows = [
|
|
692
|
+
await checkEstate({ registry: {}, wtRoots: [] }),
|
|
693
|
+
await checkEstate(seams(repo)),
|
|
694
|
+
await checkEstate(seams(huskRepo)),
|
|
695
|
+
await checkEstate({ ...seams(repo), registry: { bad: null } }),
|
|
696
|
+
];
|
|
697
|
+
expect(rows.map((r) => r.status)).toEqual(['skip', 'pass', 'warn', 'warn']);
|
|
698
|
+
expect(rows[2].message).toContain('husk candidate(s)');
|
|
699
|
+
for (const row of rows)
|
|
700
|
+
expect(row.gateExempt).toBe(true);
|
|
701
|
+
expect(doctorGateFailed(rows, 'warn')).toBe(false);
|
|
702
|
+
});
|
|
495
703
|
});
|
|
496
704
|
// ─── Secrets file tracking check ────────────────────────
|
|
497
705
|
describe('checkSecretsFileTracked', () => {
|
|
@@ -2035,4 +2243,195 @@ describe('checkFreezes', () => {
|
|
|
2035
2243
|
expect(result.message).toContain('underivable');
|
|
2036
2244
|
});
|
|
2037
2245
|
});
|
|
2246
|
+
// ─── Estate row × wt-registry coupling (#2580 slice 2) ──
|
|
2247
|
+
describe('checkEstate — wt-registry roots', () => {
|
|
2248
|
+
let estateHome;
|
|
2249
|
+
let prevHome;
|
|
2250
|
+
let prevProfile;
|
|
2251
|
+
beforeEach(() => {
|
|
2252
|
+
estateHome = fs.realpathSync(makeTmpDir());
|
|
2253
|
+
prevHome = process.env['HOME'];
|
|
2254
|
+
prevProfile = process.env['USERPROFILE'];
|
|
2255
|
+
process.env['HOME'] = estateHome;
|
|
2256
|
+
process.env['USERPROFILE'] = estateHome;
|
|
2257
|
+
});
|
|
2258
|
+
afterEach(() => {
|
|
2259
|
+
if (prevHome === undefined)
|
|
2260
|
+
delete process.env['HOME'];
|
|
2261
|
+
else
|
|
2262
|
+
process.env['HOME'] = prevHome;
|
|
2263
|
+
if (prevProfile === undefined)
|
|
2264
|
+
delete process.env['USERPROFILE'];
|
|
2265
|
+
else
|
|
2266
|
+
process.env['USERPROFILE'] = prevProfile;
|
|
2267
|
+
cleanTmpDir(estateHome);
|
|
2268
|
+
});
|
|
2269
|
+
function writeWtRegistry(contents) {
|
|
2270
|
+
fs.mkdirSync(path.join(estateHome, '.totem'), { recursive: true });
|
|
2271
|
+
fs.writeFileSync(path.join(estateHome, '.totem', 'worktrees.json'), contents, 'utf-8');
|
|
2272
|
+
}
|
|
2273
|
+
// Invariant 8 on the AMBIENT row: the sensor row must sweep what `--estate`
|
|
2274
|
+
// sweeps, or a clean ambient line would contradict the explicit command.
|
|
2275
|
+
it('sweeps the recorded DEFAULT root with an EMPTY sync registry, finding the husk', async () => {
|
|
2276
|
+
// The default `~/.totem/worktrees` is the one recorded root that carries
|
|
2277
|
+
// container semantics — it exists solely to hold worktrees.
|
|
2278
|
+
const recorded = path.join(estateHome, '.totem', 'worktrees');
|
|
2279
|
+
fs.mkdirSync(path.join(recorded, 'left-behind'), { recursive: true });
|
|
2280
|
+
writeWtRegistry(JSON.stringify({ schemaVersion: 1, roots: [recorded], worktrees: {} }));
|
|
2281
|
+
const result = await checkEstate({ registry: {} });
|
|
2282
|
+
expect(result.status).toBe('warn');
|
|
2283
|
+
expect(result.message).toContain('1 husk candidate(s)');
|
|
2284
|
+
expect(result.gateExempt).toBe(true);
|
|
2285
|
+
});
|
|
2286
|
+
it('sweeps a NON-default recorded root with shape evidence only — no by-location husks', async () => {
|
|
2287
|
+
// A recorded scratch root holds other things beside worktrees; an
|
|
2288
|
+
// arbitrary directory there is NOT residue-by-location (finding 11).
|
|
2289
|
+
const recorded = path.join(estateHome, 'scratch-root');
|
|
2290
|
+
fs.mkdirSync(path.join(recorded, 'unrelated-project'), { recursive: true });
|
|
2291
|
+
writeWtRegistry(JSON.stringify({ schemaVersion: 1, roots: [recorded], worktrees: {} }));
|
|
2292
|
+
const result = await checkEstate({ registry: {} });
|
|
2293
|
+
expect(result.status).toBe('pass');
|
|
2294
|
+
expect(result.message).toContain('no stale worktrees or husk candidates');
|
|
2295
|
+
});
|
|
2296
|
+
it('surfaces the unreadable sync registry even when recorded roots keep the scan alive', async () => {
|
|
2297
|
+
// Finding 1: a live recorded root routes AROUND the empty-registry
|
|
2298
|
+
// short-circuit — the unreadable-registry disclosure must ride the live
|
|
2299
|
+
// row, never vanish into a clean pass.
|
|
2300
|
+
fs.mkdirSync(path.join(estateHome, '.totem'), { recursive: true });
|
|
2301
|
+
fs.writeFileSync(path.join(estateHome, '.totem', 'registry.json'), '{ not json', 'utf-8');
|
|
2302
|
+
const recorded = path.join(estateHome, '.totem', 'worktrees');
|
|
2303
|
+
fs.mkdirSync(recorded, { recursive: true });
|
|
2304
|
+
writeWtRegistry(JSON.stringify({ schemaVersion: 1, roots: [recorded], worktrees: {} }));
|
|
2305
|
+
const result = await checkEstate({});
|
|
2306
|
+
expect(result.status).toBe('warn');
|
|
2307
|
+
expect(result.message).toContain('Sync registry unreadable');
|
|
2308
|
+
// The scan itself still ran — the disclosure rides a live row, not a skip.
|
|
2309
|
+
expect(result.message).toContain('no stale worktrees or husk candidates');
|
|
2310
|
+
expect(result.remediation).toContain('registry.json');
|
|
2311
|
+
// Warning text is flattened at interpolation (bot round, CR finding 2):
|
|
2312
|
+
// a multi-line parse error must never forge extra doctor rows.
|
|
2313
|
+
expect(result.message).not.toContain('\n');
|
|
2314
|
+
});
|
|
2315
|
+
it('keeps the unreadable-registry disclosure when the scan itself throws', async () => {
|
|
2316
|
+
// Re-verification round 2, finding 4: the catch arm was the one row shape
|
|
2317
|
+
// that dropped the disclosure — broken sync registry AND a scan failure
|
|
2318
|
+
// must surface BOTH signals on the same row.
|
|
2319
|
+
fs.mkdirSync(path.join(estateHome, '.totem'), { recursive: true });
|
|
2320
|
+
fs.writeFileSync(path.join(estateHome, '.totem', 'registry.json'), '{ not json', 'utf-8');
|
|
2321
|
+
// The seam getter throws AFTER the registry read populated its warnings —
|
|
2322
|
+
// the nearest injectable stand-in for a scan-side failure, since
|
|
2323
|
+
// `scanEstate` contains its own probe failures.
|
|
2324
|
+
const seams = {};
|
|
2325
|
+
Object.defineProperty(seams, 'wtRoots', {
|
|
2326
|
+
get() {
|
|
2327
|
+
throw new Error('scan-side failure');
|
|
2328
|
+
},
|
|
2329
|
+
enumerable: true,
|
|
2330
|
+
});
|
|
2331
|
+
const result = await checkEstate(seams);
|
|
2332
|
+
expect(result.status).toBe('warn');
|
|
2333
|
+
expect(result.message).toContain('Estate scan failed: scan-side failure');
|
|
2334
|
+
expect(result.message).toContain('Sync registry unreadable');
|
|
2335
|
+
});
|
|
2336
|
+
it('keeps the WORKTREE-registry disclosure when the scan itself throws', async () => {
|
|
2337
|
+
// The wt-side symmetry of the catch-arm fix (bot round, CR finding 3):
|
|
2338
|
+
// `wtWarnings` is hoisted with `registryWarnings`, so an unreadable
|
|
2339
|
+
// worktrees.json survives into the scan-failed row instead of silently
|
|
2340
|
+
// vanishing with the throw.
|
|
2341
|
+
writeWtRegistry('{ not json');
|
|
2342
|
+
// One registry entry keeps the scan off the empty short-circuit; the
|
|
2343
|
+
// throwing `safeExec` getter fires AFTER the worktree-registry read has
|
|
2344
|
+
// populated its warnings, landing the row in the catch arm.
|
|
2345
|
+
const seams = {
|
|
2346
|
+
registry: {
|
|
2347
|
+
[path.join(estateHome, 'repo')]: {
|
|
2348
|
+
path: path.join(estateHome, 'repo'),
|
|
2349
|
+
chunkCount: 0,
|
|
2350
|
+
lastSync: '2026-08-01T00:00:00.000Z',
|
|
2351
|
+
embedder: 'x',
|
|
2352
|
+
},
|
|
2353
|
+
},
|
|
2354
|
+
};
|
|
2355
|
+
Object.defineProperty(seams, 'safeExec', {
|
|
2356
|
+
get() {
|
|
2357
|
+
throw new Error('scan-side failure');
|
|
2358
|
+
},
|
|
2359
|
+
enumerable: true,
|
|
2360
|
+
});
|
|
2361
|
+
const result = await checkEstate(seams);
|
|
2362
|
+
expect(result.status).toBe('warn');
|
|
2363
|
+
expect(result.message).toContain('Estate scan failed: scan-side failure');
|
|
2364
|
+
expect(result.message).toContain('Cannot read worktree registry');
|
|
2365
|
+
});
|
|
2366
|
+
it('still skips when the recorded root no longer exists (empty sweep, not a hole)', async () => {
|
|
2367
|
+
writeWtRegistry(JSON.stringify({ schemaVersion: 1, roots: [path.join(estateHome, 'gone')], worktrees: {} }));
|
|
2368
|
+
const result = await checkEstate({ registry: {} });
|
|
2369
|
+
expect(result.status).toBe('skip');
|
|
2370
|
+
expect(result.message).toContain('No registered repos');
|
|
2371
|
+
});
|
|
2372
|
+
it('treats an unreadable worktrees.json as degraded state, never a clean skip', async () => {
|
|
2373
|
+
// Round 2, CR outside-diff finding, short-circuit half: an unreadable
|
|
2374
|
+
// worktree registry yields zero roots — exactly what routes the row onto
|
|
2375
|
+
// the empty short-circuit — so the skip arm itself must disclose it.
|
|
2376
|
+
writeWtRegistry('{ not json');
|
|
2377
|
+
const result = await checkEstate({ registry: {} });
|
|
2378
|
+
expect(result.status).toBe('warn');
|
|
2379
|
+
expect(result.message).toContain('Worktree registry unreadable');
|
|
2380
|
+
expect(result.message).toContain('Cannot read worktree registry');
|
|
2381
|
+
// The scan never ran — the row must not claim a clean sweep.
|
|
2382
|
+
expect(result.message).not.toContain('no stale worktrees');
|
|
2383
|
+
expect(result.remediation).toContain('worktrees.json');
|
|
2384
|
+
// Degraded, but never a fail: the row is sensor-class.
|
|
2385
|
+
expect(result.gateExempt).toBe(true);
|
|
2386
|
+
});
|
|
2387
|
+
it('names BOTH files in the remediation when both registries are unreadable', async () => {
|
|
2388
|
+
// Round 3, CR inline: the sync-registry arm wins precedence and its
|
|
2389
|
+
// message carries wtNote(), but a remediation naming only registry.json
|
|
2390
|
+
// would leave the next run warning again on the file it never mentioned.
|
|
2391
|
+
fs.mkdirSync(path.join(estateHome, '.totem'), { recursive: true });
|
|
2392
|
+
fs.writeFileSync(path.join(estateHome, '.totem', 'registry.json'), '{ not json', 'utf-8');
|
|
2393
|
+
writeWtRegistry('{ not json');
|
|
2394
|
+
const result = await checkEstate();
|
|
2395
|
+
expect(result.status).toBe('warn');
|
|
2396
|
+
expect(result.message).toContain('Registry unreadable');
|
|
2397
|
+
expect(result.message).toContain('Cannot read worktree registry');
|
|
2398
|
+
expect(result.remediation).toContain('registry.json');
|
|
2399
|
+
expect(result.remediation).toContain('worktrees.json');
|
|
2400
|
+
expect(result.gateExempt).toBe(true);
|
|
2401
|
+
});
|
|
2402
|
+
it('demotes an otherwise-clean scan to warn when worktrees.json is unreadable', async () => {
|
|
2403
|
+
// Round 2, CR outside-diff finding, clean-arm half: the scan ran (one
|
|
2404
|
+
// registry entry routes past the short-circuit; a missing repo path needs
|
|
2405
|
+
// no git), but an unreadable worktree registry may name recorded roots
|
|
2406
|
+
// the sweep never saw — a green line would overstate the coverage.
|
|
2407
|
+
writeWtRegistry('{ not json');
|
|
2408
|
+
const missingRepo = path.join(estateHome, 'gone-repo');
|
|
2409
|
+
const result = await checkEstate({
|
|
2410
|
+
registry: {
|
|
2411
|
+
[missingRepo]: {
|
|
2412
|
+
path: missingRepo,
|
|
2413
|
+
chunkCount: 0,
|
|
2414
|
+
lastSync: '2026-08-01T00:00:00.000Z',
|
|
2415
|
+
embedder: 'x',
|
|
2416
|
+
},
|
|
2417
|
+
},
|
|
2418
|
+
});
|
|
2419
|
+
expect(result.status).toBe('warn');
|
|
2420
|
+
expect(result.message).toContain('no stale worktrees or husk candidates');
|
|
2421
|
+
expect(result.message).toContain('Cannot read worktree registry');
|
|
2422
|
+
expect(result.remediation).toContain('worktrees.json');
|
|
2423
|
+
expect(result.gateExempt).toBe(true);
|
|
2424
|
+
});
|
|
2425
|
+
it('honours the wtRoots seam without reading the home file', async () => {
|
|
2426
|
+
// The seam value flows through the same default-vs-standard partition as
|
|
2427
|
+
// production roots, so container semantics need the default location.
|
|
2428
|
+
const seamRoot = path.join(estateHome, '.totem', 'worktrees');
|
|
2429
|
+
fs.mkdirSync(path.join(seamRoot, 'residue'), { recursive: true });
|
|
2430
|
+
writeWtRegistry('{ not json');
|
|
2431
|
+
const result = await checkEstate({ registry: {}, wtRoots: [seamRoot] });
|
|
2432
|
+
expect(result.message).not.toContain('Cannot read worktree registry');
|
|
2433
|
+
expect(result.status).toBe('warn');
|
|
2434
|
+
expect(result.message).toContain('1 husk candidate(s)');
|
|
2435
|
+
});
|
|
2436
|
+
});
|
|
2038
2437
|
//# sourceMappingURL=doctor.test.js.map
|