claudeup 6.2.1 → 6.3.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 (150) hide show
  1. package/README.md +22 -0
  2. package/bin/claudeup.js +6 -1
  3. package/package.json +6 -6
  4. package/src/__tests__/alias-adopt.test.ts +331 -329
  5. package/src/__tests__/alias-parser.test.ts +384 -376
  6. package/src/__tests__/alias-shell-writer.test.ts +638 -625
  7. package/src/__tests__/alias-store.test.ts +143 -143
  8. package/src/__tests__/catalog-cache-store.test.ts +265 -265
  9. package/src/__tests__/catalog-notice.test.ts +123 -123
  10. package/src/__tests__/cli-apply-seams.test.ts +12 -8
  11. package/src/__tests__/cli-router.test.ts +132 -3
  12. package/src/__tests__/cli-update-view.test.ts +27 -8
  13. package/src/__tests__/community-staleness.test.ts +0 -2
  14. package/src/__tests__/component-badge.test.ts +4 -4
  15. package/src/__tests__/content-drift.test.ts +5 -1
  16. package/src/__tests__/conventions-integration.test.ts +774 -689
  17. package/src/__tests__/conventions-manager.test.ts +1216 -1152
  18. package/src/__tests__/doctor-bins.test.ts +14 -6
  19. package/src/__tests__/doctor.test.ts +438 -425
  20. package/src/__tests__/dual-write-prevention.test.ts +277 -282
  21. package/src/__tests__/enabled-not-installed.test.ts +71 -63
  22. package/src/__tests__/file-locking.test.ts +196 -196
  23. package/src/__tests__/gap-fill-versions.test.ts +318 -311
  24. package/src/__tests__/github-budget.test.ts +170 -164
  25. package/src/__tests__/gitignore-detector.test.ts +160 -152
  26. package/src/__tests__/gitignore-fixer.test.ts +285 -247
  27. package/src/__tests__/gitignore-prerun.test.ts +57 -57
  28. package/src/__tests__/gitignore-resolver.test.ts +173 -146
  29. package/src/__tests__/gitignore-service.test.ts +121 -119
  30. package/src/__tests__/install-command.test.ts +3 -3
  31. package/src/__tests__/install-plan.test.ts +13 -7
  32. package/src/__tests__/manifest.test.ts +16 -5
  33. package/src/__tests__/markdown-renderer.test.ts +0 -1
  34. package/src/__tests__/marketplace-badge.test.ts +111 -90
  35. package/src/__tests__/marketplace-version.test.ts +64 -61
  36. package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
  37. package/src/__tests__/plugin-requires.test.ts +18 -4
  38. package/src/__tests__/plugin-setup.test.ts +12 -12
  39. package/src/__tests__/profile-command.test.ts +1 -7
  40. package/src/__tests__/profile-materializer.test.ts +31 -8
  41. package/src/__tests__/profile-sync.test.ts +23 -5
  42. package/src/__tests__/registry-version-resolution.test.ts +61 -47
  43. package/src/__tests__/resolve-executable.test.ts +1 -1
  44. package/src/__tests__/resolver.test.ts +38 -13
  45. package/src/__tests__/scope-action.test.ts +52 -50
  46. package/src/__tests__/scope-squares.test.tsx +4 -5
  47. package/src/__tests__/skill-install-files.test.ts +111 -103
  48. package/src/__tests__/symlink-manager.test.ts +27 -10
  49. package/src/__tests__/theme-env.test.ts +281 -0
  50. package/src/__tests__/theme-resolve.test.ts +380 -0
  51. package/src/__tests__/update-apply.test.ts +1 -3
  52. package/src/__tests__/useGitignoreModal.test.ts +69 -61
  53. package/src/__tests__/version-snapshot.test.ts +183 -165
  54. package/src/cli/ansi.ts +23 -15
  55. package/src/cli/doctor.ts +10 -9
  56. package/src/cli/install.ts +3 -3
  57. package/src/cli/profile.ts +5 -4
  58. package/src/cli/prompt.ts +0 -1
  59. package/src/cli/router.ts +69 -2
  60. package/src/cli/update-view.ts +11 -6
  61. package/src/cli/update.ts +9 -4
  62. package/src/cli/upgrade.ts +6 -4
  63. package/src/data/alias-flags.ts +260 -260
  64. package/src/data/gitignore-defaults.ts +20 -20
  65. package/src/data/gitignore-reasons.ts +86 -92
  66. package/src/data/gitignore-templates.ts +14 -14
  67. package/src/data/predefined-profiles.ts +1 -2
  68. package/src/data/skill-repos.ts +43 -35
  69. package/src/main.tsx +29 -6
  70. package/src/opentui.d.ts +1 -3
  71. package/src/prerunner/index.ts +7 -8
  72. package/src/services/alias-settings.ts +20 -23
  73. package/src/services/alias-shell-writer.ts +800 -803
  74. package/src/services/alias-store.ts +77 -77
  75. package/src/services/claude-settings.ts +27 -18
  76. package/src/services/content-drift.ts +13 -3
  77. package/src/services/conventions-manager.ts +692 -617
  78. package/src/services/doctor.ts +464 -403
  79. package/src/services/gitignore-detector.ts +113 -103
  80. package/src/services/gitignore-fixer.ts +204 -189
  81. package/src/services/gitignore-prerun.ts +26 -26
  82. package/src/services/gitignore-resolver.ts +162 -144
  83. package/src/services/gitignore-service.ts +120 -117
  84. package/src/services/install-plan.ts +4 -4
  85. package/src/services/manifest.ts +1 -1
  86. package/src/services/marketplace-catalog-git.ts +5 -2
  87. package/src/services/marketplace-fetcher.ts +241 -235
  88. package/src/services/plugin-manager.ts +17 -20
  89. package/src/services/plugin-mcp-config.ts +2 -2
  90. package/src/services/plugin-requires.ts +9 -6
  91. package/src/services/plugin-setup.ts +7 -9
  92. package/src/services/plugin-version-check.ts +4 -4
  93. package/src/services/profile-materializer.ts +9 -5
  94. package/src/services/profile-sync.ts +12 -4
  95. package/src/services/profiles.ts +3 -3
  96. package/src/services/resolver.ts +10 -6
  97. package/src/services/settings-manager.ts +82 -46
  98. package/src/services/skills-manager.ts +11 -9
  99. package/src/services/symlink-manager.ts +2 -5
  100. package/src/services/toolchain.ts +3 -3
  101. package/src/services/update-cache.ts +1 -1
  102. package/src/services/update-engine.ts +4 -4
  103. package/src/services/update-plan.ts +9 -11
  104. package/src/services/version-snapshot.ts +88 -88
  105. package/src/types/gitignore.ts +28 -28
  106. package/src/ui/App.tsx +36 -36
  107. package/src/ui/adapters/settingsAdapter.ts +2 -2
  108. package/src/ui/components/CategoryHeader.tsx +1 -1
  109. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  110. package/src/ui/components/ScopeIndicator.tsx +3 -1
  111. package/src/ui/components/ScrollableList.tsx +2 -1
  112. package/src/ui/components/StyledText.tsx +1 -1
  113. package/src/ui/components/layout/FooterHints.tsx +5 -3
  114. package/src/ui/components/layout/Panel.tsx +2 -2
  115. package/src/ui/components/layout/ScreenLayout.tsx +5 -4
  116. package/src/ui/components/modals/InputModal.tsx +7 -1
  117. package/src/ui/components/modals/ModalContainer.tsx +3 -3
  118. package/src/ui/components/modals/SelectModal.tsx +1 -1
  119. package/src/ui/components/primitives/DetailSection.tsx +1 -1
  120. package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
  121. package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
  122. package/src/ui/hooks/index.ts +0 -1
  123. package/src/ui/hooks/useGitignoreModal.ts +1 -5
  124. package/src/ui/hooks/useMismatchModal.ts +4 -10
  125. package/src/ui/renderers/cliToolRenderers.tsx +2 -2
  126. package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
  127. package/src/ui/renderers/mcpRenderers.tsx +1 -1
  128. package/src/ui/renderers/pluginRenderers.tsx +3 -9
  129. package/src/ui/renderers/profileRenderers.tsx +9 -8
  130. package/src/ui/renderers/settingsRenderers.tsx +4 -4
  131. package/src/ui/renderers/skillRenderers.tsx +14 -14
  132. package/src/ui/screens/AliasScreen.tsx +31 -34
  133. package/src/ui/screens/CliToolsScreen.tsx +14 -13
  134. package/src/ui/screens/EnvVarsScreen.tsx +8 -8
  135. package/src/ui/screens/GitignoreScreen.tsx +15 -14
  136. package/src/ui/screens/McpRegistryScreen.tsx +10 -7
  137. package/src/ui/screens/McpScreen.tsx +10 -10
  138. package/src/ui/screens/PluginsScreen.tsx +17 -21
  139. package/src/ui/screens/ProfilesScreen.tsx +25 -24
  140. package/src/ui/screens/SkillsScreen.tsx +16 -14
  141. package/src/ui/state/AppContext.tsx +4 -8
  142. package/src/ui/state/DimensionsContext.tsx +3 -2
  143. package/src/ui/theme-mode.ts +20 -38
  144. package/src/ui/theme-resolve.ts +148 -0
  145. package/src/utils/clipboard.ts +5 -5
  146. package/src/utils/command-utils.ts +2 -2
  147. package/src/utils/config-dir.ts +1 -3
  148. package/src/utils/file-locking.ts +88 -86
  149. package/src/ui/hooks/useAsyncData.ts +0 -127
  150. package/src/ui/state/AnimationContext.tsx +0 -76
