@mmnto/cli 1.124.0 → 2.1.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.
Files changed (58) hide show
  1. package/dist/commands/describe.d.ts +13 -1
  2. package/dist/commands/describe.d.ts.map +1 -1
  3. package/dist/commands/describe.js +29 -1
  4. package/dist/commands/describe.js.map +1 -1
  5. package/dist/commands/describe.test.js +86 -3
  6. package/dist/commands/describe.test.js.map +1 -1
  7. package/dist/commands/doctor.d.ts +16 -10
  8. package/dist/commands/doctor.d.ts.map +1 -1
  9. package/dist/commands/doctor.js +162 -54
  10. package/dist/commands/doctor.js.map +1 -1
  11. package/dist/commands/doctor.test.js +444 -28
  12. package/dist/commands/doctor.test.js.map +1 -1
  13. package/dist/commands/hook-totemdir-render.test.js +55 -1
  14. package/dist/commands/hook-totemdir-render.test.js.map +1 -1
  15. package/dist/commands/init-templates.d.ts +2 -2
  16. package/dist/commands/init-templates.d.ts.map +1 -1
  17. package/dist/commands/init-templates.js +2 -2
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +39 -0
  20. package/dist/commands/init.js.map +1 -1
  21. package/dist/commands/init.test.js +32 -0
  22. package/dist/commands/init.test.js.map +1 -1
  23. package/dist/commands/install-hooks-exit-contract.test.js +32 -1
  24. package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
  25. package/dist/commands/install-hooks.d.ts +143 -10
  26. package/dist/commands/install-hooks.d.ts.map +1 -1
  27. package/dist/commands/install-hooks.js +596 -92
  28. package/dist/commands/install-hooks.js.map +1 -1
  29. package/dist/commands/install-hooks.test.js +1535 -19
  30. package/dist/commands/install-hooks.test.js.map +1 -1
  31. package/dist/commands/legs.d.ts +35 -7
  32. package/dist/commands/legs.d.ts.map +1 -1
  33. package/dist/commands/legs.js +26 -7
  34. package/dist/commands/legs.js.map +1 -1
  35. package/dist/commands/legs.test.js +87 -0
  36. package/dist/commands/legs.test.js.map +1 -1
  37. package/dist/commands/release-train-shape.test.d.ts +2 -0
  38. package/dist/commands/release-train-shape.test.d.ts.map +1 -0
  39. package/dist/commands/release-train-shape.test.js +226 -0
  40. package/dist/commands/release-train-shape.test.js.map +1 -0
  41. package/dist/commands/spec-templates.d.ts +17 -4
  42. package/dist/commands/spec-templates.d.ts.map +1 -1
  43. package/dist/commands/spec-templates.js +23 -3
  44. package/dist/commands/spec-templates.js.map +1 -1
  45. package/dist/commands/spec.d.ts +57 -8
  46. package/dist/commands/spec.d.ts.map +1 -1
  47. package/dist/commands/spec.js +124 -28
  48. package/dist/commands/spec.js.map +1 -1
  49. package/dist/commands/spec.test.js +277 -44
  50. package/dist/commands/spec.test.js.map +1 -1
  51. package/dist/commands/tools-hook-parity.test.js +200 -0
  52. package/dist/commands/tools-hook-parity.test.js.map +1 -1
  53. package/dist/utils.d.ts +5 -3
  54. package/dist/utils.d.ts.map +1 -1
  55. package/dist/utils.js.map +1 -1
  56. package/dist/utils.test.js +8 -3
  57. package/dist/utils.test.js.map +1 -1
  58. package/package.json +2 -2
@@ -61,6 +61,62 @@ describe('checkCompiledRules', () => {
61
61
  });
62
62
  // ─── Git hooks check ────────────────────────────────────
