Skip to content

Commit 23cde9d

Browse files
ithiria894claude
andcommitted
feat: 30 unit tests for effective rules + move destinations (v0.13.16)
New test:unit command (node --test, zero deps, <100ms): - 12 tests: move destinations per category - 18 tests: effective rules, shadowing, conflicts, ancestors Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a5b5f3b commit 23cde9d

4 files changed

Lines changed: 417 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: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mcpware/claude-code-organizer",
3-
"version": "0.13.15",
3+
"version": "0.13.16",
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": [
@@ -16,7 +16,8 @@
1616
"start": "node bin/cli.mjs",
1717
"mcp": "node src/mcp-server.mjs",
1818
"test": "npx playwright test --config tests/e2e/playwright.config.mjs",
19-
"test:headed": "npx playwright test --config tests/e2e/playwright.config.mjs --headed"
19+
"test:headed": "npx playwright test --config tests/e2e/playwright.config.mjs --headed",
20+
"test:unit": "node --test tests/unit/"
2021
},
2122
"keywords": [
2223
"claude-code",
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
/**
2+
* Unit tests for Show Effective per-category rules.
3+
*
4+
* These test the LOGIC of effective resolution without a browser.
5+
* The functions mirror what app.js does client-side.
6+
*
7+
* Run: node --test tests/unit/test-effective-rules.mjs
8+
*/
9+
10+
import { describe, it } from 'node:test';
11+
import assert from 'node:assert/strict';
12+
13+
// ── Re-implement the client-side effective logic for testability ────
14+
// These mirror computeEffectiveSets + getVisibleItemsForScope in app.js
15+
16+
const EFFECTIVE_CATEGORIES = new Set([
17+
'skill', 'mcp', 'command', 'agent', 'config', 'hook', 'memory',
18+
]);
19+
20+
function computeEffectiveSets(scopeId, projectItems, globalItems, allScopes) {
21+
const shadowedKeys = new Set();
22+
const conflictKeys = new Set();
23+
const ancestorKeys = new Set();
24+
25+
if (scopeId === 'global') return { shadowedKeys, conflictKeys, ancestorKeys };
26+
27+
const itemKey = (i) => `${i.category}::${i.name}::${i.scopeId}`;
28+
29+
// MCP & Agents: narrower scope (project) wins same-name
30+
for (const cat of ['mcp', 'agent']) {
31+
const projectNames = new Set(
32+
projectItems.filter(i => i.category === cat).map(i => i.name)
33+
);
34+
for (const gi of globalItems.filter(i => i.category === cat)) {
35+
if (projectNames.has(gi.name)) shadowedKeys.add(itemKey(gi));
36+
}
37+
}
38+
39+
// Commands: same-name = conflict (not reliably resolved)
40+
const projCmdNames = new Set(projectItems.filter(i => i.category === 'command').map(i => i.name));
41+
const globalCmdNames = new Set(globalItems.filter(i => i.category === 'command').map(i => i.name));
42+
for (const name of projCmdNames) {
43+
if (!globalCmdNames.has(name)) continue;
44+
for (const i of [...projectItems, ...globalItems].filter(i => i.category === 'command' && i.name === name)) {
45+
conflictKeys.add(itemKey(i));
46+
}
47+
}
48+
49+
// Ancestor scopes
50+
const scope = allScopes.find(s => s.id === scopeId);
51+
if (scope?.repoDir) {
52+
const ancestors = allScopes.filter(s =>
53+
s.repoDir && s.id !== scopeId && s.id !== 'global' &&
54+
scope.repoDir.startsWith(s.repoDir + '/')
55+
);
56+
for (const as of ancestors) {
57+
// Items from ancestor scopes
58+
const ancestorItems = allItems.filter(i => i.scopeId === as.id && (i.category === 'config' || i.category === 'memory'));
59+
for (const i of ancestorItems) {
60+
ancestorKeys.add(itemKey(i));
61+
}
62+
}
63+
}
64+
65+
return { shadowedKeys, conflictKeys, ancestorKeys };
66+
}
67+
68+
function getEffectiveItems(scopeId, allItems, allScopes) {
69+
const projectItems = allItems.filter(i => i.scopeId === scopeId);
70+
const globalItems = allItems.filter(i => i.scopeId === 'global');
71+
72+
// Only add global items for categories with effectiveRule
73+
const effectiveGlobal = globalItems.filter(i => EFFECTIVE_CATEGORIES.has(i.category));
74+
75+
// Ancestor items (config/memory from parent path scopes)
76+
const scope = allScopes.find(s => s.id === scopeId);
77+
const ancestorItems = [];
78+
if (scope?.repoDir) {
79+
const ancestors = allScopes.filter(s =>
80+
s.repoDir && s.id !== scopeId && s.id !== 'global' &&
81+
scope.repoDir.startsWith(s.repoDir + '/')
82+
);
83+
for (const as of ancestors) {
84+
ancestorItems.push(
85+
...allItems.filter(i => i.scopeId === as.id && (i.category === 'config' || i.category === 'memory'))
86+
);
87+
}
88+
}
89+
90+
return [...projectItems, ...effectiveGlobal, ...ancestorItems];
91+
}
92+
93+
// ── Fixtures ───────────────────────────────────────────────────────
94+
95+
// allItems is used by computeEffectiveSets ancestor detection
96+
let allItems;
97+
98+
const SCOPES = [
99+
{ id: 'global', type: 'global', parentId: null, repoDir: null },
100+
{ id: 'company', type: 'project', parentId: 'global', repoDir: '/work/company' },
101+
{ id: 'repo-a', type: 'project', parentId: 'global', repoDir: '/work/company/repo-a' },
102+
];
103+
104+
const ITEMS = [
105+
// Global items
106+
{ category: 'skill', name: 'deploy', scopeId: 'global' },
107+
{ category: 'skill', name: 'lint', scopeId: 'global' },
108+
{ category: 'mcp', name: 'github', scopeId: 'global' },
109+
{ category: 'mcp', name: 'slack', scopeId: 'global' },
110+
{ category: 'command', name: 'test', scopeId: 'global' },
111+
{ category: 'command', name: 'deploy', scopeId: 'global' }, // same name as project
112+
{ category: 'agent', name: 'reviewer', scopeId: 'global' },
113+
{ category: 'agent', name: 'planner', scopeId: 'global' }, // same name as project
114+
{ category: 'config', name: 'CLAUDE.md', scopeId: 'global' },
115+
{ category: 'memory', name: 'user_prefs', scopeId: 'global' },
116+
{ category: 'plan', name: 'roadmap', scopeId: 'global' },
117+
{ category: 'rule', name: 'no-eval', scopeId: 'global' },
118+
{ category: 'session', name: 'session-1', scopeId: 'global' },
119+
{ category: 'hook', name: 'pre-tool', scopeId: 'global' },
120+
121+
// Company (ancestor of repo-a) items
122+
{ category: 'config', name: 'CLAUDE.md', scopeId: 'company' },
123+
{ category: 'memory', name: 'company_standards', scopeId: 'company' },
124+
125+
// repo-a items
126+
{ category: 'skill', name: 'local-build', scopeId: 'repo-a' },
127+
{ category: 'mcp', name: 'github', scopeId: 'repo-a' }, // same name as global → shadows
128+
{ category: 'command', name: 'deploy', scopeId: 'repo-a' }, // same name as global → conflict
129+
{ category: 'agent', name: 'planner', scopeId: 'repo-a' }, // same name as global → shadows
130+
{ category: 'config', name: 'settings.json', scopeId: 'repo-a' },
131+
{ category: 'memory', name: 'project_notes', scopeId: 'repo-a' },
132+
{ category: 'plan', name: 'sprint', scopeId: 'repo-a' },
133+
{ category: 'rule', name: 'no-console', scopeId: 'repo-a' },
134+
];
135+
136+
allItems = ITEMS;
137+
138+
// ── Tests ──────────────────────────────────────────────────────────
139+
140+
describe('Show Effective — categories that participate', () => {
141+
142+
it('skills: shows project + global skills', () => {
143+
const effective = getEffectiveItems('repo-a', ITEMS, SCOPES);
144+
const skills = effective.filter(i => i.category === 'skill');
145+
const names = skills.map(i => i.name).sort();
146+
assert.deepStrictEqual(names, ['deploy', 'lint', 'local-build']);
147+
});
148+
149+
it('mcp: shows project + global MCP servers', () => {
150+
const effective = getEffectiveItems('repo-a', ITEMS, SCOPES);
151+
const mcps = effective.filter(i => i.category === 'mcp');
152+
const names = mcps.map(i => i.name).sort();
153+
// Both "github" entries appear (project + global) — UI marks global as Shadowed
154+
assert.ok(names.includes('github'));
155+
assert.ok(names.includes('slack'));
156+
});
157+
158+
it('commands: shows project + global commands', () => {
159+
const effective = getEffectiveItems('repo-a', ITEMS, SCOPES);
160+
const cmds = effective.filter(i => i.category === 'command');
161+
const names = cmds.map(i => i.name).sort();
162+
assert.deepStrictEqual(names, ['deploy', 'deploy', 'test']); // deploy appears twice (conflict)
163+
});
164+
165+
it('agents: shows project + global agents', () => {
166+
const effective = getEffectiveItems('repo-a', ITEMS, SCOPES);
167+
const agents = effective.filter(i => i.category === 'agent');
168+
const names = agents.map(i => i.name).sort();
169+
assert.deepStrictEqual(names, ['planner', 'planner', 'reviewer']); // planner twice (shadow)
170+
});
171+
172+
it('config: shows project + global + ancestor CLAUDE.md', () => {
173+
const effective = getEffectiveItems('repo-a', ITEMS, SCOPES);
174+
const configs = effective.filter(i => i.category === 'config');
175+
const sources = configs.map(i => `${i.name}@${i.scopeId}`).sort();
176+
assert.ok(sources.includes('CLAUDE.md@global'), 'global CLAUDE.md');
177+
assert.ok(sources.includes('CLAUDE.md@company'), 'ancestor CLAUDE.md from company');
178+
assert.ok(sources.includes('settings.json@repo-a'), 'project settings.json');
179+
});
180+
181+
it('memory: shows project + global + ancestor memories', () => {
182+
const effective = getEffectiveItems('repo-a', ITEMS, SCOPES);
183+
const mems = effective.filter(i => i.category === 'memory');
184+
const sources = mems.map(i => `${i.name}@${i.scopeId}`).sort();
185+
assert.ok(sources.includes('user_prefs@global'), 'global memory');
186+
assert.ok(sources.includes('project_notes@repo-a'), 'project memory');
187+
assert.ok(sources.includes('company_standards@company'), 'ancestor memory');
188+
});
189+
190+
it('hooks: shows project + global hooks', () => {
191+
const effective = getEffectiveItems('repo-a', ITEMS, SCOPES);
192+
const hooks = effective.filter(i => i.category === 'hook');
193+
assert.ok(hooks.some(i => i.scopeId === 'global'), 'global hook present');
194+
});
195+
});
196+
197+
describe('Show Effective — categories that DO NOT participate', () => {
198+
199+
it('plans from global are NOT included', () => {
200+
const effective = getEffectiveItems('repo-a', ITEMS, SCOPES);
201+
const plans = effective.filter(i => i.category === 'plan');
202+
// Only repo-a plan, not global roadmap
203+
assert.strictEqual(plans.length, 1);
204+
assert.strictEqual(plans[0].scopeId, 'repo-a');
205+
});
206+
207+
it('rules from global are NOT included', () => {
208+
const effective = getEffectiveItems('repo-a', ITEMS, SCOPES);
209+
const rules = effective.filter(i => i.category === 'rule');
210+
assert.strictEqual(rules.length, 1);
211+
assert.strictEqual(rules[0].scopeId, 'repo-a');
212+
});
213+
214+
it('sessions from global are NOT included', () => {
215+
const effective = getEffectiveItems('repo-a', ITEMS, SCOPES);
216+
const sessions = effective.filter(i => i.category === 'session');
217+
assert.strictEqual(sessions.length, 0); // repo-a has no sessions
218+
});
219+
});
220+
221+
describe('Effective status detection — shadowed / conflict', () => {
222+
223+
it('MCP: global "github" is shadowed by project "github"', () => {
224+
const projectItems = ITEMS.filter(i => i.scopeId === 'repo-a');
225+
const globalItems = ITEMS.filter(i => i.scopeId === 'global');
226+
const { shadowedKeys } = computeEffectiveSets('repo-a', projectItems, globalItems, SCOPES);
227+
const shadowed = [...shadowedKeys];
228+
assert.ok(shadowed.some(k => k.includes('mcp::github::global')), 'global github should be shadowed');
229+
assert.ok(!shadowed.some(k => k.includes('mcp::slack')), 'slack should NOT be shadowed');
230+
});
231+
232+
it('Agent: global "planner" is shadowed by project "planner"', () => {
233+
const projectItems = ITEMS.filter(i => i.scopeId === 'repo-a');
234+
const globalItems = ITEMS.filter(i => i.scopeId === 'global');
235+
const { shadowedKeys } = computeEffectiveSets('repo-a', projectItems, globalItems, SCOPES);
236+
const shadowed = [...shadowedKeys];
237+
assert.ok(shadowed.some(k => k.includes('agent::planner::global')), 'global planner should be shadowed');
238+
assert.ok(!shadowed.some(k => k.includes('agent::reviewer')), 'reviewer should NOT be shadowed');
239+
});
240+
241+
it('Command: "deploy" in both scopes is flagged as conflict', () => {
242+
const projectItems = ITEMS.filter(i => i.scopeId === 'repo-a');
243+
const globalItems = ITEMS.filter(i => i.scopeId === 'global');
244+
const { conflictKeys } = computeEffectiveSets('repo-a', projectItems, globalItems, SCOPES);
245+
const conflicts = [...conflictKeys];
246+
// Both the project and global deploy should be conflicts
247+
assert.ok(conflicts.some(k => k.includes('command::deploy::global')), 'global deploy should be conflict');
248+
assert.ok(conflicts.some(k => k.includes('command::deploy::repo-a')), 'project deploy should be conflict');
249+
assert.ok(!conflicts.some(k => k.includes('command::test')), 'test command should NOT be conflict');
250+
});
251+
252+
it('MCP: no shadowing when names are unique', () => {
253+
const projectItems = ITEMS.filter(i => i.scopeId === 'repo-a');
254+
const globalItems = ITEMS.filter(i => i.scopeId === 'global');
255+
const { shadowedKeys } = computeEffectiveSets('repo-a', projectItems, globalItems, SCOPES);
256+
assert.ok(![...shadowedKeys].some(k => k.includes('slack')), 'unique names should not be shadowed');
257+
});
258+
259+
it('global scope has no shadowing or conflicts', () => {
260+
const projectItems = ITEMS.filter(i => i.scopeId === 'global');
261+
const globalItems = [];
262+
const { shadowedKeys, conflictKeys } = computeEffectiveSets('global', projectItems, globalItems, SCOPES);
263+
assert.strictEqual(shadowedKeys.size, 0);
264+
assert.strictEqual(conflictKeys.size, 0);
265+
});
266+
});
267+
268+
describe('Ancestor scope detection', () => {
269+
270+
it('repo-a sees company as ancestor scope', () => {
271+
const effective = getEffectiveItems('repo-a', ITEMS, SCOPES);
272+
const ancestorConfigs = effective.filter(i => i.scopeId === 'company');
273+
assert.ok(ancestorConfigs.length > 0, 'ancestor items should be included');
274+
assert.ok(ancestorConfigs.some(i => i.name === 'CLAUDE.md'), 'ancestor CLAUDE.md should be present');
275+
assert.ok(ancestorConfigs.some(i => i.name === 'company_standards'), 'ancestor memory should be present');
276+
});
277+
278+
it('company does NOT see repo-a as ancestor (children are not ancestors)', () => {
279+
const effective = getEffectiveItems('company', ITEMS, SCOPES);
280+
const repoItems = effective.filter(i => i.scopeId === 'repo-a');
281+
assert.strictEqual(repoItems.length, 0, 'child scope items should not appear as ancestors');
282+
});
283+
284+
it('global has no ancestors', () => {
285+
const effective = getEffectiveItems('global', ITEMS, SCOPES);
286+
const nonGlobal = effective.filter(i => i.scopeId !== 'global');
287+
assert.strictEqual(nonGlobal.length, 0, 'global should not include any other scope');
288+
});
289+
});

0 commit comments

Comments
 (0)