|
| 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