63
63
  describe('checkGitHooks', () => {
64
+ // Shared by the currency cases (mmnto-ai/totem#2753 hoisted this out of the
65
+ // custom-totemDir describe: since the compare runs on EVERY install, the
66
+ // default-totemDir cases need the real canonical too). Uses the real builders,
67
+ // so a template change moves the fixture with the code.
68
+ async function installCanonical(dir, totemDir, tier = 'standard') {
69
+ const { buildPreCommitHook, buildPrePushHook, buildHookContent, buildPostCheckoutHookContent, getFallbackCommand, } = await import('./install-hooks.js');
70
+ const render = { tier, totemDir, fallbackCmd: getFallbackCommand(dir) };
71
+ const hooksDir = path.join(dir, '.git', 'hooks');
72
+ fs.mkdirSync(hooksDir, { recursive: true });
73
+ fs.writeFileSync(path.join(hooksDir, 'pre-commit'), buildPreCommitHook(render));
74
+ fs.writeFileSync(path.join(hooksDir, 'pre-push'), buildPrePushHook(render));
75
+ fs.writeFileSync(path.join(hooksDir, 'post-merge'), buildHookContent(render));
76
+ fs.writeFileSync(path.join(hooksDir, 'post-checkout'), buildPostCheckoutHookContent(render));
77
+ }
78
+ /** The measured liquid-city extension shape (`tools/git-hooks/install.cjs`,
79
+ * block 1): a `# [lc] …` comment line, THEN the attestation, then the
80
+ * extension's commands — joined the way install.cjs joins it. */
81
+ const ATTESTED_TRAILER = [
82
+ '',
83
+ '# [lc] docs-inject extension',
84
+ '# <!-- totem:fork reason="lc docs-inject pre-commit extension (divergence-census justified fork)" owner="satur8d" attested="2026-06-07" -->',
85
+ 'sh "tools/git-hooks/pre-commit-docs-inject.sh"',
86
+ '',
87
+ ].join('\n');
88
+ /** The canonical with ONE comment line inside the managed block altered. */
89
+ function withStaleComment(canonical, endMarker) {
90
+ const lines = canonical.split('\n');
91
+ const endLine = lines.findIndex((line) => line.includes(endMarker));
92
+ const commentLine = lines.findIndex((line, i) => i > 1 && i < endLine && line.trimStart().startsWith('#') && !line.includes('[totem]'));
93
+ if (commentLine === -1)
94
+ throw new Error('no alterable comment line inside the managed block');
95
+ lines[commentLine] = `${lines[commentLine]} (frozen at an older template)`;
96
+ return lines.join('\n');
97
+ }
98
+ /** The four hooks, each carrying `trailer` after its end marker — canonical, or
99
+ * (`stale`) one comment line behind it. */
100
+ async function installWithTrailer(dir, trailer, opts) {
101
+ const hooks = await import('./install-hooks.js');
102
+ const render = {
103
+ tier: 'standard',
104
+ totemDir: '.totem',
105
+ fallbackCmd: hooks.getFallbackCommand(dir),
106
+ };
107
+ const hooksDir = path.join(dir, '.git', 'hooks');
108
+ fs.mkdirSync(hooksDir, { recursive: true });
109
+ const roster = [
110
+ ['pre-commit', hooks.buildPreCommitHook(render), hooks.TOTEM_PRECOMMIT_END],
111
+ ['pre-push', hooks.buildPrePushHook(render), hooks.TOTEM_PREPUSH_END],
112
+ ['post-merge', hooks.buildHookContent(render), hooks.TOTEM_HOOK_END],
113
+ ['post-checkout', hooks.buildPostCheckoutHookContent(render), hooks.TOTEM_CHECKOUT_END],
114
+ ];
115
+ for (const [file, canonical, endMarker] of roster) {
116
+ const block = opts.stale ? withStaleComment(canonical, endMarker) : canonical;
117
+ fs.writeFileSync(path.join(hooksDir, file), block + trailer);
118
+ }
119
+ }
64
120
  it('returns skip when not a git repo', async () => {
65
121
  const tmpDir = makeTmpDir();
66
122
  try {
@@ -75,7 +131,6 @@ describe('checkGitHooks', () => {
75
131
  it('returns warn when hooks are missing in a git repo', async () => {
76
132
  const tmpDir = makeTmpDir();
77
133
  try {
78
- const { execSync } = require('node:child_process');
79
134
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
80
135
  const result = await checkGitHooks(tmpDir);
81
136
  expect(result.status).toBe('warn');
@@ -86,10 +141,26 @@ describe('checkGitHooks', () => {
86
141
  cleanTmpDir(tmpDir);
87
142
  }
88
143
  });
89
- it('returns pass when all hooks contain totem markers', async () => {
144
+ it('returns pass when all four managed blocks are the current canonical', async () => {
145
+ const tmpDir = makeTmpDir();
146
+ try {
147
+ execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
148
+ await installCanonical(tmpDir, '.totem');
149
+ const result = await checkGitHooks(tmpDir);
150
+ expect(result.status).toBe('pass');
151
+ expect(result.message).toContain('All 4 hooks');
152
+ }
153
+ finally {
154
+ cleanTmpDir(tmpDir);
155
+ }
156
+ });
157
+ // mmnto-ai/totem#2753: presence was never the question the row is asked. Until
158
+ // this slice the marker alone passed on the default totemDir, so a hook frozen at
159
+ // an older template read as healthy (`All 4 hooks installed` over two 1.121.0
160
+ // hooks under a 1.123.0 CLI, mmnto-ai/liquid-city#1174).
161
+ it('WARNs on marker-headed hooks whose managed block is not the canonical (default totemDir)', async () => {
90
162
  const tmpDir = makeTmpDir();
91
163
  try {
92
- const { execSync } = require('node:child_process');
93
164
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
94
165
  const hooksDir = path.join(tmpDir, '.git', 'hooks');
95
166
  fs.mkdirSync(hooksDir, { recursive: true });
@@ -103,8 +174,9 @@ describe('checkGitHooks', () => {
103
174
  fs.writeFileSync(path.join(hooksDir, file), `#!/bin/sh\n# ${marker}\necho ok`);
104
175
  }
105
176
  const result = await checkGitHooks(tmpDir);
106
- expect(result.status).toBe('pass');
107
- expect(result.message).toContain('All 4 hooks');
177
+ expect(result.status).toBe('warn');
178
+ expect(result.message).toContain("totemDir '.totem'");
179
+ expect(result.message).toContain('the managed block is stale');
108
180
  }
109
181
  finally {
110
182
  cleanTmpDir(tmpDir);
@@ -115,16 +187,6 @@ describe('checkGitHooks', () => {
115
187
  // `orient.parityManifest`, so the always-on row gains ONE conditional
116
188
  // whole-file compare, and ONLY when the repo configures a non-default dir.
117
189
  describe('custom totemDir (mmnto-ai/totem#2692 C6)', () => {
118
- async function installCanonical(dir, totemDir, tier = 'standard') {
119
- const { buildPreCommitHook, buildPrePushHook, buildHookContent, buildPostCheckoutHookContent, getFallbackCommand, } = await import('./install-hooks.js');
120
- const render = { tier, totemDir, fallbackCmd: getFallbackCommand(dir) };
121
- const hooksDir = path.join(dir, '.git', 'hooks');
122
- fs.mkdirSync(hooksDir, { recursive: true });
123
- fs.writeFileSync(path.join(hooksDir, 'pre-commit'), buildPreCommitHook(render));
124
- fs.writeFileSync(path.join(hooksDir, 'pre-push'), buildPrePushHook(render));
125
- fs.writeFileSync(path.join(hooksDir, 'post-merge'), buildHookContent(render));
126
- fs.writeFileSync(path.join(hooksDir, 'post-checkout'), buildPostCheckoutHookContent(render));
127
- }
128
190
  it('WARNs when the installed hooks were rendered for a different totemDir', async () => {
129
191
  const tmpDir = makeTmpDir();
130
192
  try {
@@ -153,24 +215,31 @@ describe('checkGitHooks', () => {
153
215
  cleanTmpDir(tmpDir);
154
216
  }
155
217
  });
156
- it('does NOT compare content on the default totemDir (marker-only, zero cost)', async () => {
218
+ // mmnto-ai/totem#2753 REPLACED the pre-#2753 assertion here ("does NOT compare
219
+ // content on the default totemDir") — that early return WAS the blind spot. The
220
+ // default is now compared like any other value, whether it is configured
221
+ // explicitly or left undefined.
222
+ it('compares content on the default totemDir too, configured explicitly or not', async () => {
157
223
  const tmpDir = makeTmpDir();
158
224
  try {
159
225
  execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
160
- const hooksDir = path.join(tmpDir, '.git', 'hooks');
161
- fs.mkdirSync(hooksDir, { recursive: true });
162
- // Marker-headed but nowhere near canonical — still a pass on the default,
163
- // exactly as before this slice.
164
- for (const [file, marker] of [
165
- ['pre-commit', '[totem] pre-commit hook'],
166
- ['pre-push', '[totem] pre-push hook'],
167
- ['post-merge', '[totem] post-merge hook'],
168
- ['post-checkout', '[totem] post-checkout hook'],
169
- ]) {
170
- fs.writeFileSync(path.join(hooksDir, file), `#!/bin/sh\n# ${marker}\necho ok`);
171
- }
226
+ await installCanonical(tmpDir, '.totem');
172
227
  expect((await checkGitHooks(tmpDir, { totemDir: '.totem' })).status).toBe('pass');
173
228
  expect((await checkGitHooks(tmpDir)).status).toBe('pass');
229
+ // One comment line behind → stale on BOTH spellings of the default.
230
+ const { buildPrePushHook, getFallbackCommand, TOTEM_PREPUSH_END } = await import('./install-hooks.js');
231
+ fs.writeFileSync(path.join(tmpDir, '.git', 'hooks', 'pre-push'), withStaleComment(buildPrePushHook({
232
+ tier: 'standard',
233
+ totemDir: '.totem',
234
+ fallbackCmd: getFallbackCommand(tmpDir),
235
+ }), TOTEM_PREPUSH_END));
236
+ for (const config of [{ totemDir: '.totem' }, undefined]) {
237
+ const result = await checkGitHooks(tmpDir, config);
238
+ expect(result.status).toBe('warn');
239
+ expect(result.message).toContain('pre-push');
240
+ expect(result.message).toContain('the managed block is stale');
241
+ expect(result.remediation).toBe('totem hook install --force');
242
+ }
174
243
  }
175
244
  finally {
176
245
  cleanTmpDir(tmpDir);
@@ -338,6 +407,353 @@ describe('checkGitHooks', () => {
338
407
  }
339
408
  });
340
409
  });
410
+ // ── Attested extensions on the DEFAULT totemDir (mmnto-ai/totem#2753) ──
411
+ //
412
+ // The measured liquid-city shape: hooks totem owns through their end marker, with
413
+ // an attested `totem:fork` extension after it. A bare `totem hook install` now
414
+ // cures these, so the row must both SEE them and prescribe the bare command.
415
+ describe('attested extensions (mmnto-ai/totem#2753)', () => {
416
+ it('WARNs on a stale block beside an attested trailer and prescribes the bare install', async () => {
417
+ const tmpDir = makeTmpDir();
418
+ try {
419
+ execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
420
+ await installWithTrailer(tmpDir, ATTESTED_TRAILER, { stale: true });
421
+ const result = await checkGitHooks(tmpDir, {});
422
+ expect(result.status).toBe('warn');
423
+ expect(result.message).toContain("totemDir '.totem'");
424
+ expect(result.message).toContain('the managed block is stale');
425
+ for (const file of ['pre-commit', 'pre-push', 'post-merge', 'post-checkout']) {
426
+ expect(result.message).toContain(file);
427
+ }
428
+ expect(result.remediation).toBe('totem hook install (the managed block is rewritten in place; your attested extension after the end marker is carried through unchanged)');
429
+ }
430
+ finally {
431
+ cleanTmpDir(tmpDir);
432
+ }
433
+ });
434
+ // The attested shape whose MANAGED REGION does not decode as UTF-8 (an ANSI-editor
435
+ // save turned the template's em dash into one 0x97 byte): the installer reports
436
+ // `skipped-non-utf8` and leaves the file alone, so this row must not prescribe
437
+ // the bare install it would decline — the inert-instruction rule, applied to the
438
+ // newest shape (re-armed leg F13 on mmnto-ai/totem#2760).
439
+ it('classifies a non-UTF-8 managed region as non-utf8 and prescribes re-saving, never the bare install', async () => {
440
+ const tmpDir = makeTmpDir();
441
+ try {
442
+ execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
443
+ await installWithTrailer(tmpDir, ATTESTED_TRAILER, { stale: true });
444
+ const preCommit = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
445
+ const bytes = fs.readFileSync(preCommit);
446
+ const emDash = Buffer.from([0xe2, 0x80, 0x94]);
447
+ const dashAt = bytes.indexOf(emDash);
448
+ expect(dashAt).toBeGreaterThan(-1);
449
+ fs.writeFileSync(preCommit, Buffer.concat([
450
+ bytes.subarray(0, dashAt),
451
+ Buffer.from([0x97]),
452
+ bytes.subarray(dashAt + emDash.length),
453
+ ]));
454
+ const result = await checkGitHooks(tmpDir, {});
455
+ expect(result.status).toBe('warn');
456
+ expect(result.remediation).toContain('re-save pre-commit as UTF-8');
457
+ expect(result.remediation).toContain('does not decode as UTF-8');
458
+ // The other three hooks are still the attested shape and keep the bare-install
459
+ // clause; pre-commit must be named ONLY in the re-save clause.
460
+ expect(result.remediation).toContain('totem hook install for pre-push, post-merge, post-checkout (');
461
+ // No install clause — bare or --force — may list pre-commit (re-armed leg F17:
462
+ // the earlier `[^;]*` form could not match any output of the composer).
463
+ expect(result.remediation).not.toMatch(/totem hook install(?: --force)? for [^(]*pre-commit/);
464
+ }
465
+ finally {
466
+ cleanTmpDir(tmpDir);
467
+ }
468
+ });
469
+ // The predicate covers the region ABOVE the extension — shebang line included —
470
+ // while the staleness compare covers the managed block only, so a bad byte on
471
+ // the shebang line alone leaves the block current. The installer still skips
472
+ // such a hook, so the row must say so rather than "All 4 hooks installed"
473
+ // (re-armed leg F16).
474
+ it('senses a non-decoding shebang line above a CURRENT block and prescribes re-saving', async () => {
475
+ const tmpDir = makeTmpDir();
476
+ try {
477
+ execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
478
+ await installWithTrailer(tmpDir, ATTESTED_TRAILER, { stale: false });
479
+ const preCommit = path.join(tmpDir, '.git', 'hooks', 'pre-commit');
480
+ const bytes = fs.readFileSync(preCommit);
481
+ const shebangEnd = bytes.indexOf(0x0a);
482
+ expect(bytes.subarray(0, 2).toString('utf-8')).toBe('#!');
483
+ fs.writeFileSync(preCommit, Buffer.concat([
484
+ bytes.subarray(0, shebangEnd),
485
+ Buffer.from([0x20, 0x97]), // " " + the em dash as one cp1252 byte, on the shebang line
486
+ bytes.subarray(shebangEnd),
487
+ ]));
488
+ const result = await checkGitHooks(tmpDir, {});
489
+ expect(result.status).toBe('warn');
490
+ // The headline names the real cause: this block IS the canonical, so "the
491
+ // managed block is stale (a newer hook template, or a config change)" would be
492
+ // two false statements about it (final leg, F20). Mixed sets keep the stale
493
+ // headline for the stale hooks and append the encoding clause.
494
+ expect(result.message).toContain('does not decode as UTF-8');
495
+ expect(result.message).not.toContain('the managed block is stale');
496
+ expect(result.remediation).toContain('re-save pre-commit as UTF-8');
497
+ expect(result.remediation).not.toMatch(/totem hook install(?: --force)? for [^(]*pre-commit/);
498
+ }
499
+ finally {
500
+ cleanTmpDir(tmpDir);
501
+ }
502
+ });
503
+ // The same case with the totemDir spelled out — this is precisely what the
504
+ // pre-#2753 early return hid.
505
+ it('WARNs identically when the default totemDir is configured explicitly', async () => {
506
+ const tmpDir = makeTmpDir();
507
+ try {
508
+ execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
509
+ await installWithTrailer(tmpDir, ATTESTED_TRAILER, { stale: true });
510
+ const result = await checkGitHooks(tmpDir, { totemDir: '.totem' });
511
+ expect(result.status).toBe('warn');
512
+ expect(result.remediation).toContain('totem hook install');
513
+ expect(result.remediation).toContain('your attested extension after the end marker is carried through unchanged');
514
+ expect(result.remediation).not.toContain('--force');
515
+ }
516
+ finally {
517
+ cleanTmpDir(tmpDir);
518
+ }
519
+ });
520
+ it('PASSES when the managed block is current beside an attested trailer', async () => {
521
+ const tmpDir = makeTmpDir();
522
+ try {
523
+ execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
524
+ await installWithTrailer(tmpDir, ATTESTED_TRAILER, { stale: false });
525
+ const result = await checkGitHooks(tmpDir, {});
526
+ expect(result.status).toBe('pass');
527
+ expect(result.message).toBe('All 4 hooks installed');
528
+ }
529
+ finally {
530
+ cleanTmpDir(tmpDir);
531
+ }
532
+ });
533
+ it('keeps the delete-and-re-append remedy for an UNATTESTED trailer', async () => {
534
+ const tmpDir = makeTmpDir();
535
+ try {
536
+ execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
537
+ await installWithTrailer(tmpDir, '# my own extension\necho "[me] hi"\n', { stale: true });
538
+ const result = await checkGitHooks(tmpDir, {});
539
+ expect(result.status).toBe('warn');
540
+ expect(result.remediation).toContain('delete the totem block');
541
+ expect(result.remediation).toContain('would overwrite your own hook content');
542
+ }
543
+ finally {
544
+ cleanTmpDir(tmpDir);
545
+ }
546
+ });
547
+ // The corner the classification is gated for: the trailer IS attested, but the
548
+ // user's own lines sit ABOVE the totem block, so the marker does not open the
549
+ // file and `installGitHook` declines. Prescribing the bare install here would
550
+ // ship an instruction that does nothing (mmnto-ai/totem#2532) — the row must fall
551
+ // through to the appended remedy, which does work on this file.
552
+ it('does NOT prescribe the carry-through for an attested trailer under a user PREAMBLE', async () => {
553
+ const tmpDir = makeTmpDir();
554
+ try {
555
+ execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
556
+ const { buildPreCommitHook, TOTEM_PRECOMMIT_END } = await import('./install-hooks.js');
557
+ // Three canonical hooks; pre-commit gets user lines ABOVE a stale block and
558
+ // an attested extension BELOW its end marker.
559
+ await installCanonical(tmpDir, '.totem');
560
+ const staleBlock = withStaleComment(buildPreCommitHook({ tier: 'standard', totemDir: '.totem' }), TOTEM_PRECOMMIT_END)
561
+ .replace(/^#!\/bin\/sh\n/, '')
562
+ .trimStart();
563
+ fs.writeFileSync(path.join(tmpDir, '.git', 'hooks', 'pre-commit'), `#!/bin/sh\necho "user pre-commit"\n\n${staleBlock}${ATTESTED_TRAILER}`);
564
+ const result = await checkGitHooks(tmpDir, {});
565
+ expect(result.status).toBe('warn');
566
+ expect(result.message).toContain('pre-commit');
567
+ expect(result.message).not.toContain('pre-push');
568
+ expect(result.remediation).toContain('delete the totem block');
569
+ expect(result.remediation).toContain('pre-commit');
570
+ expect(result.remediation).toContain('would overwrite your own hook content');
571
+ // The cure the installer would decline on this file is never named.
572
+ expect(result.remediation).not.toContain('carried through unchanged');
573
+ }
574
+ finally {
575
+ cleanTmpDir(tmpDir);
576
+ }
577
+ });
578
+ // ── Mixed shapes in one repo (fold F6/F8, narrowed on fold 3 F5) ──
579
+ //
580
+ // Each shape contributes its own clause. A filename is named once, EXCEPT that
581
+ // the legacy note keeps its names when the `--force` clause lists more than the
582
+ // legacy hooks — otherwise the reader cannot tell which of the hooks it just
583
+ // listed is the legacy one. The sibling test below covers the case where the
584
+ // clause named exactly the legacy file and the note therefore drops its names.
585
+ it('composes one clause per shape; the legacy note names its file when the --force clause lists more than the legacy hooks (legacy + owned-whole + attested)', async () => {
586
+ const tmpDir = makeTmpDir();
587
+ try {
588
+ execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
589
+ const hooks = await import('./install-hooks.js');
590
+ const render = {
591
+ tier: 'standard',
592
+ totemDir: '.totem',
593
+ fallbackCmd: hooks.getFallbackCommand(tmpDir),
594
+ };
595
+ const hooksDir = path.join(tmpDir, '.git', 'hooks');
596
+ fs.mkdirSync(hooksDir, { recursive: true });
597
+ // post-checkout stays canonical; the other three each take a shape.
598
+ fs.writeFileSync(path.join(hooksDir, 'post-checkout'), hooks.buildPostCheckoutHookContent(render));
599
+ // legacy: a stale block with its end-marker line removed.
600
+ fs.writeFileSync(path.join(hooksDir, 'pre-commit'), withStaleComment(hooks.buildPreCommitHook(render), hooks.TOTEM_PRECOMMIT_END)
601
+ .split('\n')
602
+ .filter((line) => !line.includes(hooks.TOTEM_PRECOMMIT_END))
603
+ .join('\n'));
604
+ // owned-whole: a stale block, nothing after the end marker.
605
+ fs.writeFileSync(path.join(hooksDir, 'pre-push'), withStaleComment(hooks.buildPrePushHook(render), hooks.TOTEM_PREPUSH_END));
606
+ // appended-attested: a stale block with the liquid-city extension after it.
607
+ fs.writeFileSync(path.join(hooksDir, 'post-merge'), withStaleComment(hooks.buildHookContent(render), hooks.TOTEM_HOOK_END) + ATTESTED_TRAILER);
608
+ const result = await checkGitHooks(tmpDir, {});
609
+ expect(result.status).toBe('warn');
610
+ const remediation = result.remediation ?? '';
611
+ // Three clauses, one per shape present.
612
+ expect(remediation).toContain('totem hook install');
613
+ expect(remediation).toContain('carried through unchanged');
614
+ expect(remediation).toContain('--force');
615
+ // The `--force` clause names BOTH forceable hooks, so the note must keep the
616
+ // legacy filename — otherwise the reader cannot tell which of the two is the
617
+ // legacy one (fold 3 F5). pre-commit therefore appears twice by design.
618
+ expect(remediation).toContain('`totem hook install --force` for pre-commit, pre-push');
619
+ expect(remediation).toContain('(pre-commit: a legacy hook with no end marker — back up any lines of your own first)');
620
+ expect(remediation.split('pre-commit').length - 1).toBe(2);
621
+ for (const file of ['pre-push', 'post-merge']) {
622
+ expect(remediation.split(file).length - 1).toBe(1);
623
+ }
624
+ // No plain `appended` row, so the trailing --force warning is absent.
625
+ expect(remediation).not.toContain('would overwrite your own hook content');
626
+ expect(remediation).not.toContain('delete the totem block');
627
+ }
628
+ finally {
629
+ cleanTmpDir(tmpDir);
630
+ }
631
+ });
632
+ // The complementary case: the `--force` clause names EXACTLY the legacy file, so
633
+ // repeating it in the note would read as two hooks. There the note drops names.
634
+ it('drops the legacy names from the note when the --force clause named exactly them', async () => {
635
+ const tmpDir = makeTmpDir();
636
+ try {
637
+ execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
638
+ const hooks = await import('./install-hooks.js');
639
+ const render = {
640
+ tier: 'standard',
641
+ totemDir: '.totem',
642
+ fallbackCmd: hooks.getFallbackCommand(tmpDir),
643
+ };
644
+ await installCanonical(tmpDir, '.totem');
645
+ const hooksDir = path.join(tmpDir, '.git', 'hooks');
646
+ // legacy pre-commit (the only forceable row) + attested pre-push.
647
+ fs.writeFileSync(path.join(hooksDir, 'pre-commit'), withStaleComment(hooks.buildPreCommitHook(render), hooks.TOTEM_PRECOMMIT_END)
648
+ .split('\n')
649
+ .filter((line) => !line.includes(hooks.TOTEM_PRECOMMIT_END))
650
+ .join('\n'));
651
+ fs.writeFileSync(path.join(hooksDir, 'pre-push'), withStaleComment(hooks.buildPrePushHook(render), hooks.TOTEM_PREPUSH_END) +
652
+ ATTESTED_TRAILER);
653
+ const result = await checkGitHooks(tmpDir, {});
654
+ const remediation = result.remediation ?? '';
655
+ expect(remediation).toContain('`totem hook install --force` for pre-commit');
656
+ expect(remediation).toContain('(a legacy hook with no end marker — back up any lines of your own first)');
657
+ expect(remediation).not.toContain('(pre-commit: a legacy hook');
658
+ expect(remediation.split('pre-commit').length - 1).toBe(1);
659
+ }
660
+ finally {
661
+ cleanTmpDir(tmpDir);
662
+ }
663
+ });
664
+ it('pluralizes the legacy note when more than one hook is legacy', async () => {
665
+ const tmpDir = makeTmpDir();
666
+ try {
667
+ execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
668
+ const hooks = await import('./install-hooks.js');
669
+ const render = {
670
+ tier: 'standard',
671
+ totemDir: '.totem',
672
+ fallbackCmd: hooks.getFallbackCommand(tmpDir),
673
+ };
674
+ await installCanonical(tmpDir, '.totem');
675
+ const hooksDir = path.join(tmpDir, '.git', 'hooks');
676
+ const stripEnd = (text, endMarker) => text
677
+ .split('\n')
678
+ .filter((line) => !line.includes(endMarker))
679
+ .join('\n');
680
+ fs.writeFileSync(path.join(hooksDir, 'pre-commit'), stripEnd(withStaleComment(hooks.buildPreCommitHook(render), hooks.TOTEM_PRECOMMIT_END), hooks.TOTEM_PRECOMMIT_END));
681
+ fs.writeFileSync(path.join(hooksDir, 'pre-push'), stripEnd(withStaleComment(hooks.buildPrePushHook(render), hooks.TOTEM_PREPUSH_END), hooks.TOTEM_PREPUSH_END));
682
+ const result = await checkGitHooks(tmpDir, {});
683
+ expect(result.status).toBe('warn');
684
+ // The two stale hooks are the only stale hooks, so the `--force` clause
685
+ // covers them all and names none — the note therefore carries the names, and
686
+ // reads as a plural.
687
+ expect(result.remediation).toBe('totem hook install --force (pre-commit, pre-push: legacy hooks with no end marker — back up any lines of your own first)');
688
+ expect(result.remediation).not.toContain('a legacy hook with no end marker');
689
+ }
690
+ finally {
691
+ cleanTmpDir(tmpDir);
692
+ }
693
+ });
694
+ it('composes two clauses and keeps the --force warning when appended and attested mix', async () => {
695
+ const tmpDir = makeTmpDir();
696
+ try {
697
+ execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
698
+ const hooks = await import('./install-hooks.js');
699
+ const render = {
700
+ tier: 'standard',
701
+ totemDir: '.totem',
702
+ fallbackCmd: hooks.getFallbackCommand(tmpDir),
703
+ };
704
+ await installCanonical(tmpDir, '.totem');
705
+ const hooksDir = path.join(tmpDir, '.git', 'hooks');
706
+ // appended: user lines above a stale block.
707
+ const staleBlock = withStaleComment(hooks.buildPreCommitHook({ tier: 'standard', totemDir: '.totem' }), hooks.TOTEM_PRECOMMIT_END)
708
+ .replace(/^#!\/bin\/sh\n/, '')
709
+ .trimStart();
710
+ fs.writeFileSync(path.join(hooksDir, 'pre-commit'), `#!/bin/sh\necho "user pre-commit"\n\n${staleBlock}`);
711
+ // appended-attested: a stale block with the liquid-city extension after it.
712
+ fs.writeFileSync(path.join(hooksDir, 'pre-push'), withStaleComment(hooks.buildPrePushHook(render), hooks.TOTEM_PREPUSH_END) +
713
+ ATTESTED_TRAILER);
714
+ const result = await checkGitHooks(tmpDir, {});
715
+ expect(result.status).toBe('warn');
716
+ const remediation = result.remediation ?? '';
717
+ expect(remediation).toContain('delete the totem block');
718
+ expect(remediation).toContain('carried through unchanged');
719
+ expect(remediation).toContain('would overwrite your own hook content');
720
+ expect(remediation).not.toContain('--force for');
721
+ for (const file of ['pre-commit', 'pre-push']) {
722
+ expect(remediation.split(file).length - 1).toBe(1);
723
+ }
724
+ }
725
+ finally {
726
+ cleanTmpDir(tmpDir);
727
+ }
728
+ });
729
+ });
730
+ // The honest could-not-compare arm (Tenet 13): when the installer module itself
731
+ // cannot load there is no canonical to compare against AND no default to name, so
732
+ // the row says so rather than passing on an unexamined tree or printing
733
+ // `totemDir 'undefined'` (fold F9).
734
+ it('warns "could not be compared" when the installer module fails to load', async () => {
735
+ const tmpDir = makeTmpDir();
736
+ try {
737
+ execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
738
+ await installCanonical(tmpDir, '.totem');
739
+ vi.resetModules();
740
+ vi.doMock('./install-hooks.js', () => {
741
+ throw new Error('boom: install-hooks unloadable');
742
+ });
743
+ const { checkGitHooks: freshCheckGitHooks } = await import('./doctor.js');
744
+ const result = await freshCheckGitHooks(tmpDir, {});
745
+ expect(result.status).toBe('warn');
746
+ expect(result.message).toContain('could not be compared');
747
+ // `totemDir` is resolved FROM the module that failed, so it stays unresolved.
748
+ expect(result.message).toContain("totemDir 'unresolved'");
749
+ expect(result.remediation).toContain('totem hook install --force');
750
+ }
751
+ finally {
752
+ vi.doUnmock('./install-hooks.js');
753
+ vi.resetModules();
754
+ cleanTmpDir(tmpDir);
755
+ }
756
+ });
341
757
  });
342
758
  // ─── Secret leak check ─────────────────────────────────
343
759
  describe('checkSecretLeaks', () => {