@veewo/gitnexus 1.3.6 → 1.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +21 -13
  2. package/dist/cli/analyze-custom-modules-regression.test.d.ts +1 -0
  3. package/dist/cli/analyze-custom-modules-regression.test.js +75 -0
  4. package/dist/cli/analyze-modules-diagnostics.test.d.ts +1 -0
  5. package/dist/cli/analyze-modules-diagnostics.test.js +36 -0
  6. package/dist/cli/index.js +3 -2
  7. package/dist/cli/setup.d.ts +4 -3
  8. package/dist/cli/setup.js +163 -20
  9. package/dist/cli/setup.test.js +180 -34
  10. package/dist/core/ingestion/modules/assignment-engine.d.ts +33 -0
  11. package/dist/core/ingestion/modules/assignment-engine.js +179 -0
  12. package/dist/core/ingestion/modules/assignment-engine.test.d.ts +1 -0
  13. package/dist/core/ingestion/modules/assignment-engine.test.js +111 -0
  14. package/dist/core/ingestion/modules/config-loader.d.ts +2 -0
  15. package/dist/core/ingestion/modules/config-loader.js +186 -0
  16. package/dist/core/ingestion/modules/config-loader.test.d.ts +1 -0
  17. package/dist/core/ingestion/modules/config-loader.test.js +57 -0
  18. package/dist/core/ingestion/modules/rule-matcher.d.ts +12 -0
  19. package/dist/core/ingestion/modules/rule-matcher.js +63 -0
  20. package/dist/core/ingestion/modules/rule-matcher.test.d.ts +1 -0
  21. package/dist/core/ingestion/modules/rule-matcher.test.js +58 -0
  22. package/dist/core/ingestion/modules/types.d.ts +44 -0
  23. package/dist/core/ingestion/modules/types.js +2 -0
  24. package/dist/mcp/local/cluster-aggregation.d.ts +20 -0
  25. package/dist/mcp/local/cluster-aggregation.js +48 -0
  26. package/dist/mcp/local/cluster-aggregation.test.d.ts +1 -0
  27. package/dist/mcp/local/cluster-aggregation.test.js +22 -0
  28. package/package.json +1 -1
@@ -10,16 +10,58 @@ const execFileAsync = promisify(execFile);
10
10
  const here = path.dirname(fileURLToPath(import.meta.url));
11
11
  const packageRoot = path.resolve(here, '..', '..');
12
12
  const cliPath = path.join(packageRoot, 'dist', 'cli', 'index.js');
