claudeup 4.18.0 → 4.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/claudeup.js +43 -19
- package/package.json +14 -19
- package/scripts/build-binaries.ts +77 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +43 -0
- package/src/__tests__/alias-adopt.test.ts +364 -0
- package/src/__tests__/alias-parser.test.ts +92 -0
- package/src/__tests__/alias-shell-writer.test.ts +7 -0
- package/src/__tests__/alias-store.test.ts +77 -0
- package/src/__tests__/cli-router.test.ts +65 -0
- package/src/__tests__/conventions-integration.test.ts +745 -0
- package/src/__tests__/conventions-manager.test.ts +1172 -0
- package/src/__tests__/doctor-bins.test.ts +35 -0
- package/src/__tests__/doctor.test.ts +450 -0
- package/src/__tests__/dual-write-prevention.test.ts +14 -4
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/gitignore-prerun.test.ts +1 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +98 -0
- package/src/__tests__/manifest.test.ts +136 -0
- package/src/__tests__/marketplace-refresh.test.ts +267 -0
- package/src/__tests__/plugin-requires.test.ts +133 -0
- package/src/__tests__/plugin-setup.test.ts +118 -0
- package/src/__tests__/profile-command.test.ts +116 -0
- package/src/__tests__/profile-materializer.test.ts +82 -0
- package/src/__tests__/profile-sync.test.ts +136 -0
- package/src/__tests__/registry-version-resolution.test.ts +74 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +218 -0
- package/src/__tests__/symlink-manager.test.ts +99 -0
- package/src/__tests__/toolchain.test.ts +56 -0
- package/src/cli/claude.ts +21 -0
- package/src/cli/doctor.ts +132 -0
- package/src/cli/install.ts +360 -0
- package/src/cli/profile.ts +166 -0
- package/src/cli/router.ts +107 -0
- package/src/cli/update.ts +85 -0
- package/src/data/alias-flags.ts +11 -1
- package/src/data/cli-tools.ts +7 -7
- package/src/data/gitignore-defaults.ts +4 -0
- package/src/data/marketplaces.ts +12 -0
- package/src/data/predefined-profiles.ts +3 -4
- package/src/main.tsx +11 -154
- package/src/prerunner/index.ts +62 -27
- package/src/services/alias-shell-writer.ts +382 -8
- package/src/services/alias-store.ts +60 -0
- package/src/services/claude-cli.ts +19 -22
- package/src/services/claude-settings.ts +99 -26
- package/src/services/conventions-manager.ts +865 -0
- package/src/services/doctor-bins.ts +49 -0
- package/src/services/doctor.ts +509 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/manifest.ts +166 -0
- package/src/services/marketplace-refresh.ts +173 -0
- package/src/services/plugin-manager.ts +7 -1
- package/src/services/plugin-requires.ts +215 -0
- package/src/services/plugin-setup.ts +61 -4
- package/src/services/plugin-version-check.ts +9 -4
- package/src/services/profile-materializer.ts +61 -0
- package/src/services/profile-sync.ts +150 -0
- package/src/services/resolver.ts +293 -0
- package/src/services/symlink-manager.ts +146 -0
- package/src/services/toolchain.ts +97 -0
- package/src/services/version-check.ts +10 -11
- package/src/types/index.ts +138 -31
- package/src/ui/App.tsx +16 -11
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/modals/ConfirmModal.tsx +1 -1
- package/src/ui/screens/AliasScreen.tsx +491 -359
- package/src/ui/screens/CliToolsScreen.tsx +11 -3
- package/src/ui/screens/PluginsScreen.tsx +3 -1
- package/src/ui/screens/index.ts +0 -1
- package/src/ui/state/reducer.ts +6 -102
- package/src/ui/state/types.ts +8 -35
- package/src/utils/command-utils.ts +23 -0
- package/src/utils/file-locking.ts +144 -0
- package/src/data/alias-flags.js +0 -196
- package/src/data/cli-tools.js +0 -123
- package/src/data/gitignore-defaults.js +0 -24
- package/src/data/gitignore-reasons.js +0 -97
- package/src/data/gitignore-templates.js +0 -21
- package/src/data/marketplaces.js +0 -138
- package/src/data/mcp-servers.js +0 -509
- package/src/data/predefined-profiles.js +0 -248
- package/src/data/settings-catalog.js +0 -625
- package/src/data/skill-repos.js +0 -160
- package/src/data/statuslines.js +0 -159
- package/src/data/statuslines.ts +0 -188
- package/src/index.js +0 -4
- package/src/index.ts +0 -5
- package/src/main.js +0 -170
- package/src/prerunner/index.js +0 -252
- package/src/services/alias-settings.js +0 -51
- package/src/services/alias-shell-writer.js +0 -764
- package/src/services/alias-store.js +0 -77
- package/src/services/claude-cli.js +0 -189
- package/src/services/claude-runner.js +0 -28
- package/src/services/claude-settings.js +0 -1223
- package/src/services/gitignore-detector.js +0 -155
- package/src/services/gitignore-fixer.js +0 -231
- package/src/services/gitignore-prerun.js +0 -46
- package/src/services/gitignore-resolver.js +0 -143
- package/src/services/gitignore-service.js +0 -117
- package/src/services/local-marketplace.js +0 -339
- package/src/services/marketplace-fetcher.js +0 -96
- package/src/services/marketplace-sync.js +0 -94
- package/src/services/mcp-registry.js +0 -87
- package/src/services/plugin-manager.js +0 -473
- package/src/services/plugin-mcp-config.js +0 -177
- package/src/services/plugin-setup.js +0 -444
- package/src/services/plugin-version-check.js +0 -262
- package/src/services/profiles.js +0 -161
- package/src/services/settings-manager.js +0 -243
- package/src/services/skills-manager.js +0 -393
- package/src/services/skillsmp-client.js +0 -87
- package/src/services/update-cache.js +0 -52
- package/src/services/version-check.js +0 -99
- package/src/types/gitignore.js +0 -6
- package/src/types/index.js +0 -1
- package/src/ui/App.js +0 -346
- package/src/ui/adapters/pluginsAdapter.js +0 -139
- package/src/ui/adapters/settingsAdapter.js +0 -111
- package/src/ui/adapters/skillsAdapter.js +0 -154
- package/src/ui/components/CategoryHeader.js +0 -9
- package/src/ui/components/EmptyFilterState.js +0 -4
- package/src/ui/components/ScopeIndicator.js +0 -30
- package/src/ui/components/ScrollableList.js +0 -36
- package/src/ui/components/SearchInput.js +0 -19
- package/src/ui/components/StyledText.js +0 -39
- package/src/ui/components/TabBar.js +0 -19
- package/src/ui/components/layout/FooterHints.js +0 -29
- package/src/ui/components/layout/Panel.js +0 -6
- package/src/ui/components/layout/ProgressBar.js +0 -14
- package/src/ui/components/layout/ScopeTabs.js +0 -6
- package/src/ui/components/layout/ScreenLayout.js +0 -16
- package/src/ui/components/layout/index.js +0 -5
- package/src/ui/components/modals/ConfirmModal.js +0 -14
- package/src/ui/components/modals/InputModal.js +0 -5
- package/src/ui/components/modals/LoadingModal.js +0 -17
- package/src/ui/components/modals/MessageModal.js +0 -16
- package/src/ui/components/modals/ModalContainer.js +0 -137
- package/src/ui/components/modals/SelectModal.js +0 -18
- package/src/ui/components/modals/VersionMismatchModal.js +0 -36
- package/src/ui/components/modals/index.js +0 -6
- package/src/ui/components/primitives/ActionHints.js +0 -13
- package/src/ui/components/primitives/DetailSection.js +0 -7
- package/src/ui/components/primitives/KeyValueLine.js +0 -8
- package/src/ui/components/primitives/ListCategoryRow.js +0 -8
- package/src/ui/components/primitives/MetaText.js +0 -8
- package/src/ui/components/primitives/ScopeDetail.js +0 -32
- package/src/ui/components/primitives/ScopeSquares.js +0 -11
- package/src/ui/components/primitives/SelectableRow.js +0 -5
- package/src/ui/components/primitives/index.js +0 -8
- package/src/ui/hooks/index.js +0 -4
- package/src/ui/hooks/useAsyncData.js +0 -77
- package/src/ui/hooks/useGitignoreModal.js +0 -74
- package/src/ui/hooks/useKeyboard.js +0 -13
- package/src/ui/hooks/useKeyboardHandler.js +0 -39
- package/src/ui/hooks/useMismatchModal.js +0 -77
- package/src/ui/registry.js +0 -1
- package/src/ui/renderers/cliToolRenderers.js +0 -54
- package/src/ui/renderers/gitignoreRenderers.js +0 -46
- package/src/ui/renderers/mcpRenderers.js +0 -26
- package/src/ui/renderers/pluginRenderers.js +0 -124
- package/src/ui/renderers/profileRenderers.js +0 -177
- package/src/ui/renderers/settingsRenderers.js +0 -73
- package/src/ui/renderers/skillRenderers.js +0 -138
- package/src/ui/screens/AliasScreen.js +0 -1008
- package/src/ui/screens/CliToolsScreen.js +0 -338
- package/src/ui/screens/EnvVarsScreen.js +0 -152
- package/src/ui/screens/GitignoreScreen.js +0 -328
- package/src/ui/screens/McpRegistryScreen.js +0 -238
- package/src/ui/screens/McpScreen.js +0 -176
- package/src/ui/screens/ModelSelectorScreen.js +0 -296
- package/src/ui/screens/ModelSelectorScreen.tsx +0 -444
- package/src/ui/screens/PluginsScreen.js +0 -766
- package/src/ui/screens/ProfilesScreen.js +0 -298
- package/src/ui/screens/SkillsScreen.js +0 -549
- package/src/ui/screens/StatusLineScreen.js +0 -206
- package/src/ui/screens/StatusLineScreen.tsx +0 -416
- package/src/ui/screens/index.js +0 -10
- package/src/ui/state/AnimationContext.js +0 -34
- package/src/ui/state/AppContext.js +0 -162
- package/src/ui/state/DimensionsContext.js +0 -71
- package/src/ui/state/reducer.js +0 -543
- package/src/ui/state/types.js +0 -1
- package/src/ui/theme.js +0 -47
- package/src/utils/clipboard.js +0 -56
- package/src/utils/command-utils.js +0 -19
- package/src/utils/fuzzy-search.js +0 -101
- package/src/utils/string-utils.js +0 -62
|
@@ -0,0 +1,1172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for conventions-manager service
|
|
3
|
+
*
|
|
4
|
+
* Tests cover:
|
|
5
|
+
* - ensureGitignoreEntries: adds missing, skips existing, creates file if absent
|
|
6
|
+
* - removeGitignoreEntries: removes present, no-ops for absent
|
|
7
|
+
* - checkGitignoreEntries: correctly identifies missing and present
|
|
8
|
+
* - parseClaudeMdContent: well-formed, missing END, duplicates, code blocks
|
|
9
|
+
* - injectClaudeMdSection: insert new, update stale, skip current, refuse on parse errors
|
|
10
|
+
* - removeClaudeMdSection: removes existing, no-op for absent
|
|
11
|
+
* - listClaudeMdSections: lists all managed sections
|
|
12
|
+
* - resolvePluginConventions: reads conventions from plugin.json, returns null if absent
|
|
13
|
+
* - applyConventions: end-to-end for both gitignore and CLAUDE.md
|
|
14
|
+
* - removeConventions: end-to-end CLAUDE.md removal
|
|
15
|
+
* - Idempotency: calling applyConventions twice yields identical result
|
|
16
|
+
* - Malformed markers: duplicate sections, missing END, markers in code blocks
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
20
|
+
import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import {
|
|
24
|
+
ensureGitignoreEntries,
|
|
25
|
+
removeGitignoreEntries,
|
|
26
|
+
checkGitignoreEntries,
|
|
27
|
+
parseClaudeMdContent,
|
|
28
|
+
parseClaudeMdSections,
|
|
29
|
+
injectClaudeMdSection,
|
|
30
|
+
removeClaudeMdSection,
|
|
31
|
+
listClaudeMdSections,
|
|
32
|
+
resolvePluginConventions,
|
|
33
|
+
resolveGlobalGitignorePath,
|
|
34
|
+
readConventionTemplate,
|
|
35
|
+
applyConventions,
|
|
36
|
+
removeConventions,
|
|
37
|
+
} from '../services/conventions-manager.js';
|
|
38
|
+
|
|
39
|
+
describe('conventions-manager', () => {
|
|
40
|
+
let testDir: string;
|
|
41
|
+
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
testDir = await mkdtemp(join(tmpdir(), 'conventions-test-'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(async () => {
|
|
47
|
+
await rm(testDir, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ============================================================
|
|
51
|
+
// ensureGitignoreEntries
|
|
52
|
+
// ============================================================
|
|
53
|
+
|
|
54
|
+
describe('ensureGitignoreEntries', () => {
|
|
55
|
+
it('should add missing entries to existing .gitignore', async () => {
|
|
56
|
+
const gitignorePath = join(testDir, '.gitignore');
|
|
57
|
+
await writeFile(gitignorePath, 'node_modules/\n.env\n');
|
|
58
|
+
|
|
59
|
+
const result = await ensureGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
|
|
60
|
+
|
|
61
|
+
expect(result.modified).toBe(true);
|
|
62
|
+
expect(result.added).toEqual(['.mnemex/', '.claudemem/']);
|
|
63
|
+
|
|
64
|
+
const content = await readFile(gitignorePath, 'utf-8');
|
|
65
|
+
expect(content).toContain('.mnemex/');
|
|
66
|
+
expect(content).toContain('.claudemem/');
|
|
67
|
+
expect(content).toContain('node_modules/');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should skip entries that already exist', async () => {
|
|
71
|
+
const gitignorePath = join(testDir, '.gitignore');
|
|
72
|
+
await writeFile(gitignorePath, 'node_modules/\n.mnemex/\n');
|
|
73
|
+
|
|
74
|
+
const result = await ensureGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
|
|
75
|
+
|
|
76
|
+
expect(result.modified).toBe(true);
|
|
77
|
+
expect(result.added).toEqual(['.claudemem/']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should return modified=false when all entries already exist', async () => {
|
|
81
|
+
const gitignorePath = join(testDir, '.gitignore');
|
|
82
|
+
await writeFile(gitignorePath, 'node_modules/\n.mnemex/\n.claudemem/\n');
|
|
83
|
+
|
|
84
|
+
const result = await ensureGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
|
|
85
|
+
|
|
86
|
+
expect(result.modified).toBe(false);
|
|
87
|
+
expect(result.added).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should create file if absent', async () => {
|
|
91
|
+
const gitignorePath = join(testDir, '.gitignore');
|
|
92
|
+
|
|
93
|
+
const result = await ensureGitignoreEntries(gitignorePath, ['.mnemex/']);
|
|
94
|
+
|
|
95
|
+
expect(result.modified).toBe(true);
|
|
96
|
+
expect(result.added).toEqual(['.mnemex/']);
|
|
97
|
+
|
|
98
|
+
const content = await readFile(gitignorePath, 'utf-8');
|
|
99
|
+
expect(content).toContain('.mnemex/');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should add comment header when provided', async () => {
|
|
103
|
+
const gitignorePath = join(testDir, '.gitignore');
|
|
104
|
+
|
|
105
|
+
const result = await ensureGitignoreEntries(
|
|
106
|
+
gitignorePath,
|
|
107
|
+
['.mnemex/'],
|
|
108
|
+
'# Code Analysis plugin',
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(result.modified).toBe(true);
|
|
112
|
+
const content = await readFile(gitignorePath, 'utf-8');
|
|
113
|
+
expect(content).toContain('# Code Analysis plugin');
|
|
114
|
+
expect(content).toContain('.mnemex/');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should not duplicate comment if already present', async () => {
|
|
118
|
+
const gitignorePath = join(testDir, '.gitignore');
|
|
119
|
+
await writeFile(gitignorePath, '# Code Analysis plugin\n.mnemex/\n');
|
|
120
|
+
|
|
121
|
+
await ensureGitignoreEntries(
|
|
122
|
+
gitignorePath,
|
|
123
|
+
['.claudemem/'],
|
|
124
|
+
'# Code Analysis plugin',
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const content = await readFile(gitignorePath, 'utf-8');
|
|
128
|
+
const commentCount = (content.match(/# Code Analysis plugin/g) || []).length;
|
|
129
|
+
expect(commentCount).toBe(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should handle empty entries array', async () => {
|
|
133
|
+
const gitignorePath = join(testDir, '.gitignore');
|
|
134
|
+
await writeFile(gitignorePath, 'node_modules/\n');
|
|
135
|
+
|
|
136
|
+
const result = await ensureGitignoreEntries(gitignorePath, []);
|
|
137
|
+
|
|
138
|
+
expect(result.modified).toBe(false);
|
|
139
|
+
expect(result.added).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ============================================================
|
|
144
|
+
// removeGitignoreEntries
|
|
145
|
+
// ============================================================
|
|
146
|
+
|
|
147
|
+
describe('removeGitignoreEntries', () => {
|
|
148
|
+
it('should remove present entries', async () => {
|
|
149
|
+
const gitignorePath = join(testDir, '.gitignore');
|
|
150
|
+
await writeFile(gitignorePath, 'node_modules/\n.mnemex/\n.claudemem/\n');
|
|
151
|
+
|
|
152
|
+
const result = await removeGitignoreEntries(gitignorePath, ['.mnemex/']);
|
|
153
|
+
|
|
154
|
+
expect(result.modified).toBe(true);
|
|
155
|
+
expect(result.removed).toEqual(['.mnemex/']);
|
|
156
|
+
|
|
157
|
+
const content = await readFile(gitignorePath, 'utf-8');
|
|
158
|
+
expect(content).not.toContain('.mnemex/');
|
|
159
|
+
expect(content).toContain('.claudemem/');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should no-op for entries not present', async () => {
|
|
163
|
+
const gitignorePath = join(testDir, '.gitignore');
|
|
164
|
+
await writeFile(gitignorePath, 'node_modules/\n');
|
|
165
|
+
|
|
166
|
+
const result = await removeGitignoreEntries(gitignorePath, ['.mnemex/']);
|
|
167
|
+
|
|
168
|
+
expect(result.modified).toBe(false);
|
|
169
|
+
expect(result.removed).toEqual([]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should handle non-existent file', async () => {
|
|
173
|
+
const gitignorePath = join(testDir, '.gitignore');
|
|
174
|
+
|
|
175
|
+
const result = await removeGitignoreEntries(gitignorePath, ['.mnemex/']);
|
|
176
|
+
|
|
177
|
+
expect(result.modified).toBe(false);
|
|
178
|
+
expect(result.removed).toEqual([]);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ============================================================
|
|
183
|
+
// checkGitignoreEntries
|
|
184
|
+
// ============================================================
|
|
185
|
+
|
|
186
|
+
describe('checkGitignoreEntries', () => {
|
|
187
|
+
it('should identify present and missing entries', async () => {
|
|
188
|
+
const gitignorePath = join(testDir, '.gitignore');
|
|
189
|
+
await writeFile(gitignorePath, 'node_modules/\n.mnemex/\n');
|
|
190
|
+
|
|
191
|
+
const result = await checkGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
|
|
192
|
+
|
|
193
|
+
expect(result.present).toEqual(['.mnemex/']);
|
|
194
|
+
expect(result.missing).toEqual(['.claudemem/']);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should handle non-existent file (all missing)', async () => {
|
|
198
|
+
const gitignorePath = join(testDir, '.gitignore');
|
|
199
|
+
|
|
200
|
+
const result = await checkGitignoreEntries(gitignorePath, ['.mnemex/']);
|
|
201
|
+
|
|
202
|
+
expect(result.present).toEqual([]);
|
|
203
|
+
expect(result.missing).toEqual(['.mnemex/']);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should ignore comments and blank lines in gitignore', async () => {
|
|
207
|
+
const gitignorePath = join(testDir, '.gitignore');
|
|
208
|
+
await writeFile(gitignorePath, '# comment\n\n.mnemex/\n');
|
|
209
|
+
|
|
210
|
+
const result = await checkGitignoreEntries(gitignorePath, ['.mnemex/', '# comment']);
|
|
211
|
+
|
|
212
|
+
expect(result.present).toEqual(['.mnemex/']);
|
|
213
|
+
// Comments are not patterns
|
|
214
|
+
expect(result.missing).toEqual(['# comment']);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ============================================================
|
|
219
|
+
// parseClaudeMdContent (state machine parser)
|
|
220
|
+
// ============================================================
|
|
221
|
+
|
|
222
|
+
describe('parseClaudeMdContent', () => {
|
|
223
|
+
it('should parse well-formed single section', () => {
|
|
224
|
+
const content = [
|
|
225
|
+
'# Project CLAUDE.md',
|
|
226
|
+
'',
|
|
227
|
+
'<!-- BEGIN magus:code-analysis v1.0.0 -->',
|
|
228
|
+
'## Code Analysis Conventions',
|
|
229
|
+
'',
|
|
230
|
+
'> Managed by plugin',
|
|
231
|
+
'<!-- END magus:code-analysis -->',
|
|
232
|
+
'',
|
|
233
|
+
'Other content',
|
|
234
|
+
].join('\n');
|
|
235
|
+
|
|
236
|
+
const result = parseClaudeMdContent(content);
|
|
237
|
+
|
|
238
|
+
expect(result.errors).toEqual([]);
|
|
239
|
+
expect(result.sections).toHaveLength(1);
|
|
240
|
+
expect(result.sections[0].section).toBe('code-analysis');
|
|
241
|
+
expect(result.sections[0].version).toBe('1.0.0');
|
|
242
|
+
expect(result.sections[0].content).toContain('Code Analysis Conventions');
|
|
243
|
+
expect(result.sections[0].startLine).toBe(3);
|
|
244
|
+
expect(result.sections[0].endLine).toBe(7);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should parse multiple sections', () => {
|
|
248
|
+
const content = [
|
|
249
|
+
'<!-- BEGIN magus:code-analysis v1.0.0 -->',
|
|
250
|
+
'Section 1 content',
|
|
251
|
+
'<!-- END magus:code-analysis -->',
|
|
252
|
+
'',
|
|
253
|
+
'<!-- BEGIN magus:terminal v2.0.0 -->',
|
|
254
|
+
'Section 2 content',
|
|
255
|
+
'<!-- END magus:terminal -->',
|
|
256
|
+
].join('\n');
|
|
257
|
+
|
|
258
|
+
const result = parseClaudeMdContent(content);
|
|
259
|
+
|
|
260
|
+
expect(result.errors).toEqual([]);
|
|
261
|
+
expect(result.sections).toHaveLength(2);
|
|
262
|
+
expect(result.sections[0].section).toBe('code-analysis');
|
|
263
|
+
expect(result.sections[1].section).toBe('terminal');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should detect missing END marker', () => {
|
|
267
|
+
const content = [
|
|
268
|
+
'<!-- BEGIN magus:code-analysis v1.0.0 -->',
|
|
269
|
+
'Some content without an end marker',
|
|
270
|
+
].join('\n');
|
|
271
|
+
|
|
272
|
+
const result = parseClaudeMdContent(content);
|
|
273
|
+
|
|
274
|
+
expect(result.errors).toHaveLength(1);
|
|
275
|
+
expect(result.errors[0].type).toBe('missing_end');
|
|
276
|
+
expect(result.errors[0]).toHaveProperty('section', 'code-analysis');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should detect duplicate sections', () => {
|
|
280
|
+
const content = [
|
|
281
|
+
'<!-- BEGIN magus:code-analysis v1.0.0 -->',
|
|
282
|
+
'First',
|
|
283
|
+
'<!-- END magus:code-analysis -->',
|
|
284
|
+
'',
|
|
285
|
+
'<!-- BEGIN magus:code-analysis v1.0.0 -->',
|
|
286
|
+
'Second',
|
|
287
|
+
'<!-- END magus:code-analysis -->',
|
|
288
|
+
].join('\n');
|
|
289
|
+
|
|
290
|
+
const result = parseClaudeMdContent(content);
|
|
291
|
+
|
|
292
|
+
const dupError = result.errors.find((e) => e.type === 'duplicate_section');
|
|
293
|
+
expect(dupError).toBeDefined();
|
|
294
|
+
expect(dupError).toHaveProperty('section', 'code-analysis');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should ignore markers inside code blocks', () => {
|
|
298
|
+
const content = [
|
|
299
|
+
'# Example',
|
|
300
|
+
'```markdown',
|
|
301
|
+
'<!-- BEGIN magus:example v1.0.0 -->',
|
|
302
|
+
'This is just an example',
|
|
303
|
+
'<!-- END magus:example -->',
|
|
304
|
+
'```',
|
|
305
|
+
'',
|
|
306
|
+
'Regular content',
|
|
307
|
+
].join('\n');
|
|
308
|
+
|
|
309
|
+
const result = parseClaudeMdContent(content);
|
|
310
|
+
|
|
311
|
+
// The markers inside code blocks should not be parsed as real sections
|
|
312
|
+
const realSections = result.sections.filter((s) => !s.insideCodeBlock);
|
|
313
|
+
expect(realSections).toHaveLength(0);
|
|
314
|
+
|
|
315
|
+
// Should report marker_in_code_block error
|
|
316
|
+
const codeBlockError = result.errors.find((e) => e.type === 'marker_in_code_block');
|
|
317
|
+
expect(codeBlockError).toBeDefined();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should handle empty content', () => {
|
|
321
|
+
const result = parseClaudeMdContent('');
|
|
322
|
+
|
|
323
|
+
expect(result.sections).toEqual([]);
|
|
324
|
+
expect(result.errors).toEqual([]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should handle content with no markers', () => {
|
|
328
|
+
const content = '# Just a CLAUDE.md\n\nWith regular content\n';
|
|
329
|
+
|
|
330
|
+
const result = parseClaudeMdContent(content);
|
|
331
|
+
|
|
332
|
+
expect(result.sections).toEqual([]);
|
|
333
|
+
expect(result.errors).toEqual([]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should detect missing END when a new BEGIN appears', () => {
|
|
337
|
+
const content = [
|
|
338
|
+
'<!-- BEGIN magus:first v1.0.0 -->',
|
|
339
|
+
'First content',
|
|
340
|
+
'<!-- BEGIN magus:second v1.0.0 -->',
|
|
341
|
+
'Second content',
|
|
342
|
+
'<!-- END magus:second -->',
|
|
343
|
+
].join('\n');
|
|
344
|
+
|
|
345
|
+
const result = parseClaudeMdContent(content);
|
|
346
|
+
|
|
347
|
+
const missingEnd = result.errors.find(
|
|
348
|
+
(e) => e.type === 'missing_end' && e.section === 'first',
|
|
349
|
+
);
|
|
350
|
+
expect(missingEnd).toBeDefined();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should detect mismatched END markers', () => {
|
|
354
|
+
const content = [
|
|
355
|
+
'<!-- BEGIN magus:alpha v1.0.0 -->',
|
|
356
|
+
'Alpha content',
|
|
357
|
+
'<!-- END magus:beta -->',
|
|
358
|
+
'More content',
|
|
359
|
+
].join('\n');
|
|
360
|
+
|
|
361
|
+
const result = parseClaudeMdContent(content);
|
|
362
|
+
|
|
363
|
+
const mismatchError = result.errors.find((e) => e.type === 'mismatched_end');
|
|
364
|
+
expect(mismatchError).toBeDefined();
|
|
365
|
+
expect(mismatchError).toHaveProperty('section', 'beta');
|
|
366
|
+
expect(mismatchError).toHaveProperty('expected', 'alpha');
|
|
367
|
+
|
|
368
|
+
// The section should remain unclosed (also produces missing_end)
|
|
369
|
+
const missingEnd = result.errors.find((e) => e.type === 'missing_end');
|
|
370
|
+
expect(missingEnd).toBeDefined();
|
|
371
|
+
expect(missingEnd).toHaveProperty('section', 'alpha');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should handle mixed backtick/tilde fences correctly', () => {
|
|
375
|
+
// A ~~~ block inside a ``` block should not close the ``` block
|
|
376
|
+
const content = [
|
|
377
|
+
'```',
|
|
378
|
+
'<!-- BEGIN magus:inside-backtick v1.0.0 -->',
|
|
379
|
+
'~~~',
|
|
380
|
+
'still inside backtick code block',
|
|
381
|
+
'~~~',
|
|
382
|
+
'<!-- END magus:inside-backtick -->',
|
|
383
|
+
'```',
|
|
384
|
+
'',
|
|
385
|
+
'<!-- BEGIN magus:real v1.0.0 -->',
|
|
386
|
+
'Real content',
|
|
387
|
+
'<!-- END magus:real -->',
|
|
388
|
+
].join('\n');
|
|
389
|
+
|
|
390
|
+
const result = parseClaudeMdContent(content);
|
|
391
|
+
|
|
392
|
+
// The markers inside the ``` block should be flagged as in code block
|
|
393
|
+
const codeBlockErrors = result.errors.filter((e) => e.type === 'marker_in_code_block');
|
|
394
|
+
expect(codeBlockErrors.length).toBeGreaterThanOrEqual(1);
|
|
395
|
+
|
|
396
|
+
// The real section outside the code block should parse correctly
|
|
397
|
+
const realSection = result.sections.find((s) => s.section === 'real');
|
|
398
|
+
expect(realSection).toBeDefined();
|
|
399
|
+
expect(realSection!.content).toBe('Real content');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should not close backtick fence with tilde fence', () => {
|
|
403
|
+
// ``` opened, ~~~ should not close it
|
|
404
|
+
const content = [
|
|
405
|
+
'```',
|
|
406
|
+
'~~~',
|
|
407
|
+
'<!-- BEGIN magus:test v1.0.0 -->',
|
|
408
|
+
'Content',
|
|
409
|
+
'<!-- END magus:test -->',
|
|
410
|
+
'~~~',
|
|
411
|
+
'```',
|
|
412
|
+
].join('\n');
|
|
413
|
+
|
|
414
|
+
const result = parseClaudeMdContent(content);
|
|
415
|
+
|
|
416
|
+
// The markers are inside the ``` code block (~~~ doesn't close it)
|
|
417
|
+
const codeBlockErrors = result.errors.filter((e) => e.type === 'marker_in_code_block');
|
|
418
|
+
expect(codeBlockErrors.length).toBeGreaterThanOrEqual(1);
|
|
419
|
+
|
|
420
|
+
// No real sections should be parsed
|
|
421
|
+
const realSections = result.sections.filter((s) => !s.insideCodeBlock);
|
|
422
|
+
expect(realSections).toHaveLength(0);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('should parse version with prerelease tag', () => {
|
|
426
|
+
const content = [
|
|
427
|
+
'<!-- BEGIN magus:test-plugin v1.0.0-beta.1 -->',
|
|
428
|
+
'Beta content',
|
|
429
|
+
'<!-- END magus:test-plugin -->',
|
|
430
|
+
].join('\n');
|
|
431
|
+
|
|
432
|
+
const result = parseClaudeMdContent(content);
|
|
433
|
+
|
|
434
|
+
expect(result.errors).toEqual([]);
|
|
435
|
+
expect(result.sections).toHaveLength(1);
|
|
436
|
+
expect(result.sections[0].version).toBe('1.0.0-beta.1');
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ============================================================
|
|
441
|
+
// parseClaudeMdSections (file-reading wrapper)
|
|
442
|
+
// ============================================================
|
|
443
|
+
|
|
444
|
+
describe('parseClaudeMdSections', () => {
|
|
445
|
+
it('should parse file from disk', async () => {
|
|
446
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
447
|
+
const content = [
|
|
448
|
+
'# Project',
|
|
449
|
+
'',
|
|
450
|
+
'<!-- BEGIN magus:code-analysis v1.0.0 -->',
|
|
451
|
+
'Conventions here',
|
|
452
|
+
'<!-- END magus:code-analysis -->',
|
|
453
|
+
].join('\n');
|
|
454
|
+
await writeFile(claudeMdPath, content);
|
|
455
|
+
|
|
456
|
+
const result = await parseClaudeMdSections(claudeMdPath);
|
|
457
|
+
|
|
458
|
+
expect(result.sections).toHaveLength(1);
|
|
459
|
+
expect(result.errors).toEqual([]);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('should return empty result for non-existent file', async () => {
|
|
463
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
464
|
+
|
|
465
|
+
const result = await parseClaudeMdSections(claudeMdPath);
|
|
466
|
+
|
|
467
|
+
expect(result.sections).toEqual([]);
|
|
468
|
+
expect(result.errors).toEqual([]);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// ============================================================
|
|
473
|
+
// injectClaudeMdSection
|
|
474
|
+
// ============================================================
|
|
475
|
+
|
|
476
|
+
describe('injectClaudeMdSection', () => {
|
|
477
|
+
it('should insert new section into empty file', async () => {
|
|
478
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
479
|
+
|
|
480
|
+
const result = await injectClaudeMdSection(
|
|
481
|
+
claudeMdPath,
|
|
482
|
+
'code-analysis',
|
|
483
|
+
'1.0.0',
|
|
484
|
+
'## Code Analysis\n\nPrivate paths here',
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
expect(result.action).toBe('inserted');
|
|
488
|
+
|
|
489
|
+
const content = await readFile(claudeMdPath, 'utf-8');
|
|
490
|
+
expect(content).toContain('<!-- BEGIN magus:code-analysis v1.0.0 -->');
|
|
491
|
+
expect(content).toContain('## Code Analysis');
|
|
492
|
+
expect(content).toContain('<!-- END magus:code-analysis -->');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('should insert new section into existing file', async () => {
|
|
496
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
497
|
+
await writeFile(claudeMdPath, '# Project\n\nExisting content\n');
|
|
498
|
+
|
|
499
|
+
const result = await injectClaudeMdSection(
|
|
500
|
+
claudeMdPath,
|
|
501
|
+
'code-analysis',
|
|
502
|
+
'1.0.0',
|
|
503
|
+
'New content',
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
expect(result.action).toBe('inserted');
|
|
507
|
+
|
|
508
|
+
const content = await readFile(claudeMdPath, 'utf-8');
|
|
509
|
+
expect(content).toContain('# Project');
|
|
510
|
+
expect(content).toContain('Existing content');
|
|
511
|
+
expect(content).toContain('<!-- BEGIN magus:code-analysis v1.0.0 -->');
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('should update section when version changes', async () => {
|
|
515
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
516
|
+
const initial = [
|
|
517
|
+
'# Project',
|
|
518
|
+
'',
|
|
519
|
+
'<!-- BEGIN magus:code-analysis v1.0.0 -->',
|
|
520
|
+
'Old content',
|
|
521
|
+
'<!-- END magus:code-analysis -->',
|
|
522
|
+
].join('\n');
|
|
523
|
+
await writeFile(claudeMdPath, initial);
|
|
524
|
+
|
|
525
|
+
const result = await injectClaudeMdSection(
|
|
526
|
+
claudeMdPath,
|
|
527
|
+
'code-analysis',
|
|
528
|
+
'2.0.0',
|
|
529
|
+
'New content',
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
expect(result.action).toBe('updated');
|
|
533
|
+
expect(result.previousVersion).toBe('1.0.0');
|
|
534
|
+
|
|
535
|
+
const content = await readFile(claudeMdPath, 'utf-8');
|
|
536
|
+
expect(content).toContain('v2.0.0');
|
|
537
|
+
expect(content).toContain('New content');
|
|
538
|
+
expect(content).not.toContain('Old content');
|
|
539
|
+
expect(content).not.toContain('v1.0.0');
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('should skip when version is unchanged', async () => {
|
|
543
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
544
|
+
const initial = [
|
|
545
|
+
'<!-- BEGIN magus:code-analysis v1.0.0 -->',
|
|
546
|
+
'Current content',
|
|
547
|
+
'<!-- END magus:code-analysis -->',
|
|
548
|
+
].join('\n');
|
|
549
|
+
await writeFile(claudeMdPath, initial);
|
|
550
|
+
|
|
551
|
+
const result = await injectClaudeMdSection(
|
|
552
|
+
claudeMdPath,
|
|
553
|
+
'code-analysis',
|
|
554
|
+
'1.0.0',
|
|
555
|
+
'Different content but same version',
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
expect(result.action).toBe('unchanged');
|
|
559
|
+
|
|
560
|
+
// File should not be modified
|
|
561
|
+
const content = await readFile(claudeMdPath, 'utf-8');
|
|
562
|
+
expect(content).toContain('Current content');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('should refuse when file has parse errors', async () => {
|
|
566
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
567
|
+
const malformed = [
|
|
568
|
+
'<!-- BEGIN magus:broken v1.0.0 -->',
|
|
569
|
+
'Content without end marker',
|
|
570
|
+
].join('\n');
|
|
571
|
+
await writeFile(claudeMdPath, malformed);
|
|
572
|
+
|
|
573
|
+
const result = await injectClaudeMdSection(
|
|
574
|
+
claudeMdPath,
|
|
575
|
+
'code-analysis',
|
|
576
|
+
'1.0.0',
|
|
577
|
+
'New content',
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
expect(result.action).toBe('error');
|
|
581
|
+
expect(result.error).toContain('malformed markers');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('should refuse when file has duplicate sections', async () => {
|
|
585
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
586
|
+
const duplicate = [
|
|
587
|
+
'<!-- BEGIN magus:code-analysis v1.0.0 -->',
|
|
588
|
+
'First',
|
|
589
|
+
'<!-- END magus:code-analysis -->',
|
|
590
|
+
'',
|
|
591
|
+
'<!-- BEGIN magus:code-analysis v1.0.0 -->',
|
|
592
|
+
'Second',
|
|
593
|
+
'<!-- END magus:code-analysis -->',
|
|
594
|
+
].join('\n');
|
|
595
|
+
await writeFile(claudeMdPath, duplicate);
|
|
596
|
+
|
|
597
|
+
const result = await injectClaudeMdSection(
|
|
598
|
+
claudeMdPath,
|
|
599
|
+
'terminal',
|
|
600
|
+
'1.0.0',
|
|
601
|
+
'Terminal content',
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
expect(result.action).toBe('error');
|
|
605
|
+
expect(result.error).toContain('duplicate');
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// ============================================================
|
|
610
|
+
// removeClaudeMdSection
|
|
611
|
+
// ============================================================
|
|
612
|
+
|
|
613
|
+
describe('removeClaudeMdSection', () => {
|
|
614
|
+
it('should remove existing section', async () => {
|
|
615
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
616
|
+
const content = [
|
|
617
|
+
'# Project',
|
|
618
|
+
'',
|
|
619
|
+
'<!-- BEGIN magus:code-analysis v1.0.0 -->',
|
|
620
|
+
'Conventions here',
|
|
621
|
+
'<!-- END magus:code-analysis -->',
|
|
622
|
+
'',
|
|
623
|
+
'Other content',
|
|
624
|
+
].join('\n');
|
|
625
|
+
await writeFile(claudeMdPath, content);
|
|
626
|
+
|
|
627
|
+
const result = await removeClaudeMdSection(claudeMdPath, 'code-analysis');
|
|
628
|
+
|
|
629
|
+
expect(result.removed).toBe(true);
|
|
630
|
+
|
|
631
|
+
const newContent = await readFile(claudeMdPath, 'utf-8');
|
|
632
|
+
expect(newContent).not.toContain('BEGIN magus:code-analysis');
|
|
633
|
+
expect(newContent).not.toContain('END magus:code-analysis');
|
|
634
|
+
expect(newContent).toContain('# Project');
|
|
635
|
+
expect(newContent).toContain('Other content');
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('should no-op for absent section', async () => {
|
|
639
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
640
|
+
await writeFile(claudeMdPath, '# Project\n\nSome content\n');
|
|
641
|
+
|
|
642
|
+
const result = await removeClaudeMdSection(claudeMdPath, 'code-analysis');
|
|
643
|
+
|
|
644
|
+
expect(result.removed).toBe(false);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('should no-op for non-existent file', async () => {
|
|
648
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
649
|
+
|
|
650
|
+
const result = await removeClaudeMdSection(claudeMdPath, 'code-analysis');
|
|
651
|
+
|
|
652
|
+
expect(result.removed).toBe(false);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('should refuse mutation on malformed files', async () => {
|
|
656
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
657
|
+
const malformed = [
|
|
658
|
+
'<!-- BEGIN magus:broken v1.0.0 -->',
|
|
659
|
+
'Content without end marker',
|
|
660
|
+
'',
|
|
661
|
+
'<!-- BEGIN magus:target v1.0.0 -->',
|
|
662
|
+
'Target content',
|
|
663
|
+
'<!-- END magus:target -->',
|
|
664
|
+
].join('\n');
|
|
665
|
+
await writeFile(claudeMdPath, malformed);
|
|
666
|
+
|
|
667
|
+
const result = await removeClaudeMdSection(claudeMdPath, 'target');
|
|
668
|
+
|
|
669
|
+
expect(result.removed).toBe(false);
|
|
670
|
+
expect(result.error).toContain('malformed markers');
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('should allow force removal on malformed files', async () => {
|
|
674
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
675
|
+
const content = [
|
|
676
|
+
'<!-- BEGIN magus:good v1.0.0 -->',
|
|
677
|
+
'Good content',
|
|
678
|
+
'<!-- END magus:good -->',
|
|
679
|
+
'',
|
|
680
|
+
'<!-- BEGIN magus:good v2.0.0 -->',
|
|
681
|
+
'Duplicate',
|
|
682
|
+
'<!-- END magus:good -->',
|
|
683
|
+
].join('\n');
|
|
684
|
+
await writeFile(claudeMdPath, content);
|
|
685
|
+
|
|
686
|
+
// Without force, should refuse
|
|
687
|
+
const refuseResult = await removeClaudeMdSection(claudeMdPath, 'good');
|
|
688
|
+
expect(refuseResult.removed).toBe(false);
|
|
689
|
+
expect(refuseResult.error).toBeTruthy();
|
|
690
|
+
|
|
691
|
+
// With force, should proceed
|
|
692
|
+
const forceResult = await removeClaudeMdSection(claudeMdPath, 'good', { force: true });
|
|
693
|
+
expect(forceResult.removed).toBe(true);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it('should preserve other sections when removing one', async () => {
|
|
697
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
698
|
+
const content = [
|
|
699
|
+
'<!-- BEGIN magus:code-analysis v1.0.0 -->',
|
|
700
|
+
'First section',
|
|
701
|
+
'<!-- END magus:code-analysis -->',
|
|
702
|
+
'',
|
|
703
|
+
'<!-- BEGIN magus:terminal v2.0.0 -->',
|
|
704
|
+
'Second section',
|
|
705
|
+
'<!-- END magus:terminal -->',
|
|
706
|
+
].join('\n');
|
|
707
|
+
await writeFile(claudeMdPath, content);
|
|
708
|
+
|
|
709
|
+
await removeClaudeMdSection(claudeMdPath, 'code-analysis');
|
|
710
|
+
|
|
711
|
+
const newContent = await readFile(claudeMdPath, 'utf-8');
|
|
712
|
+
expect(newContent).not.toContain('code-analysis');
|
|
713
|
+
expect(newContent).toContain('<!-- BEGIN magus:terminal v2.0.0 -->');
|
|
714
|
+
expect(newContent).toContain('Second section');
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// ============================================================
|
|
719
|
+
// listClaudeMdSections
|
|
720
|
+
// ============================================================
|
|
721
|
+
|
|
722
|
+
describe('listClaudeMdSections', () => {
|
|
723
|
+
it('should list all managed sections', async () => {
|
|
724
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
725
|
+
const content = [
|
|
726
|
+
'<!-- BEGIN magus:code-analysis v1.0.0 -->',
|
|
727
|
+
'First',
|
|
728
|
+
'<!-- END magus:code-analysis -->',
|
|
729
|
+
'',
|
|
730
|
+
'<!-- BEGIN magus:terminal v2.1.0 -->',
|
|
731
|
+
'Second',
|
|
732
|
+
'<!-- END magus:terminal -->',
|
|
733
|
+
].join('\n');
|
|
734
|
+
await writeFile(claudeMdPath, content);
|
|
735
|
+
|
|
736
|
+
const sections = await listClaudeMdSections(claudeMdPath);
|
|
737
|
+
|
|
738
|
+
expect(sections).toHaveLength(2);
|
|
739
|
+
expect(sections[0].section).toBe('code-analysis');
|
|
740
|
+
expect(sections[0].version).toBe('1.0.0');
|
|
741
|
+
expect(sections[1].section).toBe('terminal');
|
|
742
|
+
expect(sections[1].version).toBe('2.1.0');
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it('should return empty array for file without sections', async () => {
|
|
746
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
747
|
+
await writeFile(claudeMdPath, '# Just a readme\n');
|
|
748
|
+
|
|
749
|
+
const sections = await listClaudeMdSections(claudeMdPath);
|
|
750
|
+
|
|
751
|
+
expect(sections).toEqual([]);
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
// ============================================================
|
|
756
|
+
// resolvePluginConventions
|
|
757
|
+
// ============================================================
|
|
758
|
+
|
|
759
|
+
describe('resolvePluginConventions', () => {
|
|
760
|
+
it('should read conventions from plugin.json', async () => {
|
|
761
|
+
const pluginDir = join(testDir, 'my-plugin');
|
|
762
|
+
await mkdir(pluginDir, { recursive: true });
|
|
763
|
+
await writeFile(
|
|
764
|
+
join(pluginDir, 'plugin.json'),
|
|
765
|
+
JSON.stringify({
|
|
766
|
+
name: 'my-plugin',
|
|
767
|
+
version: '1.0.0',
|
|
768
|
+
conventions: {
|
|
769
|
+
gitignore: { project: ['.mnemex/'] },
|
|
770
|
+
claudemd: {
|
|
771
|
+
section: 'my-plugin',
|
|
772
|
+
template: './templates/claude-md-conventions.md',
|
|
773
|
+
version: '1.0.0',
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
}),
|
|
777
|
+
);
|
|
778
|
+
|
|
779
|
+
const result = await resolvePluginConventions(pluginDir);
|
|
780
|
+
|
|
781
|
+
expect(result).not.toBeNull();
|
|
782
|
+
expect(result!.gitignore!.project).toEqual(['.mnemex/']);
|
|
783
|
+
expect(result!.claudemd!.section).toBe('my-plugin');
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('should return null when plugin has no conventions field', async () => {
|
|
787
|
+
const pluginDir = join(testDir, 'simple-plugin');
|
|
788
|
+
await mkdir(pluginDir, { recursive: true });
|
|
789
|
+
await writeFile(
|
|
790
|
+
join(pluginDir, 'plugin.json'),
|
|
791
|
+
JSON.stringify({ name: 'simple-plugin', version: '1.0.0' }),
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
const result = await resolvePluginConventions(pluginDir);
|
|
795
|
+
|
|
796
|
+
expect(result).toBeNull();
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it('should return null when plugin.json does not exist', async () => {
|
|
800
|
+
const pluginDir = join(testDir, 'missing-plugin');
|
|
801
|
+
|
|
802
|
+
const result = await resolvePluginConventions(pluginDir);
|
|
803
|
+
|
|
804
|
+
expect(result).toBeNull();
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// ============================================================
|
|
809
|
+
// readConventionTemplate
|
|
810
|
+
// ============================================================
|
|
811
|
+
|
|
812
|
+
describe('readConventionTemplate', () => {
|
|
813
|
+
it('should read a template within the plugin directory', async () => {
|
|
814
|
+
const pluginDir = join(testDir, 'my-plugin');
|
|
815
|
+
await mkdir(join(pluginDir, 'templates'), { recursive: true });
|
|
816
|
+
await writeFile(join(pluginDir, 'templates', 'conventions.md'), 'Template content');
|
|
817
|
+
|
|
818
|
+
const content = await readConventionTemplate(pluginDir, './templates/conventions.md');
|
|
819
|
+
|
|
820
|
+
expect(content).toBe('Template content');
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it('should reject path traversal attempts', async () => {
|
|
824
|
+
const pluginDir = join(testDir, 'my-plugin');
|
|
825
|
+
await mkdir(pluginDir, { recursive: true });
|
|
826
|
+
|
|
827
|
+
await expect(
|
|
828
|
+
readConventionTemplate(pluginDir, './../../etc/passwd'),
|
|
829
|
+
).rejects.toThrow('Template path escapes plugin directory');
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it('should reject path traversal with ../ in the middle', async () => {
|
|
833
|
+
const pluginDir = join(testDir, 'my-plugin');
|
|
834
|
+
await mkdir(pluginDir, { recursive: true });
|
|
835
|
+
|
|
836
|
+
await expect(
|
|
837
|
+
readConventionTemplate(pluginDir, './templates/../../secret.txt'),
|
|
838
|
+
).rejects.toThrow('Template path escapes plugin directory');
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
// ============================================================
|
|
843
|
+
// resolveGlobalGitignorePath
|
|
844
|
+
// ============================================================
|
|
845
|
+
|
|
846
|
+
describe('resolveGlobalGitignorePath', () => {
|
|
847
|
+
it('should return a path (from git config or fallback)', async () => {
|
|
848
|
+
const result = await resolveGlobalGitignorePath();
|
|
849
|
+
|
|
850
|
+
expect(result).toBeTruthy();
|
|
851
|
+
expect(typeof result).toBe('string');
|
|
852
|
+
});
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// ============================================================
|
|
856
|
+
// applyConventions (end-to-end)
|
|
857
|
+
// ============================================================
|
|
858
|
+
|
|
859
|
+
describe('applyConventions', () => {
|
|
860
|
+
let pluginDir: string;
|
|
861
|
+
let projectDir: string;
|
|
862
|
+
|
|
863
|
+
beforeEach(async () => {
|
|
864
|
+
pluginDir = join(testDir, 'plugin');
|
|
865
|
+
projectDir = join(testDir, 'project');
|
|
866
|
+
await mkdir(pluginDir, { recursive: true });
|
|
867
|
+
await mkdir(join(pluginDir, 'templates'), { recursive: true });
|
|
868
|
+
await mkdir(projectDir, { recursive: true });
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it('should apply both gitignore and CLAUDE.md conventions', async () => {
|
|
872
|
+
await writeFile(
|
|
873
|
+
join(pluginDir, 'templates', 'conventions.md'),
|
|
874
|
+
'## My Plugin\n\nConventions here',
|
|
875
|
+
);
|
|
876
|
+
await writeFile(
|
|
877
|
+
join(pluginDir, 'plugin.json'),
|
|
878
|
+
JSON.stringify({
|
|
879
|
+
name: 'my-plugin',
|
|
880
|
+
version: '1.0.0',
|
|
881
|
+
conventions: {
|
|
882
|
+
gitignore: { project: ['.mnemex/'] },
|
|
883
|
+
claudemd: {
|
|
884
|
+
section: 'my-plugin',
|
|
885
|
+
template: './templates/conventions.md',
|
|
886
|
+
version: '1.0.0',
|
|
887
|
+
},
|
|
888
|
+
},
|
|
889
|
+
}),
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
const result = await applyConventions(pluginDir, projectDir);
|
|
893
|
+
|
|
894
|
+
expect(result.gitignore.modified).toBe(true);
|
|
895
|
+
expect(result.gitignore.added).toEqual(['.mnemex/']);
|
|
896
|
+
expect(result.claudemd.action).toBe('inserted');
|
|
897
|
+
expect(result.claudemd.section).toBe('my-plugin');
|
|
898
|
+
|
|
899
|
+
// Verify files
|
|
900
|
+
const gitignore = await readFile(join(projectDir, '.gitignore'), 'utf-8');
|
|
901
|
+
expect(gitignore).toContain('.mnemex/');
|
|
902
|
+
|
|
903
|
+
const claudemd = await readFile(join(projectDir, 'CLAUDE.md'), 'utf-8');
|
|
904
|
+
expect(claudemd).toContain('<!-- BEGIN magus:my-plugin v1.0.0 -->');
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it('should no-op for null conventions', async () => {
|
|
908
|
+
// Plugin with no conventions field
|
|
909
|
+
await writeFile(
|
|
910
|
+
join(pluginDir, 'plugin.json'),
|
|
911
|
+
JSON.stringify({ name: 'my-plugin', version: '1.0.0' }),
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
const result = await applyConventions(pluginDir, projectDir);
|
|
915
|
+
|
|
916
|
+
expect(result.gitignore.modified).toBe(false);
|
|
917
|
+
expect(result.claudemd.action).toBe('skipped');
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it('should handle gitignore-only conventions', async () => {
|
|
921
|
+
await writeFile(
|
|
922
|
+
join(pluginDir, 'plugin.json'),
|
|
923
|
+
JSON.stringify({
|
|
924
|
+
name: 'my-plugin',
|
|
925
|
+
version: '1.0.0',
|
|
926
|
+
conventions: {
|
|
927
|
+
gitignore: { project: ['.mnemex/'] },
|
|
928
|
+
},
|
|
929
|
+
}),
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
const result = await applyConventions(pluginDir, projectDir);
|
|
933
|
+
|
|
934
|
+
expect(result.gitignore.modified).toBe(true);
|
|
935
|
+
expect(result.claudemd.action).toBe('skipped');
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it('should handle claudemd-only conventions', async () => {
|
|
939
|
+
await writeFile(join(pluginDir, 'templates', 'conventions.md'), 'Template content');
|
|
940
|
+
await writeFile(
|
|
941
|
+
join(pluginDir, 'plugin.json'),
|
|
942
|
+
JSON.stringify({
|
|
943
|
+
name: 'my-plugin',
|
|
944
|
+
version: '1.0.0',
|
|
945
|
+
conventions: {
|
|
946
|
+
claudemd: {
|
|
947
|
+
section: 'my-plugin',
|
|
948
|
+
template: './templates/conventions.md',
|
|
949
|
+
version: '1.0.0',
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
}),
|
|
953
|
+
);
|
|
954
|
+
|
|
955
|
+
const result = await applyConventions(pluginDir, projectDir);
|
|
956
|
+
|
|
957
|
+
expect(result.gitignore.modified).toBe(false);
|
|
958
|
+
expect(result.claudemd.action).toBe('inserted');
|
|
959
|
+
});
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
// ============================================================
|
|
963
|
+
// removeConventions (end-to-end)
|
|
964
|
+
// ============================================================
|
|
965
|
+
|
|
966
|
+
describe('removeConventions', () => {
|
|
967
|
+
it('should remove CLAUDE.md section on uninstall', async () => {
|
|
968
|
+
const projectDir = join(testDir, 'project');
|
|
969
|
+
await mkdir(projectDir, { recursive: true });
|
|
970
|
+
|
|
971
|
+
const claudemd = [
|
|
972
|
+
'# Project',
|
|
973
|
+
'',
|
|
974
|
+
'<!-- BEGIN magus:my-plugin v1.0.0 -->',
|
|
975
|
+
'Plugin content',
|
|
976
|
+
'<!-- END magus:my-plugin -->',
|
|
977
|
+
].join('\n');
|
|
978
|
+
await writeFile(join(projectDir, 'CLAUDE.md'), claudemd);
|
|
979
|
+
|
|
980
|
+
const result = await removeConventions('my-plugin', projectDir);
|
|
981
|
+
|
|
982
|
+
expect(result.claudemd.removed).toBe(true);
|
|
983
|
+
|
|
984
|
+
const content = await readFile(join(projectDir, 'CLAUDE.md'), 'utf-8');
|
|
985
|
+
expect(content).not.toContain('my-plugin');
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it('should no-op when section does not exist', async () => {
|
|
989
|
+
const projectDir = join(testDir, 'project');
|
|
990
|
+
await mkdir(projectDir, { recursive: true });
|
|
991
|
+
await writeFile(join(projectDir, 'CLAUDE.md'), '# Project\n');
|
|
992
|
+
|
|
993
|
+
const result = await removeConventions('nonexistent', projectDir);
|
|
994
|
+
|
|
995
|
+
expect(result.claudemd.removed).toBe(false);
|
|
996
|
+
});
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// ============================================================
|
|
1000
|
+
// Idempotency
|
|
1001
|
+
// ============================================================
|
|
1002
|
+
|
|
1003
|
+
describe('idempotency', () => {
|
|
1004
|
+
it('applyConventions twice should yield identical result', async () => {
|
|
1005
|
+
const pluginDir = join(testDir, 'plugin');
|
|
1006
|
+
const projectDir = join(testDir, 'project');
|
|
1007
|
+
await mkdir(pluginDir, { recursive: true });
|
|
1008
|
+
await mkdir(join(pluginDir, 'templates'), { recursive: true });
|
|
1009
|
+
await mkdir(projectDir, { recursive: true });
|
|
1010
|
+
|
|
1011
|
+
await writeFile(join(pluginDir, 'templates', 'conventions.md'), 'Template content');
|
|
1012
|
+
await writeFile(
|
|
1013
|
+
join(pluginDir, 'plugin.json'),
|
|
1014
|
+
JSON.stringify({
|
|
1015
|
+
name: 'my-plugin',
|
|
1016
|
+
version: '1.0.0',
|
|
1017
|
+
conventions: {
|
|
1018
|
+
gitignore: { project: ['.mnemex/', '.claudemem/'] },
|
|
1019
|
+
claudemd: {
|
|
1020
|
+
section: 'my-plugin',
|
|
1021
|
+
template: './templates/conventions.md',
|
|
1022
|
+
version: '1.0.0',
|
|
1023
|
+
},
|
|
1024
|
+
},
|
|
1025
|
+
}),
|
|
1026
|
+
);
|
|
1027
|
+
|
|
1028
|
+
// First apply
|
|
1029
|
+
await applyConventions(pluginDir, projectDir);
|
|
1030
|
+
const gitignoreAfterFirst = await readFile(join(projectDir, '.gitignore'), 'utf-8');
|
|
1031
|
+
const claudemdAfterFirst = await readFile(join(projectDir, 'CLAUDE.md'), 'utf-8');
|
|
1032
|
+
|
|
1033
|
+
// Second apply
|
|
1034
|
+
const result2 = await applyConventions(pluginDir, projectDir);
|
|
1035
|
+
|
|
1036
|
+
expect(result2.gitignore.modified).toBe(false);
|
|
1037
|
+
expect(result2.gitignore.added).toEqual([]);
|
|
1038
|
+
expect(result2.claudemd.action).toBe('unchanged');
|
|
1039
|
+
|
|
1040
|
+
// File contents should be identical
|
|
1041
|
+
const gitignoreAfterSecond = await readFile(join(projectDir, '.gitignore'), 'utf-8');
|
|
1042
|
+
const claudemdAfterSecond = await readFile(join(projectDir, 'CLAUDE.md'), 'utf-8');
|
|
1043
|
+
|
|
1044
|
+
expect(gitignoreAfterSecond).toBe(gitignoreAfterFirst);
|
|
1045
|
+
expect(claudemdAfterSecond).toBe(claudemdAfterFirst);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it('ensureGitignoreEntries twice should be idempotent', async () => {
|
|
1049
|
+
const gitignorePath = join(testDir, '.gitignore');
|
|
1050
|
+
|
|
1051
|
+
await ensureGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
|
|
1052
|
+
const first = await readFile(gitignorePath, 'utf-8');
|
|
1053
|
+
|
|
1054
|
+
const result2 = await ensureGitignoreEntries(gitignorePath, ['.mnemex/', '.claudemem/']);
|
|
1055
|
+
const second = await readFile(gitignorePath, 'utf-8');
|
|
1056
|
+
|
|
1057
|
+
expect(result2.modified).toBe(false);
|
|
1058
|
+
expect(second).toBe(first);
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
// ============================================================
|
|
1063
|
+
// Malformed markers
|
|
1064
|
+
// ============================================================
|
|
1065
|
+
|
|
1066
|
+
describe('malformed markers', () => {
|
|
1067
|
+
it('should detect missing END marker at EOF', () => {
|
|
1068
|
+
const content = [
|
|
1069
|
+
'# Project',
|
|
1070
|
+
'<!-- BEGIN magus:test v1.0.0 -->',
|
|
1071
|
+
'Content goes on forever...',
|
|
1072
|
+
].join('\n');
|
|
1073
|
+
|
|
1074
|
+
const result = parseClaudeMdContent(content);
|
|
1075
|
+
|
|
1076
|
+
expect(result.errors.some((e) => e.type === 'missing_end')).toBe(true);
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
it('should detect duplicate section names', () => {
|
|
1080
|
+
const content = [
|
|
1081
|
+
'<!-- BEGIN magus:test v1.0.0 -->',
|
|
1082
|
+
'First',
|
|
1083
|
+
'<!-- END magus:test -->',
|
|
1084
|
+
'<!-- BEGIN magus:test v2.0.0 -->',
|
|
1085
|
+
'Second',
|
|
1086
|
+
'<!-- END magus:test -->',
|
|
1087
|
+
].join('\n');
|
|
1088
|
+
|
|
1089
|
+
const result = parseClaudeMdContent(content);
|
|
1090
|
+
|
|
1091
|
+
const dupErrors = result.errors.filter((e) => e.type === 'duplicate_section');
|
|
1092
|
+
expect(dupErrors).toHaveLength(1);
|
|
1093
|
+
expect(dupErrors[0]).toHaveProperty('section', 'test');
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
it('should detect markers inside triple-backtick code blocks', () => {
|
|
1097
|
+
const content = [
|
|
1098
|
+
'```',
|
|
1099
|
+
'<!-- BEGIN magus:example v1.0.0 -->',
|
|
1100
|
+
'code example',
|
|
1101
|
+
'<!-- END magus:example -->',
|
|
1102
|
+
'```',
|
|
1103
|
+
].join('\n');
|
|
1104
|
+
|
|
1105
|
+
const result = parseClaudeMdContent(content);
|
|
1106
|
+
|
|
1107
|
+
const codeBlockErrors = result.errors.filter((e) => e.type === 'marker_in_code_block');
|
|
1108
|
+
expect(codeBlockErrors).toHaveLength(1);
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
it('should handle nested code fences correctly', () => {
|
|
1112
|
+
const content = [
|
|
1113
|
+
'````',
|
|
1114
|
+
'```',
|
|
1115
|
+
'<!-- BEGIN magus:nested v1.0.0 -->',
|
|
1116
|
+
'nested code',
|
|
1117
|
+
'<!-- END magus:nested -->',
|
|
1118
|
+
'```',
|
|
1119
|
+
'````',
|
|
1120
|
+
].join('\n');
|
|
1121
|
+
|
|
1122
|
+
// The outer ```` opens a code block with 4 backticks.
|
|
1123
|
+
// The inner ``` (3 backticks) is too short to close it per CommonMark.
|
|
1124
|
+
// So the markers are inside the code block.
|
|
1125
|
+
const result = parseClaudeMdContent(content);
|
|
1126
|
+
|
|
1127
|
+
// Should at minimum not crash
|
|
1128
|
+
expect(result).toBeDefined();
|
|
1129
|
+
|
|
1130
|
+
// The markers should be detected as inside a code block
|
|
1131
|
+
const codeBlockErrors = result.errors.filter((e) => e.type === 'marker_in_code_block');
|
|
1132
|
+
expect(codeBlockErrors.length).toBeGreaterThanOrEqual(1);
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
it('injectClaudeMdSection should refuse on malformed file', async () => {
|
|
1136
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
1137
|
+
// Missing END marker
|
|
1138
|
+
await writeFile(claudeMdPath, '<!-- BEGIN magus:broken v1.0.0 -->\nContent');
|
|
1139
|
+
|
|
1140
|
+
const result = await injectClaudeMdSection(
|
|
1141
|
+
claudeMdPath,
|
|
1142
|
+
'new-section',
|
|
1143
|
+
'1.0.0',
|
|
1144
|
+
'New content',
|
|
1145
|
+
);
|
|
1146
|
+
|
|
1147
|
+
expect(result.action).toBe('error');
|
|
1148
|
+
expect(result.error).toBeTruthy();
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
it('injectClaudeMdSection should refuse when marker is inside code block', async () => {
|
|
1152
|
+
const claudeMdPath = join(testDir, 'CLAUDE.md');
|
|
1153
|
+
const content = [
|
|
1154
|
+
'```',
|
|
1155
|
+
'<!-- BEGIN magus:example v1.0.0 -->',
|
|
1156
|
+
'code',
|
|
1157
|
+
'<!-- END magus:example -->',
|
|
1158
|
+
'```',
|
|
1159
|
+
].join('\n');
|
|
1160
|
+
await writeFile(claudeMdPath, content);
|
|
1161
|
+
|
|
1162
|
+
const result = await injectClaudeMdSection(
|
|
1163
|
+
claudeMdPath,
|
|
1164
|
+
'new-section',
|
|
1165
|
+
'1.0.0',
|
|
1166
|
+
'New content',
|
|
1167
|
+
);
|
|
1168
|
+
|
|
1169
|
+
expect(result.action).toBe('error');
|
|
1170
|
+
});
|
|
1171
|
+
});
|
|
1172
|
+
});
|