Skip to content

Commit dec0071

Browse files
ithiria894claude
andcommitted
feat: 31 edge case tests + export sharesGlobalClaudeDir (v0.14.6)
New test-edge-cases.mjs covering: - sharesGlobalClaudeDir: HOME, subdirectory, null, undefined, parent dir - getValidDestinations: unknown category, empty scopes, null repoDir, self-move - getEffectiveItems: empty items, non-existent scope, global-only - computeEffectiveSets: empty, null scopeId, non-overlapping names, project items never shadowed (only global gets shadowed) - Multi-level ancestors: grandparent detection, grandparent items in effective - Same-name across 3 scopes: shadowing + conflict behavior - hasEffectiveRule: all 11 categories + unknown/empty/undefined Total: 84 unit tests, 74ms. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 00eb7fc commit dec0071

4 files changed

Lines changed: 328 additions & 4 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mcpware/claude-code-organizer",
3-
"version": "0.14.5",
3+
"version": "0.14.6",
44
"description": "Organize all your Claude Code memories, skills, MCP servers, commands, agents, rules, and hooks — see what loads globally vs per-project, then move items between scopes",
55
"type": "module",
66
"files": [

src/mover.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const CLAUDE_DIR = join(HOME, ".claude");
4040
* This happens when repoDir === HOME (e.g. /home/user).
4141
* Skills/memories moved there would land in ~/.claude/ — same as Global — which is confusing.
4242
*/
43-
function sharesGlobalClaudeDir(scope) {
43+
export function sharesGlobalClaudeDir(scope) {
4444
return Boolean(scope.repoDir && join(scope.repoDir, ".claude") === CLAUDE_DIR);
4545
}
4646

tests/unit/test-edge-cases.mjs

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
/**
2+
* Edge case + coverage gap tests.
3+
*
4+
* Tests scenarios the happy-path tests don't cover:
5+
* - sharesGlobalClaudeDir detection
6+
* - Unknown/invalid categories
7+
* - Empty data sets
8+
* - Multi-level ancestors (grandparent)
9+
* - Same-name items across 3+ scopes
10+
* - MCP path differences (.mcp.json vs .claude.json)
11+
* - Boundary conditions
12+
*
13+
* Run: node --test tests/unit/test-edge-cases.mjs
14+
*/
15+
16+
import { describe, it } from 'node:test';
17+
import assert from 'node:assert/strict';
18+
import { homedir } from 'node:os';
19+
import { join } from 'node:path';
20+
import { getValidDestinations, sharesGlobalClaudeDir } from '../../src/mover.mjs';
21+
import {
22+
hasEffectiveRule, getAncestorScopes, computeEffectiveSets, getEffectiveItems
23+
} from '../../src/effective.mjs';
24+
25+
const HOME = homedir();
26+
const itemKey = (i) => `${i.category}::${i.name}::${i.scopeId}`;
27+
28+
// ── sharesGlobalClaudeDir ──────────────────────────────────────────
29+
30+
describe('sharesGlobalClaudeDir', () => {
31+
32+
it('returns true when repoDir is HOME', () => {
33+
assert.strictEqual(sharesGlobalClaudeDir({ repoDir: HOME }), true);
34+
});
35+
36+
it('returns false when repoDir is a normal project', () => {
37+
assert.strictEqual(sharesGlobalClaudeDir({ repoDir: '/tmp/project' }), false);
38+
});
39+
40+
it('returns false when repoDir is null', () => {
41+
assert.strictEqual(sharesGlobalClaudeDir({ repoDir: null }), false);
42+
});
43+
44+
it('returns false when repoDir is undefined', () => {
45+
assert.strictEqual(sharesGlobalClaudeDir({}), false);
46+
});
47+
48+
it('returns false for HOME subdirectory (not HOME itself)', () => {
49+
assert.strictEqual(sharesGlobalClaudeDir({ repoDir: join(HOME, 'projects') }), false);
50+
});
51+
52+
it('returns false for HOME parent (should not match)', () => {
53+
const parent = HOME.split('/').slice(0, -1).join('/');
54+
assert.strictEqual(sharesGlobalClaudeDir({ repoDir: parent }), false);
55+
});
56+
});
57+
58+
// ── getValidDestinations — edge cases ──────────────────────────────
59+
60+
describe('getValidDestinations — edge cases', () => {
61+
62+
const scopes = [
63+
{ id: 'global', type: 'global', repoDir: null },
64+
{ id: 'proj', type: 'project', repoDir: '/tmp/proj' },
65+
];
66+
67+
it('unknown category returns empty', () => {
68+
const item = { category: 'banana', scopeId: 'proj', locked: false };
69+
assert.deepStrictEqual(getValidDestinations(item, scopes), []);
70+
});
71+
72+
it('empty scopes array returns empty', () => {
73+
const item = { category: 'skill', scopeId: 'proj', locked: false };
74+
assert.deepStrictEqual(getValidDestinations(item, []), []);
75+
});
76+
77+
it('item already in global — global not in destinations (no self-move)', () => {
78+
const item = { category: 'skill', scopeId: 'global', locked: false };
79+
const dests = getValidDestinations(item, scopes);
80+
assert.ok(!dests.some(s => s.id === 'global'));
81+
});
82+
83+
it('scope without repoDir is not a valid destination for file-based items', () => {
84+
const scopesWithNull = [
85+
{ id: 'global', type: 'global', repoDir: null },
86+
{ id: 'broken', type: 'project', repoDir: null },
87+
];
88+
const item = { category: 'skill', scopeId: 'global', locked: false };
89+
const dests = getValidDestinations(item, scopesWithNull);
90+
assert.ok(!dests.some(s => s.id === 'broken'));
91+
});
92+
93+
it('MCP CAN go to scope without repoDir (uses claudeProjectDir)', () => {
94+
const scopesWithNull = [
95+
{ id: 'global', type: 'global', repoDir: null },
96+
{ id: 'broken', type: 'project', repoDir: null },
97+
];
98+
const item = { category: 'mcp', scopeId: 'global', locked: false };
99+
const dests = getValidDestinations(item, scopesWithNull);
100+
assert.ok(dests.some(s => s.id === 'broken'));
101+
});
102+
});
103+
104+
// ── Effective rules — edge cases ───────────────────────────────────
105+
106+
describe('getEffectiveItems — edge cases', () => {
107+
108+
it('empty items array returns empty', () => {
109+
const scopes = [{ id: 'global', type: 'global', repoDir: null }];
110+
assert.deepStrictEqual(getEffectiveItems('global', [], scopes), []);
111+
});
112+
113+
it('non-existent scopeId still includes global effective items', () => {
114+
const scopes = [{ id: 'global', type: 'global', repoDir: null }];
115+
const items = [{ category: 'skill', name: 'x', scopeId: 'global' }];
116+
const result = getEffectiveItems('nonexistent', items, scopes);
117+
// No own items, but global skill (participating category) is included
118+
assert.strictEqual(result.length, 1);
119+
assert.strictEqual(result[0].scopeId, 'global');
120+
});
121+
122+
it('global scope only returns own items (no inheritance)', () => {
123+
const scopes = [
124+
{ id: 'global', type: 'global', repoDir: null },
125+
{ id: 'proj', type: 'project', repoDir: '/tmp/proj' },
126+
];
127+
const items = [
128+
{ category: 'skill', name: 'a', scopeId: 'global' },
129+
{ category: 'skill', name: 'b', scopeId: 'proj' },
130+
];
131+
const result = getEffectiveItems('global', items, scopes);
132+
assert.strictEqual(result.length, 1);
133+
assert.strictEqual(result[0].name, 'a');
134+
});
135+
});
136+
137+
describe('computeEffectiveSets — edge cases', () => {
138+
139+
it('empty items returns empty sets', () => {
140+
const { shadowedKeys, conflictKeys, ancestorKeys } = computeEffectiveSets('proj', [], [], itemKey);
141+
assert.strictEqual(shadowedKeys.size, 0);
142+
assert.strictEqual(conflictKeys.size, 0);
143+
assert.strictEqual(ancestorKeys.size, 0);
144+
});
145+
146+
it('null scopeId returns empty sets', () => {
147+
const { shadowedKeys } = computeEffectiveSets(null, [], [], itemKey);
148+
assert.strictEqual(shadowedKeys.size, 0);
149+
});
150+
151+
it('only shadowed items are global ones with matching project names', () => {
152+
const items = [
153+
{ category: 'mcp', name: 'github', scopeId: 'proj' },
154+
{ category: 'mcp', name: 'github', scopeId: 'global' },
155+
{ category: 'mcp', name: 'slack', scopeId: 'proj' }, // unique — not shadowed
156+
];
157+
const scopes = [
158+
{ id: 'global', repoDir: null },
159+
{ id: 'proj', repoDir: '/tmp/proj' },
160+
];
161+
const { shadowedKeys } = computeEffectiveSets('proj', items, scopes, itemKey);
162+
assert.strictEqual(shadowedKeys.size, 1);
163+
assert.ok([...shadowedKeys][0].includes('global'));
164+
});
165+
166+
it('project item is NEVER shadowed (only global gets shadowed)', () => {
167+
const items = [
168+
{ category: 'agent', name: 'bot', scopeId: 'proj' },
169+
{ category: 'agent', name: 'bot', scopeId: 'global' },
170+
];
171+
const scopes = [{ id: 'global', repoDir: null }, { id: 'proj', repoDir: '/tmp/proj' }];
172+
const { shadowedKeys } = computeEffectiveSets('proj', items, scopes, itemKey);
173+
for (const key of shadowedKeys) {
174+
assert.ok(key.includes('global'), 'only global items should be shadowed');
175+
assert.ok(!key.includes('proj'), 'project items should NOT be shadowed');
176+
}
177+
});
178+
179+
it('command conflict flags BOTH project and global items', () => {
180+
const items = [
181+
{ category: 'command', name: 'deploy', scopeId: 'proj' },
182+
{ category: 'command', name: 'deploy', scopeId: 'global' },
183+
];
184+
const scopes = [{ id: 'global', repoDir: null }, { id: 'proj', repoDir: '/tmp/proj' }];
185+
const { conflictKeys } = computeEffectiveSets('proj', items, scopes, itemKey);
186+
assert.strictEqual(conflictKeys.size, 2);
187+
});
188+
189+
it('non-overlapping names produce zero conflicts/shadows', () => {
190+
const items = [
191+
{ category: 'mcp', name: 'a', scopeId: 'proj' },
192+
{ category: 'mcp', name: 'b', scopeId: 'global' },
193+
{ category: 'command', name: 'x', scopeId: 'proj' },
194+
{ category: 'command', name: 'y', scopeId: 'global' },
195+
];
196+
const scopes = [{ id: 'global', repoDir: null }, { id: 'proj', repoDir: '/tmp/proj' }];
197+
const { shadowedKeys, conflictKeys } = computeEffectiveSets('proj', items, scopes, itemKey);
198+
assert.strictEqual(shadowedKeys.size, 0);
199+
assert.strictEqual(conflictKeys.size, 0);
200+
});
201+
});
202+
203+
// ── Multi-level ancestor detection ─────────────────────────────────
204+
205+
describe('getAncestorScopes — multi-level', () => {
206+
207+
const scopes = [
208+
{ id: 'global', type: 'global', repoDir: null },
209+
{ id: 'company', type: 'project', repoDir: '/work/company' },
210+
{ id: 'team', type: 'project', repoDir: '/work/company/team' },
211+
{ id: 'repo', type: 'project', repoDir: '/work/company/team/repo' },
212+
];
213+
214+
it('repo sees both team and company as ancestors', () => {
215+
const ancestors = getAncestorScopes('repo', scopes);
216+
const ids = ancestors.map(s => s.id).sort();
217+
assert.deepStrictEqual(ids, ['company', 'team']);
218+
});
219+
220+
it('team sees company as ancestor but not repo', () => {
221+
const ancestors = getAncestorScopes('team', scopes);
222+
assert.deepStrictEqual(ancestors.map(s => s.id), ['company']);
223+
});
224+
225+
it('company has no ancestors', () => {
226+
assert.strictEqual(getAncestorScopes('company', scopes).length, 0);
227+
});
228+
229+
it('ancestor items from grandparent are included in effective view', () => {
230+
const items = [
231+
{ category: 'config', name: 'CLAUDE.md', scopeId: 'company' },
232+
{ category: 'config', name: 'CLAUDE.md', scopeId: 'team' },
233+
{ category: 'memory', name: 'team_notes', scopeId: 'team' },
234+
{ category: 'skill', name: 'deploy', scopeId: 'repo' },
235+
];
236+
const effective = getEffectiveItems('repo', items, scopes);
237+
// Should include: repo skill + company CLAUDE.md (ancestor) + team CLAUDE.md (ancestor) + team memory (ancestor)
238+
assert.ok(effective.some(i => i.scopeId === 'company' && i.name === 'CLAUDE.md'));
239+
assert.ok(effective.some(i => i.scopeId === 'team' && i.name === 'CLAUDE.md'));
240+
assert.ok(effective.some(i => i.scopeId === 'team' && i.name === 'team_notes'));
241+
});
242+
243+
it('ancestorKeys includes grandparent items', () => {
244+
const items = [
245+
{ category: 'config', name: 'CLAUDE.md', scopeId: 'company' },
246+
{ category: 'config', name: 'CLAUDE.md', scopeId: 'team' },
247+
];
248+
const { ancestorKeys } = computeEffectiveSets('repo', items, scopes, itemKey);
249+
assert.ok([...ancestorKeys].some(k => k.includes('company')));
250+
assert.ok([...ancestorKeys].some(k => k.includes('team')));
251+
});
252+
});
253+
254+
// ── Same-name items across 3+ scopes ───────────────────────────────
255+
256+
describe('Same-name items across 3 scopes', () => {
257+
258+
const scopes = [
259+
{ id: 'global', type: 'global', repoDir: null },
260+
{ id: 'parent', type: 'project', repoDir: '/work/parent' },
261+
{ id: 'child', type: 'project', repoDir: '/work/parent/child' },
262+
];
263+
264+
it('MCP: only global copy is shadowed (child scope is selected)', () => {
265+
const items = [
266+
{ category: 'mcp', name: 'server', scopeId: 'global' },
267+
{ category: 'mcp', name: 'server', scopeId: 'parent' },
268+
{ category: 'mcp', name: 'server', scopeId: 'child' },
269+
];
270+
// When viewing child scope, only items from global scope get shadowed
271+
// (parent is not "global" so computeEffectiveSets only checks global vs project)
272+
const { shadowedKeys } = computeEffectiveSets('child', items, scopes, itemKey);
273+
assert.ok([...shadowedKeys].some(k => k.includes('global')));
274+
// parent's copy is an ancestor item, not shadowed by child
275+
assert.ok(![...shadowedKeys].some(k => k.includes('parent')));
276+
});
277+
278+
it('command: same name in all 3 scopes → only global+child flagged as conflict', () => {
279+
const items = [
280+
{ category: 'command', name: 'deploy', scopeId: 'global' },
281+
{ category: 'command', name: 'deploy', scopeId: 'parent' },
282+
{ category: 'command', name: 'deploy', scopeId: 'child' },
283+
];
284+
const { conflictKeys } = computeEffectiveSets('child', items, scopes, itemKey);
285+
// Only child (project) vs global are checked for conflicts
286+
assert.ok([...conflictKeys].some(k => k.includes('child')));
287+
assert.ok([...conflictKeys].some(k => k.includes('global')));
288+
});
289+
});
290+
291+
// ── hasEffectiveRule completeness ───────────────────────────────────
292+
293+
describe('hasEffectiveRule — all 11 categories', () => {
294+
295+
const ALL_CATEGORIES = ['skill', 'memory', 'mcp', 'command', 'agent', 'plan', 'rule', 'config', 'hook', 'plugin', 'session'];
296+
297+
it('participating categories return true', () => {
298+
const expected = ['skill', 'memory', 'mcp', 'command', 'agent', 'config', 'hook'];
299+
for (const cat of expected) {
300+
assert.ok(hasEffectiveRule(cat), `${cat} should participate`);
301+
}
302+
});
303+
304+
it('non-participating categories return false', () => {
305+
const expected = ['plan', 'rule', 'plugin', 'session'];
306+
for (const cat of expected) {
307+
assert.ok(!hasEffectiveRule(cat), `${cat} should NOT participate`);
308+
}
309+
});
310+
311+
it('every known category is explicitly tested', () => {
312+
for (const cat of ALL_CATEGORIES) {
313+
// Just verify it doesn't throw
314+
const result = hasEffectiveRule(cat);
315+
assert.strictEqual(typeof result, 'boolean', `${cat} should return boolean`);
316+
}
317+
});
318+
319+
it('unknown category returns false', () => {
320+
assert.ok(!hasEffectiveRule('banana'));
321+
assert.ok(!hasEffectiveRule(''));
322+
assert.ok(!hasEffectiveRule(undefined));
323+
});
324+
});

0 commit comments

Comments
 (0)