@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.
- package/README.md +21 -13
- package/dist/cli/analyze-custom-modules-regression.test.d.ts +1 -0
- package/dist/cli/analyze-custom-modules-regression.test.js +75 -0
- package/dist/cli/analyze-modules-diagnostics.test.d.ts +1 -0
- package/dist/cli/analyze-modules-diagnostics.test.js +36 -0
- package/dist/cli/index.js +3 -2
- package/dist/cli/setup.d.ts +4 -3
- package/dist/cli/setup.js +163 -20
- package/dist/cli/setup.test.js +180 -34
- package/dist/core/ingestion/modules/assignment-engine.d.ts +33 -0
- package/dist/core/ingestion/modules/assignment-engine.js +179 -0
- package/dist/core/ingestion/modules/assignment-engine.test.d.ts +1 -0
- package/dist/core/ingestion/modules/assignment-engine.test.js +111 -0
- package/dist/core/ingestion/modules/config-loader.d.ts +2 -0
- package/dist/core/ingestion/modules/config-loader.js +186 -0
- package/dist/core/ingestion/modules/config-loader.test.d.ts +1 -0
- package/dist/core/ingestion/modules/config-loader.test.js +57 -0
- package/dist/core/ingestion/modules/rule-matcher.d.ts +12 -0
- package/dist/core/ingestion/modules/rule-matcher.js +63 -0
- package/dist/core/ingestion/modules/rule-matcher.test.d.ts +1 -0
- package/dist/core/ingestion/modules/rule-matcher.test.js +58 -0
- package/dist/core/ingestion/modules/types.d.ts +44 -0
- package/dist/core/ingestion/modules/types.js +2 -0
- package/dist/mcp/local/cluster-aggregation.d.ts +20 -0
- package/dist/mcp/local/cluster-aggregation.js +48 -0
- package/dist/mcp/local/cluster-aggregation.test.d.ts +1 -0
- package/dist/mcp/local/cluster-aggregation.test.js +22 -0
- package/package.json +1 -1
package/dist/cli/setup.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
112
|
-
const
|
|
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(
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|