@@ -23,30 +23,30 @@
23
23
  * the actual .missing property. This is a minor API deviation (richer return).
24
24
  */
25
25
 
26
- import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
27
- import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises';
28
- import { existsSync } from 'node:fs';
29
- import { join } from 'node:path';
30
- import { tmpdir } from 'node:os';
26
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
27
+ import { existsSync } from "node:fs";
28
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
31
31
 
32
32
  import {
33
- // Gitignore operations
34
- ensureGitignoreEntries,
35
- checkGitignoreEntries,
36
- // CLAUDE.md operations
37
- parseClaudeMdSections,
38
- injectClaudeMdSection,
39
- removeClaudeMdSection,
40
- listClaudeMdSections,
41
- // Convention resolution
42
- resolvePluginConventions,
43
- readConventionTemplate,
44
- // End-to-end convention application
45
- applyConventions,
46
- removeConventions,
47
- // Types
48
- type ParseResult,
49
- } from '../services/conventions-manager.js';
33
+ // Types
34
+ type ParseResult,
35
+ // End-to-end convention application
36
+ applyConventions,
37
+ checkGitignoreEntries,
38
+ // Gitignore operations
39
+ ensureGitignoreEntries,
40
+ injectClaudeMdSection,
41
+ listClaudeMdSections,
42
+ // CLAUDE.md operations
43
+ parseClaudeMdSections,
44
+ readConventionTemplate,
45
+ removeClaudeMdSection,
46
+ removeConventions,
47
+ // Convention resolution
48
+ resolvePluginConventions,
49
+ } from "../services/conventions-manager.js";
50
50
 
51
51
  // ---------------------------------------------------------------------------
52
52
  // Helpers