13
- test('setup installs global skills under ~/.agents/skills/gitnexus', async () => {
13
+ const packageName = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf-8'));
14
+ const expectedMcpPackage = `${packageName.name || 'gitnexus'}@latest`;
15
+ async function runSetup(args, env, cwd = packageRoot) {
16
+ return execFileAsync(process.execPath, [cliPath, 'setup', ...args], { cwd, env });
17
+ }
18
+ test('setup requires --agent', async () => {
14
19
  const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-home-'));
15
20
  try {
16
- await execFileAsync(process.execPath, [cliPath, 'setup'], {
17
- cwd: packageRoot,
18
- env: {
21
+ try {
22
+ await runSetup([], {
19
23
  ...process.env,
20
24
  HOME: fakeHome,
21
25
  USERPROFILE: fakeHome,
22
- },
26
+ });
27
+ assert.fail('expected setup without --agent to fail');
28
+ }
29
+ catch (err) {
30
+ assert.equal(typeof err?.stdout, 'string');
31
+ assert.match(err.stdout, /Missing --agent/);
32
+ }
33
+ }
34
+ finally {
35
+ await fs.rm(fakeHome, { recursive: true, force: true });
36
+ }
37
+ });
38
+ test('setup rejects invalid --agent', async () => {
39
+ const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-home-'));
40
+ try {
41
+ try {
42
+ await runSetup(['--agent', 'cursor'], {
43
+ ...process.env,
44
+ HOME: fakeHome,
45
+ USERPROFILE: fakeHome,
46
+ });
47
+ assert.fail('expected setup with invalid --agent to fail');
48
+ }
49
+ catch (err) {
50
+ assert.equal(typeof err?.stdout, 'string');
51
+ assert.match(err.stdout, /Invalid --agent value/);
52
+ }
53
+ }
54
+ finally {
55
+ await fs.rm(fakeHome, { recursive: true, force: true });
56
+ }
57
+ });
58
+ test('setup installs global skills under ~/.agents/skills/gitnexus', async () => {
59
+ const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-home-'));
60
+ try {
61
+ await runSetup(['--agent', 'claude'], {
62
+ ...process.env,
63
+ HOME: fakeHome,
64
+ USERPROFILE: fakeHome,
23
65
  });
24
66
  const skillPath = path.join(fakeHome, '.agents', 'skills', 'gitnexus', 'gitnexus-exploring', 'SKILL.md');
25
67
  const configPath = path.join(fakeHome, '.gitnexus', 'config.json');
@@ -27,7 +69,6 @@ test('setup installs global skills under ~/.agents/skills/gitnexus', async () =>
27
69
  const configRaw = await fs.readFile(configPath, 'utf-8');
28
70
  const config = JSON.parse(configRaw);
29
71
  assert.equal(config.setupScope, 'global');
30
- assert.ok(true);
31
72
  }
32
73
  finally {
33
74
  await fs.rm(fakeHome, { recursive: true, force: true });
@@ -63,20 +104,17 @@ process.exit(0);
63
104
  else {
64
105
  await fs.writeFile(codexShimPath, `#!/usr/bin/env node\n${shimLogic}`, { mode: 0o755 });
65
106
  }
66
- await execFileAsync(process.execPath, [cliPath, 'setup'], {
67
- cwd: packageRoot,
68
- env: {
69
- ...process.env,
70
- HOME: fakeHome,
71
- USERPROFILE: fakeHome,
72
- PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}`,
73
- },
107
+ await runSetup(['--agent', 'codex'], {
108
+ ...process.env,
109
+ HOME: fakeHome,
110
+ USERPROFILE: fakeHome,
111
+ PATH: `${fakeBin}${path.delimiter}${process.env.PATH || ''}`,
74
112
  });
75
113
  const outputPath = path.join(fakeHome, '.codex', 'gitnexus-mcp-add.json');
76
114
  const raw = await fs.readFile(outputPath, 'utf-8');
77
115
  const parsed = JSON.parse(raw);
78
116
  assert.deepEqual(parsed.args.slice(0, 4), ['mcp', 'add', 'gitnexus', '--']);
79
- assert.ok(parsed.args.includes('gitnexus@latest'));
117
+ assert.ok(parsed.args.includes(expectedMcpPackage));
80
118
  assert.ok(parsed.args.includes('mcp'));
81
119
  }
82
120
  finally {
@@ -84,35 +122,143 @@ process.exit(0);
84
122
  await fs.rm(fakeBin, { recursive: true, force: true });
85
123
  }
86
124
  });
87
- test('setup with --scope project writes local MCP and repo-local skills only', async () => {
125
+ test('setup configures OpenCode MCP in ~/.config/opencode/opencode.json', async () => {
88
126
  const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-home-'));
89
- const fakeRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-repo-'));
90
127
  try {
91
- await execFileAsync('git', ['init'], {
92
- cwd: fakeRepo,
93
- env: {
94
- ...process.env,
95
- HOME: fakeHome,
96
- USERPROFILE: fakeHome,
97
- },
128
+ const opencodeDir = path.join(fakeHome, '.config', 'opencode');
129
+ await fs.mkdir(opencodeDir, { recursive: true });
130
+ await runSetup(['--agent', 'opencode'], {
131
+ ...process.env,
132
+ HOME: fakeHome,
133
+ USERPROFILE: fakeHome,
98
134
  });
99
- await execFileAsync(process.execPath, [cliPath, 'setup', '--scope', 'project'], {
100
- cwd: fakeRepo,
101
- env: {
102
- ...process.env,
103
- HOME: fakeHome,
104
- USERPROFILE: fakeHome,
105
- },
135
+ const opencodeConfigPath = path.join(opencodeDir, 'opencode.json');
136
+ const opencodeRaw = await fs.readFile(opencodeConfigPath, 'utf-8');
137
+ const opencodeConfig = JSON.parse(opencodeRaw);
138
+ assert.equal(opencodeConfig.mcp?.gitnexus?.type, 'local');
139
+ assert.deepEqual(opencodeConfig.mcp?.gitnexus?.command, ['npx', '-y', expectedMcpPackage, 'mcp']);
140
+ }
141
+ finally {
142
+ await fs.rm(fakeHome, { recursive: true, force: true });
143
+ }
144
+ });
145
+ test('setup keeps using legacy ~/.config/opencode/config.json when it already exists', async () => {
146
+ const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-home-'));
147
+ try {
148
+ const opencodeDir = path.join(fakeHome, '.config', 'opencode');
149
+ const legacyConfigPath = path.join(opencodeDir, 'config.json');
150
+ const preferredConfigPath = path.join(opencodeDir, 'opencode.json');
151
+ await fs.mkdir(opencodeDir, { recursive: true });
152
+ await fs.writeFile(legacyConfigPath, JSON.stringify({ existing: true }, null, 2), 'utf-8');
153
+ await runSetup(['--agent', 'opencode'], {
154
+ ...process.env,
155
+ HOME: fakeHome,
156
+ USERPROFILE: fakeHome,
106
157
  });
158
+ const legacyRaw = await fs.readFile(legacyConfigPath, 'utf-8');
159
+ const legacyConfig = JSON.parse(legacyRaw);
160
+ assert.equal(legacyConfig.existing, true);
161
+ assert.equal(legacyConfig.mcp?.gitnexus?.type, 'local');
162
+ assert.deepEqual(legacyConfig.mcp?.gitnexus?.command, ['npx', '-y', expectedMcpPackage, 'mcp']);
163
+ await assert.rejects(fs.access(preferredConfigPath));
164
+ }
165
+ finally {
166
+ await fs.rm(fakeHome, { recursive: true, force: true });
167
+ }
168
+ });
169
+ test('setup --agent opencode does not install Claude hooks', async () => {
170
+ const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-home-'));
171
+ try {
172
+ const opencodeDir = path.join(fakeHome, '.config', 'opencode');
173
+ const claudeDir = path.join(fakeHome, '.claude');
174
+ const claudeSettingsPath = path.join(claudeDir, 'settings.json');
175
+ const claudeHookPath = path.join(claudeDir, 'hooks', 'gitnexus', 'gitnexus-hook.cjs');
176
+ await fs.mkdir(opencodeDir, { recursive: true });
177
+ await fs.mkdir(claudeDir, { recursive: true });
178
+ await runSetup(['--agent', 'opencode'], {
179
+ ...process.env,
180
+ HOME: fakeHome,
181
+ USERPROFILE: fakeHome,
182
+ });
183
+ await assert.rejects(fs.access(claudeSettingsPath));
184
+ await assert.rejects(fs.access(claudeHookPath));
185
+ }
186
+ finally {
187
+ await fs.rm(fakeHome, { recursive: true, force: true });
188
+ }
189
+ });
190
+ test('setup --scope project --agent claude writes only .mcp.json', async () => {
191
+ const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-home-'));
192
+ const fakeRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-repo-'));
193
+ try {
194
+ await execFileAsync('git', ['init'], { cwd: fakeRepo });
195
+ await runSetup(['--scope', 'project', '--agent', 'claude'], {
196
+ ...process.env,
197
+ HOME: fakeHome,
198
+ USERPROFILE: fakeHome,
199
+ }, fakeRepo);
107
200
  const projectMcpPath = path.join(fakeRepo, '.mcp.json');
201
+ const codexConfigPath = path.join(fakeRepo, '.codex', 'config.toml');
202
+ const opencodeConfigPath = path.join(fakeRepo, 'opencode.json');
203
+ const projectMcpRaw = await fs.readFile(projectMcpPath, 'utf-8');
204
+ const projectMcp = JSON.parse(projectMcpRaw);
205
+ assert.equal(projectMcp.mcpServers?.gitnexus?.command, 'npx');
206
+ await assert.rejects(fs.access(codexConfigPath));
207
+ await assert.rejects(fs.access(opencodeConfigPath));
208
+ }
209
+ finally {
210
+ await fs.rm(fakeHome, { recursive: true, force: true });
211
+ await fs.rm(fakeRepo, { recursive: true, force: true });
212
+ }
213
+ });
214
+ test('setup --scope project --agent codex writes only .codex/config.toml', async () => {
215
+ const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-home-'));
216
+ const fakeRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-repo-'));
217
+ try {
218
+ await execFileAsync('git', ['init'], { cwd: fakeRepo });
219
+ await runSetup(['--scope', 'project', '--agent', 'codex'], {
220
+ ...process.env,
221
+ HOME: fakeHome,
222
+ USERPROFILE: fakeHome,
223
+ }, fakeRepo);
224
+ const projectMcpPath = path.join(fakeRepo, '.mcp.json');
225
+ const codexConfigPath = path.join(fakeRepo, '.codex', 'config.toml');
226
+ const opencodeConfigPath = path.join(fakeRepo, 'opencode.json');
227
+ const codexConfigRaw = await fs.readFile(codexConfigPath, 'utf-8');
228
+ assert.match(codexConfigRaw, /\[mcp_servers\.gitnexus\]/);
229
+ assert.match(codexConfigRaw, /command = "npx"/);
230
+ await assert.rejects(fs.access(projectMcpPath));
231
+ await assert.rejects(fs.access(opencodeConfigPath));
232
+ }
233
+ finally {
234
+ await fs.rm(fakeHome, { recursive: true, force: true });
235
+ await fs.rm(fakeRepo, { recursive: true, force: true });
236
+ }
237
+ });
238
+ test('setup --scope project --agent opencode writes only opencode.json', async () => {
239
+ const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-home-'));
240
+ const fakeRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-setup-repo-'));
241
+ try {
242
+ await execFileAsync('git', ['init'], { cwd: fakeRepo });
243
+ await runSetup(['--scope', 'project', '--agent', 'opencode'], {
244
+ ...process.env,
245
+ HOME: fakeHome,
246
+ USERPROFILE: fakeHome,
247
+ }, fakeRepo);
248
+ const projectMcpPath = path.join(fakeRepo, '.mcp.json');
249
+ const codexConfigPath = path.join(fakeRepo, '.codex', 'config.toml');
250
+ const opencodeConfigPath = path.join(fakeRepo, 'opencode.json');
108
251
  const localSkillPath = path.join(fakeRepo, '.agents', 'skills', 'gitnexus', 'gitnexus-exploring', 'SKILL.md');
109
252
  const globalSkillPath = path.join(fakeHome, '.agents', 'skills', 'gitnexus', 'gitnexus-exploring', 'SKILL.md');
110
253
  const configPath = path.join(fakeHome, '.gitnexus', 'config.json');
111
- const projectMcpRaw = await fs.readFile(projectMcpPath, 'utf-8');
112
- const projectMcp = JSON.parse(projectMcpRaw);
254
+ const opencodeRaw = await fs.readFile(opencodeConfigPath, 'utf-8');
255
+ const opencodeConfig = JSON.parse(opencodeRaw);
113
256
  const configRaw = await fs.readFile(configPath, 'utf-8');
114
257
  const config = JSON.parse(configRaw);
115
- assert.equal(projectMcp.mcpServers?.gitnexus?.command, 'npx');
258
+ assert.equal(opencodeConfig.mcp?.gitnexus?.type, 'local');
259
+ assert.deepEqual(opencodeConfig.mcp?.gitnexus?.command, ['npx', '-y', expectedMcpPackage, 'mcp']);
260
+ await assert.rejects(fs.access(projectMcpPath));
261
+ await assert.rejects(fs.access(codexConfigPath));
116
262
  await fs.access(localSkillPath);
117
263
  await assert.rejects(fs.access(globalSkillPath));
118
264
  assert.equal(config.setupScope, 'project');
@@ -0,0 +1,33 @@
1
+ import type { CommunityMembership, CommunityNode } from '../community-processor.js';
2
+ import type { ModuleConfig, ModuleMode } from './types.js';
3
+ import { type MatchableSymbol } from './rule-matcher.js';
4
+ export type AssignmentSource = 'config-rule' | 'auto-fallback';
5
+ export type AssignmentResolvedBy = 'priority' | 'specificity' | 'rule-order' | 'module-lexicographic' | 'fallback-auto';
6
+ export interface AssignmentInput {
7
+ mode: ModuleMode;
8
+ symbols: MatchableSymbol[];
9
+ autoCommunities: CommunityNode[];
10
+ autoMemberships: CommunityMembership[];
11
+ config: ModuleConfig | null;
12
+ }
13
+ export interface SymbolAssignment {
14
+ symbolId: string;
15
+ moduleName: string;
16
+ communityId: string;
17
+ assignmentSource: AssignmentSource;
18
+ matchedRuleId?: string;
19
+ resolvedBy: AssignmentResolvedBy;
20
+ }
21
+ export interface AssignmentDiagnostics {
22
+ mode: ModuleMode;
23
+ configuredModuleCount: number;
24
+ finalModuleCount: number;
25
+ emptyModules: string[];
26
+ }
27
+ export interface AssignmentOutput {
28
+ finalModules: CommunityNode[];
29
+ finalMemberships: CommunityMembership[];
30
+ membershipsBySymbol: Map<string, SymbolAssignment>;
31
+ diagnostics: AssignmentDiagnostics;
32
+ }
33
+ export declare function assignModules(input: AssignmentInput): AssignmentOutput;
@@ -0,0 +1,179 @@
1
+ import { matchRule, ruleSpecificityScore } from './rule-matcher.js';
2
+ function sanitizeModuleId(name) {
3
+ const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
4
+ return base || 'module';
5
+ }
6
+ function resolveBy(top, second) {
7
+ if (!second)
8
+ return 'priority';
9
+ if (top.effectivePriority !== second.effectivePriority)
10
+ return 'priority';
11
+ if (top.specificity !== second.specificity)
12
+ return 'specificity';
13
+ if (top.ruleOrder !== second.ruleOrder)
14
+ return 'rule-order';
15
+ return 'module-lexicographic';
16
+ }
17
+ function pickWinner(candidates) {
18
+ const sorted = [...candidates].sort((a, b) => {
19
+ if (a.effectivePriority !== b.effectivePriority)
20
+ return b.effectivePriority - a.effectivePriority;
21
+ if (a.specificity !== b.specificity)
22
+ return b.specificity - a.specificity;
23
+ if (a.ruleOrder !== b.ruleOrder)
24
+ return a.ruleOrder - b.ruleOrder;
25
+ return a.moduleName.localeCompare(b.moduleName);
26
+ });
27
+ return { winner: sorted[0], resolvedBy: resolveBy(sorted[0], sorted[1]) };
28
+ }
29
+ function buildAutoFallbackMap(autoCommunities) {
30
+ const m = new Map();
31
+ for (const comm of autoCommunities)
32
+ m.set(comm.id, comm);
33
+ return m;
34
+ }
35
+ function toConfiguredCommunities(config) {
36
+ const seen = new Set();
37
+ return config.modules.map((mod) => {
38
+ let idBase = `comm_cfg_${sanitizeModuleId(mod.name)}`;
39
+ let suffix = 1;
40
+ while (seen.has(idBase)) {
41
+ idBase = `comm_cfg_${sanitizeModuleId(mod.name)}_${suffix++}`;
42
+ }
43
+ seen.add(idBase);
44
+ return {
45
+ id: idBase,
46
+ moduleName: mod.name,
47
+ label: mod.name,
48
+ heuristicLabel: mod.name,
49
+ cohesion: 0,
50
+ symbolCount: 0,
51
+ };
52
+ });
53
+ }
54
+ export function assignModules(input) {
55
+ const membershipsBySymbol = new Map();
56
+ if (input.mode === 'auto' || !input.config) {
57
+ const autoCommunityById = buildAutoFallbackMap(input.autoCommunities);
58
+ for (const membership of input.autoMemberships) {
59
+ const autoComm = autoCommunityById.get(membership.communityId);
60
+ const moduleName = autoComm?.heuristicLabel || autoComm?.label || membership.communityId;
61
+ membershipsBySymbol.set(membership.nodeId, {
62
+ symbolId: membership.nodeId,
63
+ moduleName,
64
+ communityId: membership.communityId,
65
+ assignmentSource: 'auto-fallback',
66
+ resolvedBy: 'fallback-auto',
67
+ });
68
+ }
69
+ return {
70
+ finalModules: input.autoCommunities,
71
+ finalMemberships: input.autoMemberships,
72
+ membershipsBySymbol,
73
+ diagnostics: {
74
+ mode: input.mode,
75
+ configuredModuleCount: 0,
76
+ finalModuleCount: input.autoCommunities.length,
77
+ emptyModules: [],
78
+ },
79
+ };
80
+ }
81
+ const configuredCommunities = toConfiguredCommunities(input.config);
82
+ const configuredCommByName = new Map();
83
+ for (const comm of configuredCommunities) {
84
+ configuredCommByName.set(comm.moduleName, comm);
85
+ }
86
+ const autoCommunityById = buildAutoFallbackMap(input.autoCommunities);
87
+ const symbolById = new Map(input.symbols.map((s) => [s.id, s]));
88
+ const autoMembershipBySymbol = new Map(input.autoMemberships.map((m) => [m.nodeId, m.communityId]));
89
+ const configModuleCounts = new Map();
90
+ const fallbackCommunityCounts = new Map();
91
+ const finalMemberships = [];
92
+ for (const [symbolId, autoCommunityId] of autoMembershipBySymbol.entries()) {
93
+ const symbol = symbolById.get(symbolId);
94
+ if (!symbol)
95
+ continue;
96
+ const candidates = [];
97
+ let ruleOrder = 0;
98
+ for (const moduleDef of input.config.modules) {
99
+ const comm = configuredCommByName.get(moduleDef.name);
100
+ if (!comm)
101
+ continue;
102
+ for (const rule of moduleDef.rules) {
103
+ if (!matchRule(symbol, rule)) {
104
+ ruleOrder += 1;
105
+ continue;
106
+ }
107
+ candidates.push({
108
+ moduleName: moduleDef.name,
109
+ communityId: comm.id,
110
+ ruleId: rule.id,
111
+ effectivePriority: rule.priority ?? moduleDef.defaultPriority,
112
+ specificity: ruleSpecificityScore(rule),
113
+ ruleOrder,
114
+ });
115
+ ruleOrder += 1;
116
+ }
117
+ }
118
+ if (candidates.length === 0) {
119
+ finalMemberships.push({ nodeId: symbolId, communityId: autoCommunityId });
120
+ fallbackCommunityCounts.set(autoCommunityId, (fallbackCommunityCounts.get(autoCommunityId) || 0) + 1);
121
+ const autoCommunity = autoCommunityById.get(autoCommunityId);
122
+ membershipsBySymbol.set(symbolId, {
123
+ symbolId,
124
+ moduleName: autoCommunity?.heuristicLabel || autoCommunity?.label || autoCommunityId,
125
+ communityId: autoCommunityId,
126
+ assignmentSource: 'auto-fallback',
127
+ resolvedBy: 'fallback-auto',
128
+ });
129
+ continue;
130
+ }
131
+ const { winner, resolvedBy } = pickWinner(candidates);
132
+ finalMemberships.push({ nodeId: symbolId, communityId: winner.communityId });
133
+ configModuleCounts.set(winner.moduleName, (configModuleCounts.get(winner.moduleName) || 0) + 1);
134
+ membershipsBySymbol.set(symbolId, {
135
+ symbolId,
136
+ moduleName: winner.moduleName,
137
+ communityId: winner.communityId,
138
+ assignmentSource: 'config-rule',
139
+ matchedRuleId: winner.ruleId,
140
+ resolvedBy,
141
+ });
142
+ }
143
+ const emptyModules = [];
144
+ const finalModules = configuredCommunities.map((comm) => {
145
+ const count = configModuleCounts.get(comm.moduleName) || 0;
146
+ if (count === 0)
147
+ emptyModules.push(comm.moduleName);
148
+ return {
149
+ id: comm.id,
150
+ label: comm.label,
151
+ heuristicLabel: comm.heuristicLabel,
152
+ cohesion: 0,
153
+ symbolCount: count,
154
+ };
155
+ });
156
+ for (const autoComm of input.autoCommunities) {
157
+ const fallbackCount = fallbackCommunityCounts.get(autoComm.id) || 0;
158
+ if (fallbackCount <= 0)
159
+ continue;
160
+ finalModules.push({
161
+ id: autoComm.id,
162
+ label: autoComm.label,
163
+ heuristicLabel: autoComm.heuristicLabel,
164
+ cohesion: autoComm.cohesion,
165
+ symbolCount: fallbackCount,
166
+ });
167
+ }
168
+ return {
169
+ finalModules,
170
+ finalMemberships,
171
+ membershipsBySymbol,
172
+ diagnostics: {
173
+ mode: input.mode,
174
+ configuredModuleCount: input.config.modules.length,
175
+ finalModuleCount: finalModules.length,
176
+ emptyModules,
177
+ },
178
+ };
179
+ }
@@ -0,0 +1,111 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { assignModules } from './assignment-engine.js';
4
+ function baseInput() {
5
+ return {
6
+ mode: 'mixed',
7
+ symbols: [
8
+ {
9
+ id: 'Class:MinionFactory',
10
+ name: 'MinionFactory',
11
+ kind: 'Class',
12
+ filePath: 'Assets/Scripts/MinionFactory.cs',
13
+ },
14
+ {
15
+ id: 'Class:Minion',
16
+ name: 'Minion',
17
+ kind: 'Class',
18
+ filePath: 'Assets/Scripts/Minion.cs',
19
+ },
20
+ ],
21
+ autoCommunities: [
22
+ {
23
+ id: 'comm_0',
24
+ label: 'AutoGroup',
25
+ heuristicLabel: 'AutoGroup',
26
+ cohesion: 0.7,
27
+ symbolCount: 2,
28
+ },
29
+ ],
30
+ autoMemberships: [
31
+ { nodeId: 'Class:MinionFactory', communityId: 'comm_0' },
32
+ { nodeId: 'Class:Minion', communityId: 'comm_0' },
33
+ ],
34
+ config: {
35
+ version: 1,
36
+ mode: 'mixed',
37
+ modules: [
38
+ {
39
+ name: 'Factory',
40
+ defaultPriority: 100,
41
+ rules: [
42
+ {
43
+ id: 'factory-rule',
44
+ when: {
45
+ all: [{ field: 'symbol.name', op: 'contains', value: 'Factory' }],
46
+ },
47
+ },
48
+ ],
49
+ },
50
+ ],
51
+ },
52
+ };
53
+ }
54
+ test('mixed mode applies config override and auto fallback with single membership', () => {
55
+ const out = assignModules(baseInput());
56
+ assert.equal(out.finalMemberships.length, 2);
57
+ assert.equal(new Set(out.finalMemberships.map((m) => m.nodeId)).size, 2);
58
+ assert.equal(out.membershipsBySymbol.get('Class:MinionFactory')?.moduleName, 'Factory');
59
+ assert.equal(out.membershipsBySymbol.get('Class:MinionFactory')?.assignmentSource, 'config-rule');
60
+ assert.equal(out.membershipsBySymbol.get('Class:Minion')?.assignmentSource, 'auto-fallback');
61
+ });
62
+ test('conflict resolution order: priority > specificity > rule-order > module-name', () => {
63
+ const input = baseInput();
64
+ input.symbols = [{ id: 'Class:X', name: 'X', kind: 'Class', filePath: 'x.ts' }];
65
+ input.autoMemberships = [{ nodeId: 'Class:X', communityId: 'comm_0' }];
66
+ input.autoCommunities = [{ id: 'comm_0', label: 'Auto', heuristicLabel: 'Auto', cohesion: 0.4, symbolCount: 1 }];
67
+ input.config = {
68
+ version: 1,
69
+ mode: 'mixed',
70
+ modules: [
71
+ {
72
+ name: 'ContainsWinsByOrder',
73
+ defaultPriority: 100,
74
+ rules: [{ id: 'contains-a', when: { all: [{ field: 'symbol.name', op: 'contains', value: 'X' }] } }],
75
+ },
76
+ {
77
+ name: 'EqWinsBySpecificity',
78
+ defaultPriority: 100,
79
+ rules: [{ id: 'eq-b', when: { all: [{ field: 'symbol.name', op: 'eq', value: 'X' }] } }],
80
+ },
81
+ ],
82
+ };
83
+ const out = assignModules(input);
84
+ assert.equal(out.membershipsBySymbol.get('Class:X')?.moduleName, 'EqWinsBySpecificity');
85
+ assert.equal(out.membershipsBySymbol.get('Class:X')?.resolvedBy, 'specificity');
86
+ });
87
+ test('higher effective priority wins even with lower specificity', () => {
88
+ const input = baseInput();
89
+ input.symbols = [{ id: 'Class:X', name: 'X', kind: 'Class', filePath: 'x.ts' }];
90
+ input.autoMemberships = [{ nodeId: 'Class:X', communityId: 'comm_0' }];
91
+ input.autoCommunities = [{ id: 'comm_0', label: 'Auto', heuristicLabel: 'Auto', cohesion: 0.4, symbolCount: 1 }];
92
+ input.config = {
93
+ version: 1,
94
+ mode: 'mixed',
95
+ modules: [
96
+ {
97
+ name: 'HighPriorityContains',
98
+ defaultPriority: 120,
99
+ rules: [{ id: 'contains-a', when: { all: [{ field: 'symbol.name', op: 'contains', value: 'X' }] } }],
100
+ },
101
+ {
102
+ name: 'LowPriorityEq',
103
+ defaultPriority: 100,
104
+ rules: [{ id: 'eq-b', when: { all: [{ field: 'symbol.name', op: 'eq', value: 'X' }] } }],
105
+ },
106
+ ],
107
+ };
108
+ const out = assignModules(input);
109
+ assert.equal(out.membershipsBySymbol.get('Class:X')?.moduleName, 'HighPriorityContains');
110
+ assert.equal(out.membershipsBySymbol.get('Class:X')?.resolvedBy, 'priority');
111
+ });
@@ -0,0 +1,2 @@
1
+ import { type LoadModuleConfigInput, type ModuleConfigLoadResult } from './types.js';
2
+ export declare function loadModuleConfig(input: LoadModuleConfigInput): Promise<ModuleConfigLoadResult>;