@@ -54,67 +54,67 @@ import {
54
54
 
55
55
  /** Create an isolated temp directory for test operations */
56
56
  async function createTempDir(): Promise<string> {
57
- return mkdtemp(join(tmpdir(), 'conventions-integ-'));
57
+ return mkdtemp(join(tmpdir(), "conventions-integ-"));
58
58
  }
59
59
 
60
60
  /** Create a mock plugin directory with plugin.json and optional template */
61
61
  async function createMockPlugin(
62
- baseDir: string,
63
- pluginName: string,
64
- opts: {
65
- gitignoreProject?: string[];
66
- gitignoreGlobal?: string[];
67
- claudemdSection?: string;
68
- claudemdVersion?: string;
69
- templateContent?: string;
70
- },
62
+ baseDir: string,
63
+ pluginName: string,
64
+ opts: {
65
+ gitignoreProject?: string[];
66
+ gitignoreGlobal?: string[];
67
+ claudemdSection?: string;
68
+ claudemdVersion?: string;
69
+ templateContent?: string;
70
+ },
71
71
  ): Promise<string> {
72
- const pluginDir = join(baseDir, pluginName);
73
- await mkdir(pluginDir, { recursive: true });
74
-
75
- const manifest: Record<string, unknown> = {
76
- name: pluginName,
77
- version: '1.0.0',
78
- description: `Test plugin ${pluginName}`,
79
- };
80
-
81
- const conventions: Record<string, unknown> = {};
82
-
83
- if (opts.gitignoreProject || opts.gitignoreGlobal) {
84
- const gitignore: Record<string, string[]> = {};
85
- if (opts.gitignoreProject) gitignore.project = opts.gitignoreProject;
86
- if (opts.gitignoreGlobal) gitignore.global = opts.gitignoreGlobal;
87
- conventions.gitignore = gitignore;
88
- }
89
-
90
- if (opts.claudemdSection && opts.claudemdVersion && opts.templateContent) {
91
- conventions.claudemd = {
92
- section: opts.claudemdSection,
93
- template: './templates/claude-md-conventions.md',
94
- version: opts.claudemdVersion,
95
- };
96
-
97
- // Create the template file
98
- const templateDir = join(pluginDir, 'templates');
99
- await mkdir(templateDir, { recursive: true });
100
- await writeFile(
101
- join(templateDir, 'claude-md-conventions.md'),
102
- opts.templateContent,
103
- 'utf-8',
104
- );
105
- }
106
-
107
- if (Object.keys(conventions).length > 0) {
108
- manifest.conventions = conventions;
109
- }
110
-
111
- await writeFile(
112
- join(pluginDir, 'plugin.json'),
113
- JSON.stringify(manifest, null, 2),
114
- 'utf-8',
115
- );
116
-
117
- return pluginDir;
72
+ const pluginDir = join(baseDir, pluginName);
73
+ await mkdir(pluginDir, { recursive: true });
74
+
75
+ const manifest: Record<string, unknown> = {
76
+ name: pluginName,
77
+ version: "1.0.0",
78
+ description: `Test plugin ${pluginName}`,
79
+ };
80
+
81
+ const conventions: Record<string, unknown> = {};
82
+
83
+ if (opts.gitignoreProject || opts.gitignoreGlobal) {
84
+ const gitignore: Record<string, string[]> = {};
85
+ if (opts.gitignoreProject) gitignore.project = opts.gitignoreProject;
86
+ if (opts.gitignoreGlobal) gitignore.global = opts.gitignoreGlobal;
87
+ conventions.gitignore = gitignore;
88
+ }
89
+
90
+ if (opts.claudemdSection && opts.claudemdVersion && opts.templateContent) {
91
+ conventions.claudemd = {
92
+ section: opts.claudemdSection,
93
+ template: "./templates/claude-md-conventions.md",
94
+ version: opts.claudemdVersion,
95
+ };
96
+
97
+ // Create the template file
98
+ const templateDir = join(pluginDir, "templates");
99
+ await mkdir(templateDir, { recursive: true });
100
+ await writeFile(
101
+ join(templateDir, "claude-md-conventions.md"),
102
+ opts.templateContent,
103
+ "utf-8",
104
+ );
105
+ }
106
+
107
+ if (Object.keys(conventions).length > 0) {
108
+ manifest.conventions = conventions;
109
+ }
110
+
111
+ await writeFile(
112
+ join(pluginDir, "plugin.json"),
113
+ JSON.stringify(manifest, null, 2),
114
+ "utf-8",
115
+ );
116
+
117
+ return pluginDir;
118
118
  }
119
119
 
120
120
  /**
@@ -123,623 +123,708 @@ async function createMockPlugin(
123
123
  * has a bug (see IMPLEMENTATION ISSUES above).
124
124
  */
125
125
  async function applyConventionsManually(
126
- pluginDir: string,
127
- projectDir: string,
126
+ pluginDir: string,
127
+ projectDir: string,
128
128
  ): Promise<{
129
- gitignore: { added: string[]; modified: boolean };
130
- claudemd: { action: string; section?: string };
129
+ gitignore: { added: string[]; modified: boolean };
130
+ claudemd: { action: string; section?: string };
131
131
  }> {
132
- const conventions = await resolvePluginConventions(pluginDir);
133
- if (!conventions) {
134
- return { gitignore: { added: [], modified: false }, claudemd: { action: 'skipped' } };
135
- }
136
-
137
- let gitignoreResult = { added: [] as string[], modified: false };
138
- if (conventions.gitignore?.project && conventions.gitignore.project.length > 0) {
139
- const gitignorePath = join(projectDir, '.gitignore');
140
- gitignoreResult = await ensureGitignoreEntries(gitignorePath, conventions.gitignore.project);
141
- }
142
-
143
- let claudemdResult: { action: string; section?: string } = { action: 'skipped' };
144
- if (conventions.claudemd) {
145
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
146
- const template = await readConventionTemplate(pluginDir, conventions.claudemd.template);
147
- const injectResult = await injectClaudeMdSection(
148
- claudeMdPath,
149
- conventions.claudemd.section,
150
- conventions.claudemd.version,
151
- template,
152
- );
153
- claudemdResult = { action: injectResult.action, section: conventions.claudemd.section };
154
- }
155
-
156
- return { gitignore: gitignoreResult, claudemd: claudemdResult };
132
+ const conventions = await resolvePluginConventions(pluginDir);
133
+ if (!conventions) {
134
+ return {
135
+ gitignore: { added: [], modified: false },
136
+ claudemd: { action: "skipped" },
137
+ };
138
+ }
139
+
140
+ let gitignoreResult = { added: [] as string[], modified: false };
141
+ if (
142
+ conventions.gitignore?.project &&
143
+ conventions.gitignore.project.length > 0
144
+ ) {
145
+ const gitignorePath = join(projectDir, ".gitignore");
146
+ gitignoreResult = await ensureGitignoreEntries(
147
+ gitignorePath,
148
+ conventions.gitignore.project,
149
+ );
150
+ }
151
+
152
+ let claudemdResult: { action: string; section?: string } = {
153
+ action: "skipped",
154
+ };
155
+ if (conventions.claudemd) {
156
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
157
+ const template = await readConventionTemplate(
158
+ pluginDir,
159
+ conventions.claudemd.template,
160
+ );
161
+ const injectResult = await injectClaudeMdSection(
162
+ claudeMdPath,
163
+ conventions.claudemd.section,
164
+ conventions.claudemd.version,
165
+ template,
166
+ );
167
+ claudemdResult = {
168
+ action: injectResult.action,
169
+ section: conventions.claudemd.section,
170
+ };
171
+ }
172
+
173
+ return { gitignore: gitignoreResult, claudemd: claudemdResult };
157
174
  }
158
175
 
159
176
  // ---------------------------------------------------------------------------
160
177
  // Tests
161
178
  // ---------------------------------------------------------------------------
162
179
 
163
- describe('Convention System Integration', () => {
164
- let tempDir: string;
165
- let projectDir: string;
166
-
167
- beforeEach(async () => {
168
- tempDir = await createTempDir();
169
- projectDir = join(tempDir, 'project');
170
- await mkdir(projectDir, { recursive: true });
171
- });
172
-
173
- afterEach(async () => {
174
- if (existsSync(tempDir)) {
175
- await rm(tempDir, { recursive: true, force: true });
176
- }
177
- });
178
-
179
- // =========================================================================
180
- // Category 1: End-to-End Convention Lifecycle
181
- // =========================================================================
182
-
183
- describe('End-to-End Convention Lifecycle', () => {
184
- it('TEST-1: install plugin with conventions updates .gitignore', async () => {
185
- const pluginDir = await createMockPlugin(tempDir, 'test-plugin', {
186
- gitignoreProject: ['.mnemex/', '.claude/sessions/'],
187
- claudemdSection: 'test-plugin',
188
- claudemdVersion: '1.0.0',
189
- templateContent: '## Test Plugin\n\nSome conventions here.\n',
190
- });
191
-
192
- const result = await applyConventionsManually(pluginDir, projectDir);
193
-
194
- // Gitignore should have been modified
195
- expect(result.gitignore.modified).toBe(true);
196
- expect(result.gitignore.added).toContain('.mnemex/');
197
- expect(result.gitignore.added).toContain('.claude/sessions/');
198
-
199
- // Verify file on disk
200
- const gitignorePath = join(projectDir, '.gitignore');
201
- expect(existsSync(gitignorePath)).toBe(true);
202
- const gitignoreContent = await readFile(gitignorePath, 'utf-8');
203
- expect(gitignoreContent).toContain('.mnemex/');
204
- expect(gitignoreContent).toContain('.claude/sessions/');
205
- });
206
-
207
- it('TEST-2: install plugin with conventions injects CLAUDE.md section', async () => {
208
- const pluginDir = await createMockPlugin(tempDir, 'test-plugin', {
209
- gitignoreProject: ['.mnemex/'],
210
- claudemdSection: 'test-plugin',
211
- claudemdVersion: '1.0.0',
212
- templateContent: '## Test Plugin Conventions\n\nDo not commit `.mnemex/`.\n',
213
- });
214
-
215
- const result = await applyConventionsManually(pluginDir, projectDir);
216
-
217
- // CLAUDE.md should have been injected
218
- expect(result.claudemd.action).toBe('inserted');
219
-
220
- // Verify file on disk
221
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
222
- expect(existsSync(claudeMdPath)).toBe(true);
223
- const claudeMdContent = await readFile(claudeMdPath, 'utf-8');
224
- expect(claudeMdContent).toContain('<!-- BEGIN magus:test-plugin v1.0.0 -->');
225
- expect(claudeMdContent).toContain('<!-- END magus:test-plugin -->');
226
- expect(claudeMdContent).toContain('Test Plugin Conventions');
227
- expect(claudeMdContent).toContain('Do not commit `.mnemex/`.');
228
- });
229
-
230
- it('TEST-3: uninstall removes CLAUDE.md section but keeps gitignore entries', async () => {
231
- const pluginDir = await createMockPlugin(tempDir, 'test-plugin', {
232
- gitignoreProject: ['.mnemex/', '.claude/sessions/'],
233
- claudemdSection: 'test-plugin',
234
- claudemdVersion: '1.0.0',
235
- templateContent: '## Test Plugin Conventions\n\nContent here.\n',
236
- });
237
-
238
- // Apply conventions manually (since applyConventions has a bug)
239
- await applyConventionsManually(pluginDir, projectDir);
240
-
241
- // Verify both files exist
242
- const gitignorePath = join(projectDir, '.gitignore');
243
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
244
- expect(existsSync(gitignorePath)).toBe(true);
245
- expect(existsSync(claudeMdPath)).toBe(true);
246
-
247
- // Remove CLAUDE.md section directly (removeConventions has same bug as applyConventions)
248
- const removeResult = await removeClaudeMdSection(claudeMdPath, 'test-plugin');
249
- expect(removeResult.removed).toBe(true);
250
-
251
- // CLAUDE.md section should be gone
252
- const claudeMdContent = await readFile(claudeMdPath, 'utf-8');
253
- expect(claudeMdContent).not.toContain('<!-- BEGIN magus:test-plugin');
254
- expect(claudeMdContent).not.toContain('<!-- END magus:test-plugin -->');
255
- expect(claudeMdContent).not.toContain('Test Plugin Conventions');
256
-
257
- // .gitignore entries MUST still be present (by design)
258
- const gitignoreContent = await readFile(gitignorePath, 'utf-8');
259
- expect(gitignoreContent).toContain('.mnemex/');
260
- expect(gitignoreContent).toContain('.claude/sessions/');
261
- });
262
- });
263
-
264
- // =========================================================================
265
- // Category 2: Idempotency
266
- // =========================================================================
267
-
268
- describe('Idempotency', () => {
269
- it('TEST-4: applying conventions twice produces identical result', async () => {
270
- const pluginDir = await createMockPlugin(tempDir, 'test-plugin', {
271
- gitignoreProject: ['.mnemex/'],
272
- claudemdSection: 'test-plugin',
273
- claudemdVersion: '1.0.0',
274
- templateContent: '## Test Conventions\n\nContent.\n',
275
- });
276
-
277
- // First apply (using manual helper)
278
- const result1 = await applyConventionsManually(pluginDir, projectDir);
279
- expect(result1.gitignore.modified).toBe(true);
280
- expect(result1.claudemd.action).toBe('inserted');
281
-
282
- // Snapshot file contents after first apply
283
- const gitignorePath = join(projectDir, '.gitignore');
284
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
285
- const gitignoreAfterFirst = await readFile(gitignorePath, 'utf-8');
286
- const claudeMdAfterFirst = await readFile(claudeMdPath, 'utf-8');
287
-
288
- // Second apply
289
- const result2 = await applyConventionsManually(pluginDir, projectDir);
290
- expect(result2.gitignore.modified).toBe(false);
291
- expect(result2.claudemd.action).toBe('unchanged');
292
-
293
- // File contents must be identical
294
- const gitignoreAfterSecond = await readFile(gitignorePath, 'utf-8');
295
- const claudeMdAfterSecond = await readFile(claudeMdPath, 'utf-8');
296
- expect(gitignoreAfterSecond).toBe(gitignoreAfterFirst);
297
- expect(claudeMdAfterSecond).toBe(claudeMdAfterFirst);
298
- });
299
-
300
- it('TEST-5: ensureGitignoreEntries is idempotent', async () => {
301
- const gitignorePath = join(projectDir, '.gitignore');
302
-
303
- // First call
304
- const result1 = await ensureGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
305
- expect(result1.modified).toBe(true);
306
- expect(result1.added.length).toBe(2);
307
-
308
- const contentAfterFirst = await readFile(gitignorePath, 'utf-8');
309
-
310
- // Second call with same entries
311
- const result2 = await ensureGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
312
- expect(result2.modified).toBe(false);
313
- expect(result2.added.length).toBe(0);
314
-
315
- const contentAfterSecond = await readFile(gitignorePath, 'utf-8');
316
- expect(contentAfterSecond).toBe(contentAfterFirst);
317
- });
318
-
319
- it('TEST-6: injectClaudeMdSection with same version is idempotent', async () => {
320
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
321
- const content = '## Test Section\n\nSome content.\n';
322
-
323
- // First inject
324
- const result1 = await injectClaudeMdSection(claudeMdPath, 'test-plugin', '1.0.0', content);
325
- expect(result1.action).toBe('inserted');
326
-
327
- // Second inject with same version and content
328
- const result2 = await injectClaudeMdSection(claudeMdPath, 'test-plugin', '1.0.0', content);
329
- expect(result2.action).toBe('unchanged');
330
- });
331
- });
332
-
333
- // =========================================================================
334
- // Category 3: Version Upgrade
335
- // =========================================================================
336
-
337
- describe('Version Upgrade', () => {
338
- it('TEST-7: applying v2.0.0 over v1.0.0 updates CLAUDE.md content', async () => {
339
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
340
- const v1Content = '## V1 Content\n\nOriginal version.\n';
341
- const v2Content = '## V2 Content\n\nUpdated version with new features.\n';
342
-
343
- // Inject v1
344
- const result1 = await injectClaudeMdSection(claudeMdPath, 'test-plugin', '1.0.0', v1Content);
345
- expect(result1.action).toBe('inserted');
346
-
347
- // Inject v2
348
- const result2 = await injectClaudeMdSection(claudeMdPath, 'test-plugin', '2.0.0', v2Content);
349
- expect(result2.action).toBe('updated');
350
- expect(result2.previousVersion).toBe('1.0.0');
351
- });
352
-
353
- it('TEST-8: version marker is updated in BEGIN tag after upgrade', async () => {
354
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
355
- const v1Content = '## V1 Content\n\nOriginal.\n';
356
- const v2Content = '## V2 Content\n\nNew stuff.\n';
357
-
358
- await injectClaudeMdSection(claudeMdPath, 'test-plugin', '1.0.0', v1Content);
359
- await injectClaudeMdSection(claudeMdPath, 'test-plugin', '2.0.0', v2Content);
360
-
361
- const fileContent = await readFile(claudeMdPath, 'utf-8');
362
-
363
- // Should have v2 marker, not v1
364
- expect(fileContent).toContain('<!-- BEGIN magus:test-plugin v2.0.0 -->');
365
- expect(fileContent).not.toContain('<!-- BEGIN magus:test-plugin v1.0.0 -->');
366
-
367
- // Should have v2 content, not v1
368
- expect(fileContent).toContain('V2 Content');
369
- expect(fileContent).toContain('New stuff.');
370
- expect(fileContent).not.toContain('V1 Content');
371
- expect(fileContent).not.toContain('Original.');
372
- });
373
- });
374
-
375
- // =========================================================================
376
- // Category 4: Multiple Plugins
377
- // =========================================================================
378
-
379
- describe('Multiple Plugins', () => {
380
- it('TEST-9: two plugins can coexist in CLAUDE.md', async () => {
381
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
382
-
383
- const contentA = '## Plugin A Conventions\n\nPlugin A content.\n';
384
- const contentB = '## Plugin B Conventions\n\nPlugin B content.\n';
385
-
386
- await injectClaudeMdSection(claudeMdPath, 'plugin-a', '1.0.0', contentA);
387
- await injectClaudeMdSection(claudeMdPath, 'plugin-b', '1.0.0', contentB);
388
-
389
- const fileContent = await readFile(claudeMdPath, 'utf-8');
390
-
391
- // Both sections present
392
- expect(fileContent).toContain('<!-- BEGIN magus:plugin-a v1.0.0 -->');
393
- expect(fileContent).toContain('<!-- END magus:plugin-a -->');
394
- expect(fileContent).toContain('Plugin A content.');
395
-
396
- expect(fileContent).toContain('<!-- BEGIN magus:plugin-b v1.0.0 -->');
397
- expect(fileContent).toContain('<!-- END magus:plugin-b -->');
398
- expect(fileContent).toContain('Plugin B content.');
399
-
400
- // List should show both
401
- const sections = await listClaudeMdSections(claudeMdPath);
402
- expect(sections.length).toBe(2);
403
- const sectionNames = sections.map((s) => s.section);
404
- expect(sectionNames).toContain('plugin-a');
405
- expect(sectionNames).toContain('plugin-b');
406
- });
407
-
408
- it('TEST-10: removing one plugin leaves the other intact', async () => {
409
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
410
-
411
- const contentA = '## Plugin A Conventions\n\nPlugin A content.\n';
412
- const contentB = '## Plugin B Conventions\n\nPlugin B content.\n';
413
-
414
- await injectClaudeMdSection(claudeMdPath, 'plugin-a', '1.0.0', contentA);
415
- await injectClaudeMdSection(claudeMdPath, 'plugin-b', '1.0.0', contentB);
416
-
417
- // Remove plugin-a
418
- const removeResult = await removeClaudeMdSection(claudeMdPath, 'plugin-a');
419
- expect(removeResult.removed).toBe(true);
420
-
421
- const fileContent = await readFile(claudeMdPath, 'utf-8');
422
-
423
- // Plugin A should be gone
424
- expect(fileContent).not.toContain('<!-- BEGIN magus:plugin-a');
425
- expect(fileContent).not.toContain('Plugin A content.');
426
-
427
- // Plugin B should still be there
428
- expect(fileContent).toContain('<!-- BEGIN magus:plugin-b v1.0.0 -->');
429
- expect(fileContent).toContain('<!-- END magus:plugin-b -->');
430
- expect(fileContent).toContain('Plugin B content.');
431
- });
432
-
433
- it('TEST-11: two plugins with different gitignore entries both present', async () => {
434
- const gitignorePath = join(projectDir, '.gitignore');
435
-
436
- await ensureGitignoreEntries(gitignorePath, ['.mnemex/'], '# Plugin A');
437
- await ensureGitignoreEntries(gitignorePath, ['.claudemem/'], '# Plugin B');
438
-
439
- const content = await readFile(gitignorePath, 'utf-8');
440
- expect(content).toContain('.mnemex/');
441
- expect(content).toContain('.claudemem/');
442
-
443
- // No duplicates -- adding same entries again should not duplicate
444
- await ensureGitignoreEntries(gitignorePath, ['.mnemex/']);
445
- const contentAfter = await readFile(gitignorePath, 'utf-8');
446
- const mnemexOccurrences = contentAfter.split('.mnemex/').length - 1;
447
- expect(mnemexOccurrences).toBe(1);
448
- });
449
- });
450
-
451
- // =========================================================================
452
- // Category 5: Doctor Detection
453
- // =========================================================================
454
-
455
- describe('Doctor Detection', () => {
456
- // Note: checkGitignoreEntries returns { present: string[], missing: string[] }
457
- // instead of string[] as documented in the architecture. This is an
458
- // IMPLEMENTATION_ISSUE (API deviation), but the richer return type is a
459
- // superset. Tests use .missing property to access the missing entries.
460
-
461
- it('TEST-12: checkGitignoreEntries detects missing entries', async () => {
462
- const gitignorePath = join(projectDir, '.gitignore');
463
- // Create gitignore with only one entry
464
- await writeFile(gitignorePath, '.mnemex/\n', 'utf-8');
465
-
466
- const result = await checkGitignoreEntries(gitignorePath, [
467
- '.mnemex/',
468
- '.claudemem/',
469
- '.claude/sessions/',
470
- ]);
471
-
472
- // API returns { present, missing } object instead of string[] per contract
473
- const resultObj = result as unknown as { present: string[]; missing: string[] };
474
-
475
- expect(resultObj.missing).toContain('.claudemem/');
476
- expect(resultObj.missing).toContain('.claude/sessions/');
477
- expect(resultObj.missing).not.toContain('.mnemex/');
478
- expect(resultObj.missing.length).toBe(2);
479
-
480
- // Also verify present entries
481
- expect(resultObj.present).toContain('.mnemex/');
482
- expect(resultObj.present.length).toBe(1);
483
- });
484
-
485
- it('TEST-13: missing CLAUDE.md section is detectable via listClaudeMdSections', async () => {
486
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
487
- // Create CLAUDE.md with existing content but no managed sections
488
- await writeFile(claudeMdPath, '# Project CLAUDE.md\n\nSome existing content.\n', 'utf-8');
489
-
490
- const sections = await listClaudeMdSections(claudeMdPath);
491
- expect(sections.length).toBe(0);
492
- });
493
-
494
- it('TEST-14: ensureGitignoreEntries fixes missing entries detected by check', async () => {
495
- const gitignorePath = join(projectDir, '.gitignore');
496
- await writeFile(gitignorePath, '.mnemex/\n', 'utf-8');
497
-
498
- // Detect missing using the actual return type
499
- const checkResult = await checkGitignoreEntries(gitignorePath, [
500
- '.mnemex/',
501
- '.claudemem/',
502
- '.claude/sessions/',
503
- ]);
504
- const missing = (checkResult as unknown as { missing: string[] }).missing;
505
- expect(missing.length).toBe(2);
506
-
507
- // Fix by adding missing entries
508
- const fixResult = await ensureGitignoreEntries(gitignorePath, missing);
509
- expect(fixResult.modified).toBe(true);
510
- expect(fixResult.added.length).toBe(2);
511
-
512
- // Re-check: should now all be present
513
- const recheck = await checkGitignoreEntries(gitignorePath, [
514
- '.mnemex/',
515
- '.claudemem/',
516
- '.claude/sessions/',
517
- ]);
518
- const stillMissing = (recheck as unknown as { missing: string[] }).missing;
519
- expect(stillMissing.length).toBe(0);
520
- });
521
- });
522
-
523
- // =========================================================================
524
- // Category 6: Malformed CLAUDE.md Handling
525
- // =========================================================================
526
-
527
- describe('Malformed CLAUDE.md Handling', () => {
528
- it('TEST-15: inject refuses when CLAUDE.md has missing END marker', async () => {
529
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
530
- // Write a malformed file: BEGIN without END
531
- const malformedContent = [
532
- '# Project Notes',
533
- '',
534
- '<!-- BEGIN magus:broken-plugin v1.0.0 -->',
535
- '## Broken Plugin',
536
- '',
537
- 'This section has no END marker.',
538
- '',
539
- '# More content below',
540
- '',
541
- ].join('\n');
542
- await writeFile(claudeMdPath, malformedContent, 'utf-8');
543
-
544
- // Attempting to inject a NEW section should be refused
545
- const result = await injectClaudeMdSection(
546
- claudeMdPath,
547
- 'new-plugin',
548
- '1.0.0',
549
- '## New Plugin\n\nContent.\n',
550
- );
551
- expect(result.action).toBe('error');
552
- expect(result.error).toBeDefined();
553
- expect(typeof result.error).toBe('string');
554
- });
555
-
556
- it('TEST-16: parse detects duplicate sections', async () => {
557
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
558
- const duplicateContent = [
559
- '# Project',
560
- '',
561
- '<!-- BEGIN magus:test-plugin v1.0.0 -->',
562
- '## First instance',
563
- '<!-- END magus:test-plugin -->',
564
- '',
565
- '<!-- BEGIN magus:test-plugin v1.0.0 -->',
566
- '## Second instance (duplicate!)',
567
- '<!-- END magus:test-plugin -->',
568
- '',
569
- ].join('\n');
570
- await writeFile(claudeMdPath, duplicateContent, 'utf-8');
571
-
572
- const parseResult: ParseResult = await parseClaudeMdSections(claudeMdPath);
573
- expect(parseResult.errors.length).toBeGreaterThan(0);
574
-
575
- const duplicateError = parseResult.errors.find(
576
- (e) => e.type === 'duplicate_section',
577
- );
578
- expect(duplicateError).toBeDefined();
579
- if (duplicateError) {
580
- expect(duplicateError.section).toBe('test-plugin');
581
- }
582
- });
583
-
584
- it('TEST-17: parse ignores markers inside code blocks', async () => {
585
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
586
- const contentWithCodeBlock = [
587
- '# Documentation',
588
- '',
589
- 'Here is an example of markers:',
590
- '',
591
- '```markdown',
592
- '<!-- BEGIN magus:example v1.0.0 -->',
593
- '## Example section',
594
- '<!-- END magus:example -->',
595
- '```',
596
- '',
597
- 'The above markers are inside a code block and should be ignored.',
598
- '',
599
- ].join('\n');
600
- await writeFile(claudeMdPath, contentWithCodeBlock, 'utf-8');
601
-
602
- const parseResult = await parseClaudeMdSections(claudeMdPath);
603
-
604
- // Markers inside code blocks should NOT be treated as real sections
605
- const realSections = parseResult.sections.filter(
606
- (s) => !s.insideCodeBlock,
607
- );
608
- expect(realSections.length).toBe(0);
609
-
610
- // If the parser reports code-block markers, check the error type
611
- if (parseResult.errors.length > 0) {
612
- const codeBlockErrors = parseResult.errors.filter(
613
- (e) => e.type === 'marker_in_code_block',
614
- );
615
- // It's valid for the parser to either:
616
- // 1. Report marker_in_code_block errors, OR
617
- // 2. Simply ignore them (no sections, no errors)
618
- // Both behaviors comply with the architecture spec.
619
- if (codeBlockErrors.length > 0) {
620
- expect(codeBlockErrors[0].section).toBe('example');
621
- }
622
- }
623
- });
624
- });
625
-
626
- // =========================================================================
627
- // Category 7: Path Classification (Gitignore Entries)
628
- // =========================================================================
629
-
630
- describe('Path Classification', () => {
631
- it('TEST-18: all standard private paths can be added to gitignore', async () => {
632
- const gitignorePath = join(projectDir, '.gitignore');
633
- const privatePaths = [
634
- '.mnemex/',
635
- '.claudemem/',
636
- '.claude/sessions/',
637
- '.claude/worktrees/',
638
- ];
639
-
640
- const result = await ensureGitignoreEntries(
641
- gitignorePath,
642
- privatePaths,
643
- '# Claude Code ephemeral/machine-specific data',
644
- );
645
-
646
- expect(result.modified).toBe(true);
647
- expect(result.added.length).toBe(4);
648
-
649
- const content = await readFile(gitignorePath, 'utf-8');
650
- for (const path of privatePaths) {
651
- expect(content).toContain(path);
652
- }
653
- });
654
-
655
- it('TEST-19: checkGitignoreEntries reports correct missing set', async () => {
656
- const gitignorePath = join(projectDir, '.gitignore');
657
- // Only .mnemex/ exists
658
- await writeFile(gitignorePath, '.mnemex/\n', 'utf-8');
659
-
660
- const allPaths = [
661
- '.mnemex/',
662
- '.claudemem/',
663
- '.claude/sessions/',
664
- '.claude/worktrees/',
665
- ];
666
-
667
- const result = await checkGitignoreEntries(gitignorePath, allPaths);
668
- // API returns { present, missing } instead of string[] (see IMPLEMENTATION_ISSUE note)
669
- const resultObj = result as unknown as { present: string[]; missing: string[] };
670
-
671
- expect(resultObj.missing.length).toBe(3);
672
- expect(resultObj.missing).toContain('.claudemem/');
673
- expect(resultObj.missing).toContain('.claude/sessions/');
674
- expect(resultObj.missing).toContain('.claude/worktrees/');
675
- expect(resultObj.missing).not.toContain('.mnemex/');
676
- });
677
-
678
- it('TEST-20: gitignore entries survive plugin uninstall', async () => {
679
- const pluginDir = await createMockPlugin(tempDir, 'test-plugin', {
680
- gitignoreProject: ['.mnemex/', '.claude/sessions/'],
681
- claudemdSection: 'test-plugin',
682
- claudemdVersion: '1.0.0',
683
- templateContent: '## Test\n\nContent.\n',
684
- });
685
-
686
- // Apply conventions manually (since applyConventions has a bug)
687
- await applyConventionsManually(pluginDir, projectDir);
688
-
689
- const gitignorePath = join(projectDir, '.gitignore');
690
- const gitignoreBefore = await readFile(gitignorePath, 'utf-8');
691
- expect(gitignoreBefore).toContain('.mnemex/');
692
- expect(gitignoreBefore).toContain('.claude/sessions/');
693
-
694
- // Remove conventions via public API
695
- await removeConventions('test-plugin', projectDir);
696
-
697
- // Gitignore entries MUST still be there (by design)
698
- const gitignoreAfter = await readFile(gitignorePath, 'utf-8');
699
- expect(gitignoreAfter).toContain('.mnemex/');
700
- expect(gitignoreAfter).toContain('.claude/sessions/');
701
- });
702
- });
703
-
704
- // =========================================================================
705
- // IMPLEMENTATION_ISSUE: applyConventions() regression tests
706
- // These tests SHOULD pass when the bug in applyConventions() is fixed.
707
- // They currently fail because applyConventions() silently skips all work.
708
- // =========================================================================
709
-
710
- describe('applyConventions orchestration (IMPLEMENTATION_ISSUE)', () => {
711
- it('TEST-1-A: applyConventions should apply gitignore entries', async () => {
712
- const pluginDir = await createMockPlugin(tempDir, 'apply-test', {
713
- gitignoreProject: ['.mnemex/', '.claude/sessions/'],
714
- claudemdSection: 'apply-test',
715
- claudemdVersion: '1.0.0',
716
- templateContent: '## Test Plugin\n\nConventions.\n',
717
- });
718
-
719
- const result = await applyConventions(pluginDir, projectDir);
720
- expect(result.gitignore.modified).toBe(true);
721
- expect(result.gitignore.added).toContain('.mnemex/');
722
- });
723
-
724
- it('TEST-2-A: applyConventions should inject CLAUDE.md section', async () => {
725
- const pluginDir = await createMockPlugin(tempDir, 'apply-test', {
726
- claudemdSection: 'apply-test',
727
- claudemdVersion: '1.0.0',
728
- templateContent: '## Test Plugin\n\nConventions.\n',
729
- });
730
-
731
- const result = await applyConventions(pluginDir, projectDir);
732
- expect(result.claudemd.action).toBe('inserted');
733
- });
734
-
735
- it('TEST-3-A: removeConventions should remove CLAUDE.md section', async () => {
736
- // First create a CLAUDE.md with a section using the working low-level API
737
- const claudeMdPath = join(projectDir, 'CLAUDE.md');
738
- await injectClaudeMdSection(claudeMdPath, 'test-plugin', '1.0.0', '## Test\n\nContent.\n');
739
-
740
- // removeConventions should find and remove the section
741
- const result = await removeConventions('test-plugin', projectDir);
742
- expect(result.claudemd.removed).toBe(true);
743
- });
744
- });
180
+ describe("Convention System Integration", () => {
181
+ let tempDir: string;
182
+ let projectDir: string;
183
+
184
+ beforeEach(async () => {
185
+ tempDir = await createTempDir();
186
+ projectDir = join(tempDir, "project");
187
+ await mkdir(projectDir, { recursive: true });
188
+ });
189
+
190
+ afterEach(async () => {
191
+ if (existsSync(tempDir)) {
192
+ await rm(tempDir, { recursive: true, force: true });
193
+ }
194
+ });
195
+
196
+ // =========================================================================
197
+ // Category 1: End-to-End Convention Lifecycle
198
+ // =========================================================================
199
+
200
+ describe("End-to-End Convention Lifecycle", () => {
201
+ it("TEST-1: install plugin with conventions updates .gitignore", async () => {
202
+ const pluginDir = await createMockPlugin(tempDir, "test-plugin", {
203
+ gitignoreProject: [".mnemex/", ".claude/sessions/"],
204
+ claudemdSection: "test-plugin",
205
+ claudemdVersion: "1.0.0",
206
+ templateContent: "## Test Plugin\n\nSome conventions here.\n",
207
+ });
208
+
209
+ const result = await applyConventionsManually(pluginDir, projectDir);
210
+
211
+ // Gitignore should have been modified
212
+ expect(result.gitignore.modified).toBe(true);
213
+ expect(result.gitignore.added).toContain(".mnemex/");
214
+ expect(result.gitignore.added).toContain(".claude/sessions/");
215
+
216
+ // Verify file on disk
217
+ const gitignorePath = join(projectDir, ".gitignore");
218
+ expect(existsSync(gitignorePath)).toBe(true);
219
+ const gitignoreContent = await readFile(gitignorePath, "utf-8");
220
+ expect(gitignoreContent).toContain(".mnemex/");
221
+ expect(gitignoreContent).toContain(".claude/sessions/");
222
+ });
223
+
224
+ it("TEST-2: install plugin with conventions injects CLAUDE.md section", async () => {
225
+ const pluginDir = await createMockPlugin(tempDir, "test-plugin", {
226
+ gitignoreProject: [".mnemex/"],
227
+ claudemdSection: "test-plugin",
228
+ claudemdVersion: "1.0.0",
229
+ templateContent:
230
+ "## Test Plugin Conventions\n\nDo not commit `.mnemex/`.\n",
231
+ });
232
+
233
+ const result = await applyConventionsManually(pluginDir, projectDir);
234
+
235
+ // CLAUDE.md should have been injected
236
+ expect(result.claudemd.action).toBe("inserted");
237
+
238
+ // Verify file on disk
239
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
240
+ expect(existsSync(claudeMdPath)).toBe(true);
241
+ const claudeMdContent = await readFile(claudeMdPath, "utf-8");
242
+ expect(claudeMdContent).toContain(
243
+ "<!-- BEGIN magus:test-plugin v1.0.0 -->",
244
+ );
245
+ expect(claudeMdContent).toContain("<!-- END magus:test-plugin -->");
246
+ expect(claudeMdContent).toContain("Test Plugin Conventions");
247
+ expect(claudeMdContent).toContain("Do not commit `.mnemex/`.");
248
+ });
249
+
250
+ it("TEST-3: uninstall removes CLAUDE.md section but keeps gitignore entries", async () => {
251
+ const pluginDir = await createMockPlugin(tempDir, "test-plugin", {
252
+ gitignoreProject: [".mnemex/", ".claude/sessions/"],
253
+ claudemdSection: "test-plugin",
254
+ claudemdVersion: "1.0.0",
255
+ templateContent: "## Test Plugin Conventions\n\nContent here.\n",
256
+ });
257
+
258
+ // Apply conventions manually (since applyConventions has a bug)
259
+ await applyConventionsManually(pluginDir, projectDir);
260
+
261
+ // Verify both files exist
262
+ const gitignorePath = join(projectDir, ".gitignore");
263
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
264
+ expect(existsSync(gitignorePath)).toBe(true);
265
+ expect(existsSync(claudeMdPath)).toBe(true);
266
+
267
+ // Remove CLAUDE.md section directly (removeConventions has same bug as applyConventions)
268
+ const removeResult = await removeClaudeMdSection(
269
+ claudeMdPath,
270
+ "test-plugin",
271
+ );
272
+ expect(removeResult.removed).toBe(true);
273
+
274
+ // CLAUDE.md section should be gone
275
+ const claudeMdContent = await readFile(claudeMdPath, "utf-8");
276
+ expect(claudeMdContent).not.toContain("<!-- BEGIN magus:test-plugin");
277
+ expect(claudeMdContent).not.toContain("<!-- END magus:test-plugin -->");
278
+ expect(claudeMdContent).not.toContain("Test Plugin Conventions");
279
+
280
+ // .gitignore entries MUST still be present (by design)
281
+ const gitignoreContent = await readFile(gitignorePath, "utf-8");
282
+ expect(gitignoreContent).toContain(".mnemex/");
283
+ expect(gitignoreContent).toContain(".claude/sessions/");
284
+ });
285
+ });
286
+
287
+ // =========================================================================
288
+ // Category 2: Idempotency
289
+ // =========================================================================
290
+
291
+ describe("Idempotency", () => {
292
+ it("TEST-4: applying conventions twice produces identical result", async () => {
293
+ const pluginDir = await createMockPlugin(tempDir, "test-plugin", {
294
+ gitignoreProject: [".mnemex/"],
295
+ claudemdSection: "test-plugin",
296
+ claudemdVersion: "1.0.0",
297
+ templateContent: "## Test Conventions\n\nContent.\n",
298
+ });
299
+
300
+ // First apply (using manual helper)
301
+ const result1 = await applyConventionsManually(pluginDir, projectDir);
302
+ expect(result1.gitignore.modified).toBe(true);
303
+ expect(result1.claudemd.action).toBe("inserted");
304
+
305
+ // Snapshot file contents after first apply
306
+ const gitignorePath = join(projectDir, ".gitignore");
307
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
308
+ const gitignoreAfterFirst = await readFile(gitignorePath, "utf-8");
309
+ const claudeMdAfterFirst = await readFile(claudeMdPath, "utf-8");
310
+
311
+ // Second apply
312
+ const result2 = await applyConventionsManually(pluginDir, projectDir);
313
+ expect(result2.gitignore.modified).toBe(false);
314
+ expect(result2.claudemd.action).toBe("unchanged");
315
+
316
+ // File contents must be identical
317
+ const gitignoreAfterSecond = await readFile(gitignorePath, "utf-8");
318
+ const claudeMdAfterSecond = await readFile(claudeMdPath, "utf-8");
319
+ expect(gitignoreAfterSecond).toBe(gitignoreAfterFirst);
320
+ expect(claudeMdAfterSecond).toBe(claudeMdAfterFirst);
321
+ });
322
+
323
+ it("TEST-5: ensureGitignoreEntries is idempotent", async () => {
324
+ const gitignorePath = join(projectDir, ".gitignore");
325
+
326
+ // First call
327
+ const result1 = await ensureGitignoreEntries(gitignorePath, [
328
+ ".mnemex/",
329
+ ".claudemem/",
330
+ ]);
331
+ expect(result1.modified).toBe(true);
332
+ expect(result1.added.length).toBe(2);
333
+
334
+ const contentAfterFirst = await readFile(gitignorePath, "utf-8");
335
+
336
+ // Second call with same entries
337
+ const result2 = await ensureGitignoreEntries(gitignorePath, [
338
+ ".mnemex/",
339
+ ".claudemem/",
340
+ ]);
341
+ expect(result2.modified).toBe(false);
342
+ expect(result2.added.length).toBe(0);
343
+
344
+ const contentAfterSecond = await readFile(gitignorePath, "utf-8");
345
+ expect(contentAfterSecond).toBe(contentAfterFirst);
346
+ });
347
+
348
+ it("TEST-6: injectClaudeMdSection with same version is idempotent", async () => {
349
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
350
+ const content = "## Test Section\n\nSome content.\n";
351
+
352
+ // First inject
353
+ const result1 = await injectClaudeMdSection(
354
+ claudeMdPath,
355
+ "test-plugin",
356
+ "1.0.0",
357
+ content,
358
+ );
359
+ expect(result1.action).toBe("inserted");
360
+
361
+ // Second inject with same version and content
362
+ const result2 = await injectClaudeMdSection(
363
+ claudeMdPath,
364
+ "test-plugin",
365
+ "1.0.0",
366
+ content,
367
+ );
368
+ expect(result2.action).toBe("unchanged");
369
+ });
370
+ });
371
+
372
+ // =========================================================================
373
+ // Category 3: Version Upgrade
374
+ // =========================================================================
375
+
376
+ describe("Version Upgrade", () => {
377
+ it("TEST-7: applying v2.0.0 over v1.0.0 updates CLAUDE.md content", async () => {
378
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
379
+ const v1Content = "## V1 Content\n\nOriginal version.\n";
380
+ const v2Content = "## V2 Content\n\nUpdated version with new features.\n";
381
+
382
+ // Inject v1
383
+ const result1 = await injectClaudeMdSection(
384
+ claudeMdPath,
385
+ "test-plugin",
386
+ "1.0.0",
387
+ v1Content,
388
+ );
389
+ expect(result1.action).toBe("inserted");
390
+
391
+ // Inject v2
392
+ const result2 = await injectClaudeMdSection(
393
+ claudeMdPath,
394
+ "test-plugin",
395
+ "2.0.0",
396
+ v2Content,
397
+ );
398
+ expect(result2.action).toBe("updated");
399
+ expect(result2.previousVersion).toBe("1.0.0");
400
+ });
401
+
402
+ it("TEST-8: version marker is updated in BEGIN tag after upgrade", async () => {
403
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
404
+ const v1Content = "## V1 Content\n\nOriginal.\n";
405
+ const v2Content = "## V2 Content\n\nNew stuff.\n";
406
+
407
+ await injectClaudeMdSection(
408
+ claudeMdPath,
409
+ "test-plugin",
410
+ "1.0.0",
411
+ v1Content,
412
+ );
413
+ await injectClaudeMdSection(
414
+ claudeMdPath,
415
+ "test-plugin",
416
+ "2.0.0",
417
+ v2Content,
418
+ );
419
+
420
+ const fileContent = await readFile(claudeMdPath, "utf-8");
421
+
422
+ // Should have v2 marker, not v1
423
+ expect(fileContent).toContain("<!-- BEGIN magus:test-plugin v2.0.0 -->");
424
+ expect(fileContent).not.toContain(
425
+ "<!-- BEGIN magus:test-plugin v1.0.0 -->",
426
+ );
427
+
428
+ // Should have v2 content, not v1
429
+ expect(fileContent).toContain("V2 Content");
430
+ expect(fileContent).toContain("New stuff.");
431
+ expect(fileContent).not.toContain("V1 Content");
432
+ expect(fileContent).not.toContain("Original.");
433
+ });
434
+ });
435
+
436
+ // =========================================================================
437
+ // Category 4: Multiple Plugins
438
+ // =========================================================================
439
+
440
+ describe("Multiple Plugins", () => {
441
+ it("TEST-9: two plugins can coexist in CLAUDE.md", async () => {
442
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
443
+
444
+ const contentA = "## Plugin A Conventions\n\nPlugin A content.\n";
445
+ const contentB = "## Plugin B Conventions\n\nPlugin B content.\n";
446
+
447
+ await injectClaudeMdSection(claudeMdPath, "plugin-a", "1.0.0", contentA);
448
+ await injectClaudeMdSection(claudeMdPath, "plugin-b", "1.0.0", contentB);
449
+
450
+ const fileContent = await readFile(claudeMdPath, "utf-8");
451
+
452
+ // Both sections present
453
+ expect(fileContent).toContain("<!-- BEGIN magus:plugin-a v1.0.0 -->");
454
+ expect(fileContent).toContain("<!-- END magus:plugin-a -->");
455
+ expect(fileContent).toContain("Plugin A content.");
456
+
457
+ expect(fileContent).toContain("<!-- BEGIN magus:plugin-b v1.0.0 -->");
458
+ expect(fileContent).toContain("<!-- END magus:plugin-b -->");
459
+ expect(fileContent).toContain("Plugin B content.");
460
+
461
+ // List should show both
462
+ const sections = await listClaudeMdSections(claudeMdPath);
463
+ expect(sections.length).toBe(2);
464
+ const sectionNames = sections.map((s) => s.section);
465
+ expect(sectionNames).toContain("plugin-a");
466
+ expect(sectionNames).toContain("plugin-b");
467
+ });
468
+
469
+ it("TEST-10: removing one plugin leaves the other intact", async () => {
470
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
471
+
472
+ const contentA = "## Plugin A Conventions\n\nPlugin A content.\n";
473
+ const contentB = "## Plugin B Conventions\n\nPlugin B content.\n";
474
+
475
+ await injectClaudeMdSection(claudeMdPath, "plugin-a", "1.0.0", contentA);
476
+ await injectClaudeMdSection(claudeMdPath, "plugin-b", "1.0.0", contentB);
477
+
478
+ // Remove plugin-a
479
+ const removeResult = await removeClaudeMdSection(
480
+ claudeMdPath,
481
+ "plugin-a",
482
+ );
483
+ expect(removeResult.removed).toBe(true);
484
+
485
+ const fileContent = await readFile(claudeMdPath, "utf-8");
486
+
487
+ // Plugin A should be gone
488
+ expect(fileContent).not.toContain("<!-- BEGIN magus:plugin-a");
489
+ expect(fileContent).not.toContain("Plugin A content.");
490
+
491
+ // Plugin B should still be there
492
+ expect(fileContent).toContain("<!-- BEGIN magus:plugin-b v1.0.0 -->");
493
+ expect(fileContent).toContain("<!-- END magus:plugin-b -->");
494
+ expect(fileContent).toContain("Plugin B content.");
495
+ });
496
+
497
+ it("TEST-11: two plugins with different gitignore entries both present", async () => {
498
+ const gitignorePath = join(projectDir, ".gitignore");
499
+
500
+ await ensureGitignoreEntries(gitignorePath, [".mnemex/"], "# Plugin A");
501
+ await ensureGitignoreEntries(
502
+ gitignorePath,
503
+ [".claudemem/"],
504
+ "# Plugin B",
505
+ );
506
+
507
+ const content = await readFile(gitignorePath, "utf-8");
508
+ expect(content).toContain(".mnemex/");
509
+ expect(content).toContain(".claudemem/");
510
+
511
+ // No duplicates -- adding same entries again should not duplicate
512
+ await ensureGitignoreEntries(gitignorePath, [".mnemex/"]);
513
+ const contentAfter = await readFile(gitignorePath, "utf-8");
514
+ const mnemexOccurrences = contentAfter.split(".mnemex/").length - 1;
515
+ expect(mnemexOccurrences).toBe(1);
516
+ });
517
+ });
518
+
519
+ // =========================================================================
520
+ // Category 5: Doctor Detection
521
+ // =========================================================================
522
+
523
+ describe("Doctor Detection", () => {
524
+ // Note: checkGitignoreEntries returns { present: string[], missing: string[] }
525
+ // instead of string[] as documented in the architecture. This is an
526
+ // IMPLEMENTATION_ISSUE (API deviation), but the richer return type is a
527
+ // superset. Tests use .missing property to access the missing entries.
528
+
529
+ it("TEST-12: checkGitignoreEntries detects missing entries", async () => {
530
+ const gitignorePath = join(projectDir, ".gitignore");
531
+ // Create gitignore with only one entry
532
+ await writeFile(gitignorePath, ".mnemex/\n", "utf-8");
533
+
534
+ const result = await checkGitignoreEntries(gitignorePath, [
535
+ ".mnemex/",
536
+ ".claudemem/",
537
+ ".claude/sessions/",
538
+ ]);
539
+
540
+ // API returns { present, missing } object instead of string[] per contract
541
+ const resultObj = result as unknown as {
542
+ present: string[];
543
+ missing: string[];
544
+ };
545
+
546
+ expect(resultObj.missing).toContain(".claudemem/");
547
+ expect(resultObj.missing).toContain(".claude/sessions/");
548
+ expect(resultObj.missing).not.toContain(".mnemex/");
549
+ expect(resultObj.missing.length).toBe(2);
550
+
551
+ // Also verify present entries
552
+ expect(resultObj.present).toContain(".mnemex/");
553
+ expect(resultObj.present.length).toBe(1);
554
+ });
555
+
556
+ it("TEST-13: missing CLAUDE.md section is detectable via listClaudeMdSections", async () => {
557
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
558
+ // Create CLAUDE.md with existing content but no managed sections
559
+ await writeFile(
560
+ claudeMdPath,
561
+ "# Project CLAUDE.md\n\nSome existing content.\n",
562
+ "utf-8",
563
+ );
564
+
565
+ const sections = await listClaudeMdSections(claudeMdPath);
566
+ expect(sections.length).toBe(0);
567
+ });
568
+
569
+ it("TEST-14: ensureGitignoreEntries fixes missing entries detected by check", async () => {
570
+ const gitignorePath = join(projectDir, ".gitignore");
571
+ await writeFile(gitignorePath, ".mnemex/\n", "utf-8");
572
+
573
+ // Detect missing using the actual return type
574
+ const checkResult = await checkGitignoreEntries(gitignorePath, [
575
+ ".mnemex/",
576
+ ".claudemem/",
577
+ ".claude/sessions/",
578
+ ]);
579
+ const missing = (checkResult as unknown as { missing: string[] }).missing;
580
+ expect(missing.length).toBe(2);
581
+
582
+ // Fix by adding missing entries
583
+ const fixResult = await ensureGitignoreEntries(gitignorePath, missing);
584
+ expect(fixResult.modified).toBe(true);
585
+ expect(fixResult.added.length).toBe(2);
586
+
587
+ // Re-check: should now all be present
588
+ const recheck = await checkGitignoreEntries(gitignorePath, [
589
+ ".mnemex/",
590
+ ".claudemem/",
591
+ ".claude/sessions/",
592
+ ]);
593
+ const stillMissing = (recheck as unknown as { missing: string[] })
594
+ .missing;
595
+ expect(stillMissing.length).toBe(0);
596
+ });
597
+ });
598
+
599
+ // =========================================================================
600
+ // Category 6: Malformed CLAUDE.md Handling
601
+ // =========================================================================
602
+
603
+ describe("Malformed CLAUDE.md Handling", () => {
604
+ it("TEST-15: inject refuses when CLAUDE.md has missing END marker", async () => {
605
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
606
+ // Write a malformed file: BEGIN without END
607
+ const malformedContent = [
608
+ "# Project Notes",
609
+ "",
610
+ "<!-- BEGIN magus:broken-plugin v1.0.0 -->",
611
+ "## Broken Plugin",
612
+ "",
613
+ "This section has no END marker.",
614
+ "",
615
+ "# More content below",
616
+ "",
617
+ ].join("\n");
618
+ await writeFile(claudeMdPath, malformedContent, "utf-8");
619
+
620
+ // Attempting to inject a NEW section should be refused
621
+ const result = await injectClaudeMdSection(
622
+ claudeMdPath,
623
+ "new-plugin",
624
+ "1.0.0",
625
+ "## New Plugin\n\nContent.\n",
626
+ );
627
+ expect(result.action).toBe("error");
628
+ expect(result.error).toBeDefined();
629
+ expect(typeof result.error).toBe("string");
630
+ });
631
+
632
+ it("TEST-16: parse detects duplicate sections", async () => {
633
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
634
+ const duplicateContent = [
635
+ "# Project",
636
+ "",
637
+ "<!-- BEGIN magus:test-plugin v1.0.0 -->",
638
+ "## First instance",
639
+ "<!-- END magus:test-plugin -->",
640
+ "",
641
+ "<!-- BEGIN magus:test-plugin v1.0.0 -->",
642
+ "## Second instance (duplicate!)",
643
+ "<!-- END magus:test-plugin -->",
644
+ "",
645
+ ].join("\n");
646
+ await writeFile(claudeMdPath, duplicateContent, "utf-8");
647
+
648
+ const parseResult: ParseResult =
649
+ await parseClaudeMdSections(claudeMdPath);
650
+ expect(parseResult.errors.length).toBeGreaterThan(0);
651
+
652
+ const duplicateError = parseResult.errors.find(
653
+ (e) => e.type === "duplicate_section",
654
+ );
655
+ expect(duplicateError).toBeDefined();
656
+ if (duplicateError) {
657
+ expect(duplicateError.section).toBe("test-plugin");
658
+ }
659
+ });
660
+
661
+ it("TEST-17: parse ignores markers inside code blocks", async () => {
662
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
663
+ const contentWithCodeBlock = [
664
+ "# Documentation",
665
+ "",
666
+ "Here is an example of markers:",
667
+ "",
668
+ "```markdown",
669
+ "<!-- BEGIN magus:example v1.0.0 -->",
670
+ "## Example section",
671
+ "<!-- END magus:example -->",
672
+ "```",
673
+ "",
674
+ "The above markers are inside a code block and should be ignored.",
675
+ "",
676
+ ].join("\n");
677
+ await writeFile(claudeMdPath, contentWithCodeBlock, "utf-8");
678
+
679
+ const parseResult = await parseClaudeMdSections(claudeMdPath);
680
+
681
+ // Markers inside code blocks should NOT be treated as real sections
682
+ const realSections = parseResult.sections.filter(
683
+ (s) => !s.insideCodeBlock,
684
+ );
685
+ expect(realSections.length).toBe(0);
686
+
687
+ // If the parser reports code-block markers, check the error type
688
+ if (parseResult.errors.length > 0) {
689
+ const codeBlockErrors = parseResult.errors.filter(
690
+ (e) => e.type === "marker_in_code_block",
691
+ );
692
+ // It's valid for the parser to either:
693
+ // 1. Report marker_in_code_block errors, OR
694
+ // 2. Simply ignore them (no sections, no errors)
695
+ // Both behaviors comply with the architecture spec.
696
+ if (codeBlockErrors.length > 0) {
697
+ expect(codeBlockErrors[0].section).toBe("example");
698
+ }
699
+ }
700
+ });
701
+ });
702
+
703
+ // =========================================================================
704
+ // Category 7: Path Classification (Gitignore Entries)
705
+ // =========================================================================
706
+
707
+ describe("Path Classification", () => {
708
+ it("TEST-18: all standard private paths can be added to gitignore", async () => {
709
+ const gitignorePath = join(projectDir, ".gitignore");
710
+ const privatePaths = [
711
+ ".mnemex/",
712
+ ".claudemem/",
713
+ ".claude/sessions/",
714
+ ".claude/worktrees/",
715
+ ];
716
+
717
+ const result = await ensureGitignoreEntries(
718
+ gitignorePath,
719
+ privatePaths,
720
+ "# Claude Code ephemeral/machine-specific data",
721
+ );
722
+
723
+ expect(result.modified).toBe(true);
724
+ expect(result.added.length).toBe(4);
725
+
726
+ const content = await readFile(gitignorePath, "utf-8");
727
+ for (const path of privatePaths) {
728
+ expect(content).toContain(path);
729
+ }
730
+ });
731
+
732
+ it("TEST-19: checkGitignoreEntries reports correct missing set", async () => {
733
+ const gitignorePath = join(projectDir, ".gitignore");
734
+ // Only .mnemex/ exists
735
+ await writeFile(gitignorePath, ".mnemex/\n", "utf-8");
736
+
737
+ const allPaths = [
738
+ ".mnemex/",
739
+ ".claudemem/",
740
+ ".claude/sessions/",
741
+ ".claude/worktrees/",
742
+ ];
743
+
744
+ const result = await checkGitignoreEntries(gitignorePath, allPaths);
745
+ // API returns { present, missing } instead of string[] (see IMPLEMENTATION_ISSUE note)
746
+ const resultObj = result as unknown as {
747
+ present: string[];
748
+ missing: string[];
749
+ };
750
+
751
+ expect(resultObj.missing.length).toBe(3);
752
+ expect(resultObj.missing).toContain(".claudemem/");
753
+ expect(resultObj.missing).toContain(".claude/sessions/");
754
+ expect(resultObj.missing).toContain(".claude/worktrees/");
755
+ expect(resultObj.missing).not.toContain(".mnemex/");
756
+ });
757
+
758
+ it("TEST-20: gitignore entries survive plugin uninstall", async () => {
759
+ const pluginDir = await createMockPlugin(tempDir, "test-plugin", {
760
+ gitignoreProject: [".mnemex/", ".claude/sessions/"],
761
+ claudemdSection: "test-plugin",
762
+ claudemdVersion: "1.0.0",
763
+ templateContent: "## Test\n\nContent.\n",
764
+ });
765
+
766
+ // Apply conventions manually (since applyConventions has a bug)
767
+ await applyConventionsManually(pluginDir, projectDir);
768
+
769
+ const gitignorePath = join(projectDir, ".gitignore");
770
+ const gitignoreBefore = await readFile(gitignorePath, "utf-8");
771
+ expect(gitignoreBefore).toContain(".mnemex/");
772
+ expect(gitignoreBefore).toContain(".claude/sessions/");
773
+
774
+ // Remove conventions via public API
775
+ await removeConventions("test-plugin", projectDir);
776
+
777
+ // Gitignore entries MUST still be there (by design)
778
+ const gitignoreAfter = await readFile(gitignorePath, "utf-8");
779
+ expect(gitignoreAfter).toContain(".mnemex/");
780
+ expect(gitignoreAfter).toContain(".claude/sessions/");
781
+ });
782
+ });
783
+
784
+ // =========================================================================
785
+ // IMPLEMENTATION_ISSUE: applyConventions() regression tests
786
+ // These tests SHOULD pass when the bug in applyConventions() is fixed.
787
+ // They currently fail because applyConventions() silently skips all work.
788
+ // =========================================================================
789
+
790
+ describe("applyConventions orchestration (IMPLEMENTATION_ISSUE)", () => {
791
+ it("TEST-1-A: applyConventions should apply gitignore entries", async () => {
792
+ const pluginDir = await createMockPlugin(tempDir, "apply-test", {
793
+ gitignoreProject: [".mnemex/", ".claude/sessions/"],
794
+ claudemdSection: "apply-test",
795
+ claudemdVersion: "1.0.0",
796
+ templateContent: "## Test Plugin\n\nConventions.\n",
797
+ });
798
+
799
+ const result = await applyConventions(pluginDir, projectDir);
800
+ expect(result.gitignore.modified).toBe(true);
801
+ expect(result.gitignore.added).toContain(".mnemex/");
802
+ });
803
+
804
+ it("TEST-2-A: applyConventions should inject CLAUDE.md section", async () => {
805
+ const pluginDir = await createMockPlugin(tempDir, "apply-test", {
806
+ claudemdSection: "apply-test",
807
+ claudemdVersion: "1.0.0",
808
+ templateContent: "## Test Plugin\n\nConventions.\n",
809
+ });
810
+
811
+ const result = await applyConventions(pluginDir, projectDir);
812
+ expect(result.claudemd.action).toBe("inserted");
813
+ });
814
+
815
+ it("TEST-3-A: removeConventions should remove CLAUDE.md section", async () => {
816
+ // First create a CLAUDE.md with a section using the working low-level API
817
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
818
+ await injectClaudeMdSection(
819
+ claudeMdPath,
820
+ "test-plugin",
821
+ "1.0.0",
822
+ "## Test\n\nContent.\n",
823
+ );
824
+
825
+ // removeConventions should find and remove the section
826
+ const result = await removeConventions("test-plugin", projectDir);
827
+ expect(result.claudemd.removed).toBe(true);
828
+ });
829
+ });
745
830
  });