@veewo/gitnexus 1.3.7 → 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 +4 -4
- 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/setup.js +24 -3
- package/dist/cli/setup.test.js +6 -4
- 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/README.md
CHANGED
|
@@ -70,7 +70,7 @@ If you prefer to configure manually instead of using `gitnexus setup`:
|
|
|
70
70
|
### Claude Code (full support — MCP + skills + hooks)
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
|
-
claude mcp add gitnexus -- npx -y gitnexus@latest mcp
|
|
73
|
+
claude mcp add gitnexus -- npx -y @veewo/gitnexus@latest mcp
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
### Cursor / Windsurf
|
|
@@ -82,7 +82,7 @@ Add to `~/.cursor/mcp.json` (global — works for all projects):
|
|
|
82
82
|
"mcpServers": {
|
|
83
83
|
"gitnexus": {
|
|
84
84
|
"command": "npx",
|
|
85
|
-
"args": ["-y", "gitnexus@latest", "mcp"]
|
|
85
|
+
"args": ["-y", "@veewo/gitnexus@latest", "mcp"]
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
}
|
|
@@ -97,7 +97,7 @@ Add to `~/.config/opencode/opencode.json`:
|
|
|
97
97
|
"mcp": {
|
|
98
98
|
"gitnexus": {
|
|
99
99
|
"type": "local",
|
|
100
|
-
"command": ["npx", "-y", "gitnexus@latest", "mcp"]
|
|
100
|
+
"command": ["npx", "-y", "@veewo/gitnexus@latest", "mcp"]
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
}
|
|
@@ -106,7 +106,7 @@ Add to `~/.config/opencode/opencode.json`:
|
|
|
106
106
|
### Codex
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
codex mcp add gitnexus -- npx -y gitnexus@latest mcp
|
|
109
|
+
codex mcp add gitnexus -- npx -y @veewo/gitnexus@latest mcp
|
|
110
110
|
```
|
|
111
111
|
|
|
112
112
|
## How It Works
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { runPipelineFromRepo } from '../core/ingestion/pipeline.js';
|
|
7
|
+
const FIXTURE_REPO = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../benchmarks/fixtures/unity-mini');
|
|
8
|
+
async function copyFixtureRepo() {
|
|
9
|
+
const tmpRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-custom-modules-'));
|
|
10
|
+
await fs.cp(FIXTURE_REPO, tmpRepo, { recursive: true });
|
|
11
|
+
await fs.mkdir(path.join(tmpRepo, '.gitnexus'), { recursive: true });
|
|
12
|
+
return tmpRepo;
|
|
13
|
+
}
|
|
14
|
+
test('pipeline mixed mode writes config module + auto fallback memberships', { timeout: 120_000 }, async () => {
|
|
15
|
+
const repoPath = await copyFixtureRepo();
|
|
16
|
+
const configPath = path.join(repoPath, '.gitnexus', 'modules.json');
|
|
17
|
+
await fs.writeFile(configPath, JSON.stringify({
|
|
18
|
+
version: 1,
|
|
19
|
+
mode: 'mixed',
|
|
20
|
+
modules: [
|
|
21
|
+
{
|
|
22
|
+
name: 'Factory',
|
|
23
|
+
defaultPriority: 100,
|
|
24
|
+
rules: [
|
|
25
|
+
{
|
|
26
|
+
id: 'factory-file-rule',
|
|
27
|
+
when: {
|
|
28
|
+
all: [{ field: 'file.path', op: 'contains', value: 'MinionFactory.cs' }],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'Battle',
|
|
35
|
+
defaultPriority: 100,
|
|
36
|
+
rules: [],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
}), 'utf-8');
|
|
40
|
+
const result = await runPipelineFromRepo(repoPath, () => { }, { includeExtensions: ['.cs'] });
|
|
41
|
+
const communities = [...result.graph.iterNodes()].filter((n) => n.label === 'Community');
|
|
42
|
+
const memberships = [...result.graph.iterRelationships()].filter((r) => r.type === 'MEMBER_OF');
|
|
43
|
+
const commById = new Map(communities.map((c) => [c.id, c]));
|
|
44
|
+
const labels = communities.map((c) => String(c.properties.heuristicLabel || c.properties.name || c.id));
|
|
45
|
+
assert.ok(labels.includes('Factory'));
|
|
46
|
+
assert.ok(labels.includes('Battle'));
|
|
47
|
+
const createMembership = memberships.find((m) => m.sourceId.includes('MinionFactory.cs:Create'));
|
|
48
|
+
assert.ok(createMembership);
|
|
49
|
+
const targetCommunity = commById.get(createMembership.targetId);
|
|
50
|
+
assert.ok(targetCommunity);
|
|
51
|
+
assert.equal(String(targetCommunity.properties.heuristicLabel), 'Factory');
|
|
52
|
+
const uniquePerSymbol = new Set(memberships.map((m) => m.sourceId));
|
|
53
|
+
assert.equal(uniquePerSymbol.size, memberships.length);
|
|
54
|
+
const battleCommunity = communities.find((c) => String(c.properties.heuristicLabel) === 'Battle');
|
|
55
|
+
assert.ok(battleCommunity);
|
|
56
|
+
assert.ok(!memberships.some((m) => m.targetId === battleCommunity.id));
|
|
57
|
+
const membershipCommunities = new Set(memberships.map((m) => m.targetId));
|
|
58
|
+
const processCommunities = new Set((result.processResult?.processes || []).flatMap((p) => p.communities || []));
|
|
59
|
+
for (const processCommunity of processCommunities) {
|
|
60
|
+
assert.ok(membershipCommunities.has(processCommunity));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
test('pipeline mixed + invalid modules.json fails fast', { timeout: 120_000 }, async () => {
|
|
64
|
+
const repoPath = await copyFixtureRepo();
|
|
65
|
+
const configPath = path.join(repoPath, '.gitnexus', 'modules.json');
|
|
66
|
+
await fs.writeFile(configPath, JSON.stringify({
|
|
67
|
+
version: 1,
|
|
68
|
+
mode: 'mixed',
|
|
69
|
+
modules: [
|
|
70
|
+
{ name: 'Dup', defaultPriority: 100, rules: [] },
|
|
71
|
+
{ name: 'Dup', defaultPriority: 100, rules: [] },
|
|
72
|
+
],
|
|
73
|
+
}), 'utf-8');
|
|
74
|
+
await assert.rejects(runPipelineFromRepo(repoPath, () => { }, { includeExtensions: ['.cs'] }), /duplicate module name/i);
|
|
75
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { formatModuleDiagnostics } from './analyze.js';
|
|
7
|
+
test('prints one-time fallback warning for mixed + missing modules.json', () => {
|
|
8
|
+
const lines = formatModuleDiagnostics({
|
|
9
|
+
mode: 'mixed',
|
|
10
|
+
usedFallbackAuto: true,
|
|
11
|
+
warnings: ['modules.json missing in mixed mode, fallback to auto'],
|
|
12
|
+
emptyModules: [],
|
|
13
|
+
configuredModuleCount: 0,
|
|
14
|
+
finalModuleCount: 3,
|
|
15
|
+
});
|
|
16
|
+
assert.ok(lines.some((line) => /fallback to auto/i.test(line)));
|
|
17
|
+
});
|
|
18
|
+
test('prints warning for empty modules', () => {
|
|
19
|
+
const lines = formatModuleDiagnostics({
|
|
20
|
+
mode: 'mixed',
|
|
21
|
+
usedFallbackAuto: false,
|
|
22
|
+
warnings: [],
|
|
23
|
+
emptyModules: ['Battle'],
|
|
24
|
+
configuredModuleCount: 1,
|
|
25
|
+
finalModuleCount: 2,
|
|
26
|
+
});
|
|
27
|
+
assert.ok(lines.some((line) => /empty module/i.test(line)));
|
|
28
|
+
assert.ok(lines.some((line) => /Battle/.test(line)));
|
|
29
|
+
});
|
|
30
|
+
test('README mentions modules.json mode semantics and fallback behavior', async () => {
|
|
31
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const readmePath = path.resolve(here, '../../README.md');
|
|
33
|
+
const readme = await fs.readFile(readmePath, 'utf-8');
|
|
34
|
+
assert.match(readme, /\.gitnexus\/modules\.json/);
|
|
35
|
+
assert.match(readme, /mixed.*fallback.*auto/i);
|
|
36
|
+
});
|
package/dist/cli/setup.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* in either global or project scope.
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'fs/promises';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
9
10
|
import path from 'path';
|
|
10
11
|
import os from 'os';
|
|
11
12
|
import { execFile } from 'node:child_process';
|
|
@@ -16,6 +17,7 @@ import { getGitRoot } from '../storage/git.js';
|
|
|
16
17
|
const __filename = fileURLToPath(import.meta.url);
|
|
17
18
|
const __dirname = path.dirname(__filename);
|
|
18
19
|
const execFileAsync = promisify(execFile);
|
|
20
|
+
const FALLBACK_MCP_PACKAGE = 'gitnexus@latest';
|
|
19
21
|
function resolveSetupScope(rawScope) {
|
|
20
22
|
if (!rawScope || rawScope.trim() === '')
|
|
21
23
|
return 'global';
|
|
@@ -32,6 +34,25 @@ function resolveSetupAgent(rawAgent) {
|
|
|
32
34
|
}
|
|
33
35
|
throw new Error(`Invalid --agent value "${rawAgent}". Use "claude", "opencode", or "codex".`);
|
|
34
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the package spec used by MCP commands.
|
|
39
|
+
* Defaults to gitnexus@latest when package metadata is unavailable.
|
|
40
|
+
*/
|
|
41
|
+
function resolveMcpPackageSpec() {
|
|
42
|
+
try {
|
|
43
|
+
const packageJsonPath = path.join(__dirname, '..', '..', 'package.json');
|
|
44
|
+
const raw = readFileSync(packageJsonPath, 'utf-8');
|
|
45
|
+
const parsed = JSON.parse(raw);
|
|
46
|
+
if (typeof parsed.name === 'string' && parsed.name.trim().length > 0) {
|
|
47
|
+
return `${parsed.name}@latest`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Fallback keeps behavior for unusual runtimes.
|
|
52
|
+
}
|
|
53
|
+
return FALLBACK_MCP_PACKAGE;
|
|
54
|
+
}
|
|
55
|
+
const MCP_PACKAGE_SPEC = resolveMcpPackageSpec();
|
|
35
56
|
/**
|
|
36
57
|
* The MCP server entry for all editors.
|
|
37
58
|
* On Windows, npx must be invoked via cmd /c since it's a .cmd script.
|
|
@@ -40,12 +61,12 @@ function getMcpEntry() {
|
|
|
40
61
|
if (process.platform === 'win32') {
|
|
41
62
|
return {
|
|
42
63
|
command: 'cmd',
|
|
43
|
-
args: ['/c', 'npx', '-y',
|
|
64
|
+
args: ['/c', 'npx', '-y', MCP_PACKAGE_SPEC, 'mcp'],
|
|
44
65
|
};
|
|
45
66
|
}
|
|
46
67
|
return {
|
|
47
68
|
command: 'npx',
|
|
48
|
-
args: ['-y',
|
|
69
|
+
args: ['-y', MCP_PACKAGE_SPEC, 'mcp'],
|
|
49
70
|
};
|
|
50
71
|
}
|
|
51
72
|
function getOpenCodeMcpEntry() {
|
|
@@ -190,7 +211,7 @@ async function setupClaudeCode(result) {
|
|
|
190
211
|
console.log('');
|
|
191
212
|
console.log(' Claude Code detected. Run this command to add GitNexus MCP:');
|
|
192
213
|
console.log('');
|
|
193
|
-
console.log(
|
|
214
|
+
console.log(` claude mcp add gitnexus -- npx -y ${MCP_PACKAGE_SPEC} mcp`);
|
|
194
215
|
console.log('');
|
|
195
216
|
result.configured.push('Claude Code (MCP manual step printed)');
|
|
196
217
|
}
|
package/dist/cli/setup.test.js
CHANGED
|
@@ -10,6 +10,8 @@ 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
|
+
const packageName = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf-8'));
|
|
14
|
+
const expectedMcpPackage = `${packageName.name || 'gitnexus'}@latest`;
|
|
13
15
|
async function runSetup(args, env, cwd = packageRoot) {
|
|
14
16
|
return execFileAsync(process.execPath, [cliPath, 'setup', ...args], { cwd, env });
|
|
15
17
|
}
|
|
@@ -112,7 +114,7 @@ process.exit(0);
|
|
|
112
114
|
const raw = await fs.readFile(outputPath, 'utf-8');
|
|
113
115
|
const parsed = JSON.parse(raw);
|
|
114
116
|
assert.deepEqual(parsed.args.slice(0, 4), ['mcp', 'add', 'gitnexus', '--']);
|
|
115
|
-
assert.ok(parsed.args.includes(
|
|
117
|
+
assert.ok(parsed.args.includes(expectedMcpPackage));
|
|
116
118
|
assert.ok(parsed.args.includes('mcp'));
|
|
117
119
|
}
|
|
118
120
|
finally {
|
|
@@ -134,7 +136,7 @@ test('setup configures OpenCode MCP in ~/.config/opencode/opencode.json', async
|
|
|
134
136
|
const opencodeRaw = await fs.readFile(opencodeConfigPath, 'utf-8');
|
|
135
137
|
const opencodeConfig = JSON.parse(opencodeRaw);
|
|
136
138
|
assert.equal(opencodeConfig.mcp?.gitnexus?.type, 'local');
|
|
137
|
-
assert.deepEqual(opencodeConfig.mcp?.gitnexus?.command, ['npx', '-y',
|
|
139
|
+
assert.deepEqual(opencodeConfig.mcp?.gitnexus?.command, ['npx', '-y', expectedMcpPackage, 'mcp']);
|
|
138
140
|
}
|
|
139
141
|
finally {
|
|
140
142
|
await fs.rm(fakeHome, { recursive: true, force: true });
|
|
@@ -157,7 +159,7 @@ test('setup keeps using legacy ~/.config/opencode/config.json when it already ex
|
|
|
157
159
|
const legacyConfig = JSON.parse(legacyRaw);
|
|
158
160
|
assert.equal(legacyConfig.existing, true);
|
|
159
161
|
assert.equal(legacyConfig.mcp?.gitnexus?.type, 'local');
|
|
160
|
-
assert.deepEqual(legacyConfig.mcp?.gitnexus?.command, ['npx', '-y',
|
|
162
|
+
assert.deepEqual(legacyConfig.mcp?.gitnexus?.command, ['npx', '-y', expectedMcpPackage, 'mcp']);
|
|
161
163
|
await assert.rejects(fs.access(preferredConfigPath));
|
|
162
164
|
}
|
|
163
165
|
finally {
|
|
@@ -254,7 +256,7 @@ test('setup --scope project --agent opencode writes only opencode.json', async (
|
|
|
254
256
|
const configRaw = await fs.readFile(configPath, 'utf-8');
|
|
255
257
|
const config = JSON.parse(configRaw);
|
|
256
258
|
assert.equal(opencodeConfig.mcp?.gitnexus?.type, 'local');
|
|
257
|
-
assert.deepEqual(opencodeConfig.mcp?.gitnexus?.command, ['npx', '-y',
|
|
259
|
+
assert.deepEqual(opencodeConfig.mcp?.gitnexus?.command, ['npx', '-y', expectedMcpPackage, 'mcp']);
|
|
258
260
|
await assert.rejects(fs.access(projectMcpPath));
|
|
259
261
|
await assert.rejects(fs.access(codexConfigPath));
|
|
260
262
|
await fs.access(localSkillPath);
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { MODULE_FIELDS, MODULE_OPERATORS, } from './types.js';
|
|
4
|
+
const MODULE_CONFIG_RELATIVE_PATH = path.join('.gitnexus', 'modules.json');
|
|
5
|
+
function assertObject(input, fieldPath) {
|
|
6
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
7
|
+
throw new Error(`${fieldPath} must be an object`);
|
|
8
|
+
}
|
|
9
|
+
return input;
|
|
10
|
+
}
|
|
11
|
+
function assertString(input, fieldPath) {
|
|
12
|
+
if (typeof input !== 'string' || input.trim().length === 0) {
|
|
13
|
+
throw new Error(`${fieldPath} must be a non-empty string`);
|
|
14
|
+
}
|
|
15
|
+
return input.trim();
|
|
16
|
+
}
|
|
17
|
+
function assertNumber(input, fieldPath) {
|
|
18
|
+
if (typeof input !== 'number' || !Number.isFinite(input)) {
|
|
19
|
+
throw new Error(`${fieldPath} must be a finite number`);
|
|
20
|
+
}
|
|
21
|
+
return input;
|
|
22
|
+
}
|
|
23
|
+
function toMode(input, defaultMode) {
|
|
24
|
+
if (input === undefined || input === null || input === '')
|
|
25
|
+
return defaultMode;
|
|
26
|
+
if (input === 'auto' || input === 'mixed')
|
|
27
|
+
return input;
|
|
28
|
+
throw new Error(`mode must be one of: auto, mixed`);
|
|
29
|
+
}
|
|
30
|
+
function validateCondition(input, fieldPath) {
|
|
31
|
+
const obj = assertObject(input, fieldPath);
|
|
32
|
+
const field = assertString(obj.field, `${fieldPath}.field`);
|
|
33
|
+
const op = assertString(obj.op, `${fieldPath}.op`);
|
|
34
|
+
if (!MODULE_FIELDS.includes(field)) {
|
|
35
|
+
throw new Error(`${fieldPath}.field must be one of: ${MODULE_FIELDS.join(', ')}`);
|
|
36
|
+
}
|
|
37
|
+
if (!MODULE_OPERATORS.includes(op)) {
|
|
38
|
+
throw new Error(`${fieldPath}.op must be one of: ${MODULE_OPERATORS.join(', ')}`);
|
|
39
|
+
}
|
|
40
|
+
const value = obj.value;
|
|
41
|
+
if (op === 'in') {
|
|
42
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((v) => typeof v !== 'string' || v.length === 0)) {
|
|
43
|
+
throw new Error(`${fieldPath}.value must be a non-empty string array when op=in`);
|
|
44
|
+
}
|
|
45
|
+
return { field: field, op: 'in', value: value };
|
|
46
|
+
}
|
|
47
|
+
if (typeof value !== 'string') {
|
|
48
|
+
throw new Error(`${fieldPath}.value must be a string when op=${op}`);
|
|
49
|
+
}
|
|
50
|
+
if (op === 'regex') {
|
|
51
|
+
try {
|
|
52
|
+
new RegExp(value);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
throw new Error(`${fieldPath}.value regex compile failed: ${error?.message || String(error)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
field: field,
|
|
60
|
+
op: op,
|
|
61
|
+
value,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function validateRule(input, fieldPath, seenRuleIds) {
|
|
65
|
+
const obj = assertObject(input, fieldPath);
|
|
66
|
+
const id = assertString(obj.id, `${fieldPath}.id`);
|
|
67
|
+
const existingPath = seenRuleIds.get(id);
|
|
68
|
+
if (existingPath) {
|
|
69
|
+
throw new Error(`${fieldPath}.id duplicate rule id "${id}" (already declared at ${existingPath})`);
|
|
70
|
+
}
|
|
71
|
+
seenRuleIds.set(id, `${fieldPath}.id`);
|
|
72
|
+
const priority = obj.priority === undefined ? undefined : assertNumber(obj.priority, `${fieldPath}.priority`);
|
|
73
|
+
const whenObj = assertObject(obj.when, `${fieldPath}.when`);
|
|
74
|
+
const hasAll = Array.isArray(whenObj.all);
|
|
75
|
+
const hasAny = Array.isArray(whenObj.any);
|
|
76
|
+
if (!hasAll && !hasAny) {
|
|
77
|
+
throw new Error(`${fieldPath}.when must include "all" or "any"`);
|
|
78
|
+
}
|
|
79
|
+
const all = hasAll ? whenObj.all.map((c, idx) => validateCondition(c, `${fieldPath}.when.all[${idx}]`)) : undefined;
|
|
80
|
+
const any = hasAny ? whenObj.any.map((c, idx) => validateCondition(c, `${fieldPath}.when.any[${idx}]`)) : undefined;
|
|
81
|
+
if ((all && all.length === 0) && (!any || any.length === 0)) {
|
|
82
|
+
throw new Error(`${fieldPath}.when must include at least one condition`);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
priority,
|
|
87
|
+
when: { all, any },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function validateModule(input, fieldPath, seenRuleIds) {
|
|
91
|
+
const obj = assertObject(input, fieldPath);
|
|
92
|
+
const name = assertString(obj.name, `${fieldPath}.name`);
|
|
93
|
+
const defaultPriority = assertNumber(obj.defaultPriority, `${fieldPath}.defaultPriority`);
|
|
94
|
+
const rawRules = obj.rules;
|
|
95
|
+
if (rawRules !== undefined && !Array.isArray(rawRules)) {
|
|
96
|
+
throw new Error(`${fieldPath}.rules must be an array`);
|
|
97
|
+
}
|
|
98
|
+
const rules = rawRules?.map((rule, idx) => validateRule(rule, `${fieldPath}.rules[${idx}]`, seenRuleIds)) || [];
|
|
99
|
+
return { name, defaultPriority, rules };
|
|
100
|
+
}
|
|
101
|
+
function validateMixedConfig(input, defaultMode) {
|
|
102
|
+
const obj = assertObject(input, 'modules.json');
|
|
103
|
+
if (obj.version !== 1) {
|
|
104
|
+
throw new Error(`version must be 1`);
|
|
105
|
+
}
|
|
106
|
+
const mode = toMode(obj.mode, defaultMode);
|
|
107
|
+
if (mode !== 'mixed') {
|
|
108
|
+
// auto mode intentionally ignores module schema to keep behavior non-blocking.
|
|
109
|
+
return { version: 1, mode: 'auto', modules: [] };
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(obj.modules) || obj.modules.length === 0) {
|
|
112
|
+
throw new Error(`modules must be a non-empty array`);
|
|
113
|
+
}
|
|
114
|
+
const seenModuleNames = new Set();
|
|
115
|
+
const seenRuleIds = new Map();
|
|
116
|
+
const modules = obj.modules.map((mod, idx) => {
|
|
117
|
+
const normalized = validateModule(mod, `modules[${idx}]`, seenRuleIds);
|
|
118
|
+
if (seenModuleNames.has(normalized.name)) {
|
|
119
|
+
throw new Error(`modules[${idx}].name duplicate module name "${normalized.name}"`);
|
|
120
|
+
}
|
|
121
|
+
seenModuleNames.add(normalized.name);
|
|
122
|
+
return normalized;
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
version: 1,
|
|
126
|
+
mode: 'mixed',
|
|
127
|
+
modules,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export async function loadModuleConfig(input) {
|
|
131
|
+
const defaultMode = input.defaultMode ?? 'mixed';
|
|
132
|
+
const configPath = path.join(input.repoPath, MODULE_CONFIG_RELATIVE_PATH);
|
|
133
|
+
const diagnostics = {
|
|
134
|
+
configPath,
|
|
135
|
+
usedFallbackAuto: false,
|
|
136
|
+
warnings: [],
|
|
137
|
+
};
|
|
138
|
+
if (defaultMode === 'auto') {
|
|
139
|
+
return {
|
|
140
|
+
mode: 'auto',
|
|
141
|
+
config: null,
|
|
142
|
+
diagnostics,
|
|
143
|
+
usedFallbackAuto: false,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
let rawText;
|
|
147
|
+
try {
|
|
148
|
+
rawText = await fs.readFile(configPath, 'utf-8');
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (error?.code === 'ENOENT') {
|
|
152
|
+
diagnostics.usedFallbackAuto = true;
|
|
153
|
+
diagnostics.warnings.push('modules.json missing in mixed mode, fallback to auto');
|
|
154
|
+
return {
|
|
155
|
+
mode: 'mixed',
|
|
156
|
+
config: null,
|
|
157
|
+
diagnostics,
|
|
158
|
+
usedFallbackAuto: true,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
let rawConfig;
|
|
164
|
+
try {
|
|
165
|
+
rawConfig = JSON.parse(rawText);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
throw new Error(`modules.json invalid JSON: ${error?.message || String(error)}`);
|
|
169
|
+
}
|
|
170
|
+
const parsedMode = toMode(assertObject(rawConfig, 'modules.json').mode, defaultMode);
|
|
171
|
+
if (parsedMode === 'auto') {
|
|
172
|
+
return {
|
|
173
|
+
mode: 'auto',
|
|
174
|
+
config: null,
|
|
175
|
+
diagnostics,
|
|
176
|
+
usedFallbackAuto: false,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const config = validateMixedConfig(rawConfig, defaultMode);
|
|
180
|
+
return {
|
|
181
|
+
mode: config.mode,
|
|
182
|
+
config,
|
|
183
|
+
diagnostics,
|
|
184
|
+
usedFallbackAuto: false,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { loadModuleConfig } from './config-loader.js';
|
|
7
|
+
async function makeTempRepo() {
|
|
8
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-modules-loader-'));
|
|
9
|
+
await fs.mkdir(path.join(dir, '.gitnexus'), { recursive: true });
|
|
10
|
+
return dir;
|
|
11
|
+
}
|
|
12
|
+
test('mixed + missing modules.json returns fallback diagnostic (no throw)', async () => {
|
|
13
|
+
const repoPath = await makeTempRepo();
|
|
14
|
+
const result = await loadModuleConfig({ repoPath, defaultMode: 'mixed' });
|
|
15
|
+
assert.equal(result.mode, 'mixed');
|
|
16
|
+
assert.equal(result.usedFallbackAuto, true);
|
|
17
|
+
assert.equal(result.config, null);
|
|
18
|
+
});
|
|
19
|
+
test('mixed + invalid duplicate rule id throws with location', async () => {
|
|
20
|
+
const repoPath = await makeTempRepo();
|
|
21
|
+
const cfgPath = path.join(repoPath, '.gitnexus', 'modules.json');
|
|
22
|
+
await fs.writeFile(cfgPath, JSON.stringify({
|
|
23
|
+
version: 1,
|
|
24
|
+
mode: 'mixed',
|
|
25
|
+
modules: [
|
|
26
|
+
{
|
|
27
|
+
name: 'A',
|
|
28
|
+
defaultPriority: 10,
|
|
29
|
+
rules: [{ id: 'dup', when: { all: [{ field: 'symbol.name', op: 'contains', value: 'X' }] } }],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'B',
|
|
33
|
+
defaultPriority: 10,
|
|
34
|
+
rules: [{ id: 'dup', when: { all: [{ field: 'symbol.name', op: 'contains', value: 'Y' }] } }],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
}), 'utf-8');
|
|
38
|
+
await assert.rejects(loadModuleConfig({ repoPath, defaultMode: 'mixed' }), /rules\[0\]\.id.*duplicate/i);
|
|
39
|
+
});
|
|
40
|
+
test('valid config defaults mode to mixed when omitted', async () => {
|
|
41
|
+
const repoPath = await makeTempRepo();
|
|
42
|
+
const cfgPath = path.join(repoPath, '.gitnexus', 'modules.json');
|
|
43
|
+
await fs.writeFile(cfgPath, JSON.stringify({
|
|
44
|
+
version: 1,
|
|
45
|
+
modules: [
|
|
46
|
+
{
|
|
47
|
+
name: 'Battle',
|
|
48
|
+
defaultPriority: 100,
|
|
49
|
+
rules: [{ id: 'battle-rule', when: { all: [{ field: 'symbol.name', op: 'contains', value: 'Battle' }] } }],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
}), 'utf-8');
|
|
53
|
+
const result = await loadModuleConfig({ repoPath, defaultMode: 'mixed' });
|
|
54
|
+
assert.equal(result.mode, 'mixed');
|
|
55
|
+
assert.equal(result.usedFallbackAuto, false);
|
|
56
|
+
assert.equal(result.config?.modules.length, 1);
|
|
57
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ModuleCondition, ModuleRule } from './types.js';
|
|
2
|
+
export interface MatchableSymbol {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
kind: string;
|
|
6
|
+
fqn?: string;
|
|
7
|
+
filePath: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function matchCondition(symbol: MatchableSymbol, condition: ModuleCondition): boolean;
|
|
10
|
+
export declare function matchRule(symbol: MatchableSymbol, rule: ModuleRule): boolean;
|
|
11
|
+
export declare function specificityScore(condition: ModuleCondition): number;
|
|
12
|
+
export declare function ruleSpecificityScore(rule: ModuleRule): number;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
function readFieldValue(symbol, field) {
|
|
2
|
+
switch (field) {
|
|
3
|
+
case 'symbol.name':
|
|
4
|
+
return symbol.name;
|
|
5
|
+
case 'symbol.kind':
|
|
6
|
+
return symbol.kind;
|
|
7
|
+
case 'symbol.fqn':
|
|
8
|
+
return symbol.fqn && symbol.fqn.length > 0 ? symbol.fqn : symbol.name;
|
|
9
|
+
case 'file.path':
|
|
10
|
+
return symbol.filePath;
|
|
11
|
+
default:
|
|
12
|
+
return '';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function matchCondition(symbol, condition) {
|
|
16
|
+
const actual = readFieldValue(symbol, condition.field);
|
|
17
|
+
if (condition.op === 'eq') {
|
|
18
|
+
return actual === condition.value;
|
|
19
|
+
}
|
|
20
|
+
if (condition.op === 'contains') {
|
|
21
|
+
return actual.includes(String(condition.value));
|
|
22
|
+
}
|
|
23
|
+
if (condition.op === 'regex') {
|
|
24
|
+
try {
|
|
25
|
+
return new RegExp(String(condition.value)).test(actual);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (condition.op === 'in') {
|
|
32
|
+
return Array.isArray(condition.value) && condition.value.includes(actual);
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
export function matchRule(symbol, rule) {
|
|
37
|
+
const hasAll = Array.isArray(rule.when.all);
|
|
38
|
+
const hasAny = Array.isArray(rule.when.any);
|
|
39
|
+
if (!hasAll && !hasAny)
|
|
40
|
+
return false;
|
|
41
|
+
const allPass = !hasAll || rule.when.all.every((cond) => matchCondition(symbol, cond));
|
|
42
|
+
const anyPass = !hasAny || rule.when.any.some((cond) => matchCondition(symbol, cond));
|
|
43
|
+
return allPass && anyPass;
|
|
44
|
+
}
|
|
45
|
+
export function specificityScore(condition) {
|
|
46
|
+
switch (condition.op) {
|
|
47
|
+
case 'eq':
|
|
48
|
+
return 4;
|
|
49
|
+
case 'in':
|
|
50
|
+
return 3;
|
|
51
|
+
case 'regex':
|
|
52
|
+
return 2;
|
|
53
|
+
case 'contains':
|
|
54
|
+
return 1;
|
|
55
|
+
default:
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function ruleSpecificityScore(rule) {
|
|
60
|
+
const all = rule.when.all || [];
|
|
61
|
+
const any = rule.when.any || [];
|
|
62
|
+
return [...all, ...any].reduce((sum, cond) => sum + specificityScore(cond), 0);
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { matchRule, specificityScore } from './rule-matcher.js';
|
|
4
|
+
const sample = {
|
|
5
|
+
id: 'Class:BattleManager',
|
|
6
|
+
name: 'BattleManager',
|
|
7
|
+
kind: 'Class',
|
|
8
|
+
filePath: 'Assets/Scripts/Battle/BattleManager.cs',
|
|
9
|
+
};
|
|
10
|
+
test('supports eq/contains/regex/in on symbol fields', () => {
|
|
11
|
+
assert.equal(matchRule(sample, {
|
|
12
|
+
id: 'r1',
|
|
13
|
+
when: { all: [{ field: 'symbol.kind', op: 'eq', value: 'Class' }] },
|
|
14
|
+
}), true);
|
|
15
|
+
assert.equal(matchRule(sample, {
|
|
16
|
+
id: 'r2',
|
|
17
|
+
when: { all: [{ field: 'symbol.name', op: 'contains', value: 'Battle' }] },
|
|
18
|
+
}), true);
|
|
19
|
+
assert.equal(matchRule(sample, {
|
|
20
|
+
id: 'r3',
|
|
21
|
+
when: { all: [{ field: 'symbol.name', op: 'regex', value: '^Battle.*' }] },
|
|
22
|
+
}), true);
|
|
23
|
+
assert.equal(matchRule(sample, {
|
|
24
|
+
id: 'r4',
|
|
25
|
+
when: { all: [{ field: 'file.path', op: 'in', value: ['Assets/Scripts/Battle/BattleManager.cs'] }] },
|
|
26
|
+
}), true);
|
|
27
|
+
});
|
|
28
|
+
test('all + any must both pass when both provided', () => {
|
|
29
|
+
const pass = matchRule(sample, {
|
|
30
|
+
id: 'all-any-pass',
|
|
31
|
+
when: {
|
|
32
|
+
all: [{ field: 'symbol.name', op: 'contains', value: 'Battle' }],
|
|
33
|
+
any: [{ field: 'symbol.kind', op: 'eq', value: 'Class' }],
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
assert.equal(pass, true);
|
|
37
|
+
const fail = matchRule(sample, {
|
|
38
|
+
id: 'all-any-fail',
|
|
39
|
+
when: {
|
|
40
|
+
all: [{ field: 'symbol.name', op: 'contains', value: 'Battle' }],
|
|
41
|
+
any: [{ field: 'symbol.kind', op: 'eq', value: 'Function' }],
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
assert.equal(fail, false);
|
|
45
|
+
});
|
|
46
|
+
test('symbol.fqn falls back to symbol.name when fqn missing', () => {
|
|
47
|
+
const hit = matchRule(sample, {
|
|
48
|
+
id: 'fqn-fallback',
|
|
49
|
+
when: { all: [{ field: 'symbol.fqn', op: 'contains', value: 'BattleManager' }] },
|
|
50
|
+
});
|
|
51
|
+
assert.equal(hit, true);
|
|
52
|
+
});
|
|
53
|
+
test('specificity score weights operators deterministically', () => {
|
|
54
|
+
assert.equal(specificityScore({ field: 'symbol.name', op: 'eq', value: 'A' }), 4);
|
|
55
|
+
assert.equal(specificityScore({ field: 'symbol.name', op: 'in', value: ['A'] }), 3);
|
|
56
|
+
assert.equal(specificityScore({ field: 'symbol.name', op: 'regex', value: '^A$' }), 2);
|
|
57
|
+
assert.equal(specificityScore({ field: 'symbol.name', op: 'contains', value: 'A' }), 1);
|
|
58
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type ModuleMode = 'auto' | 'mixed';
|
|
2
|
+
export type ModuleField = 'symbol.name' | 'symbol.kind' | 'symbol.fqn' | 'file.path';
|
|
3
|
+
export type ModuleOperator = 'eq' | 'contains' | 'regex' | 'in';
|
|
4
|
+
export interface ModuleCondition {
|
|
5
|
+
field: ModuleField;
|
|
6
|
+
op: ModuleOperator;
|
|
7
|
+
value: string | string[];
|
|
8
|
+
}
|
|
9
|
+
export interface ModuleRuleWhen {
|
|
10
|
+
all?: ModuleCondition[];
|
|
11
|
+
any?: ModuleCondition[];
|
|
12
|
+
}
|
|
13
|
+
export interface ModuleRule {
|
|
14
|
+
id: string;
|
|
15
|
+
priority?: number;
|
|
16
|
+
when: ModuleRuleWhen;
|
|
17
|
+
}
|
|
18
|
+
export interface ModuleDefinition {
|
|
19
|
+
name: string;
|
|
20
|
+
defaultPriority: number;
|
|
21
|
+
rules: ModuleRule[];
|
|
22
|
+
}
|
|
23
|
+
export interface ModuleConfig {
|
|
24
|
+
version: 1;
|
|
25
|
+
mode: ModuleMode;
|
|
26
|
+
modules: ModuleDefinition[];
|
|
27
|
+
}
|
|
28
|
+
export interface ModuleConfigLoadDiagnostics {
|
|
29
|
+
configPath: string;
|
|
30
|
+
usedFallbackAuto: boolean;
|
|
31
|
+
warnings: string[];
|
|
32
|
+
}
|
|
33
|
+
export interface LoadModuleConfigInput {
|
|
34
|
+
repoPath: string;
|
|
35
|
+
defaultMode?: ModuleMode;
|
|
36
|
+
}
|
|
37
|
+
export interface ModuleConfigLoadResult {
|
|
38
|
+
mode: ModuleMode;
|
|
39
|
+
config: ModuleConfig | null;
|
|
40
|
+
diagnostics: ModuleConfigLoadDiagnostics;
|
|
41
|
+
usedFallbackAuto: boolean;
|
|
42
|
+
}
|
|
43
|
+
export declare const MODULE_FIELDS: readonly ModuleField[];
|
|
44
|
+
export declare const MODULE_OPERATORS: readonly ModuleOperator[];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface RawCluster {
|
|
2
|
+
id: string;
|
|
3
|
+
label?: string;
|
|
4
|
+
heuristicLabel?: string;
|
|
5
|
+
cohesion?: number;
|
|
6
|
+
symbolCount?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface AggregatedCluster {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
heuristicLabel: string;
|
|
12
|
+
symbolCount: number;
|
|
13
|
+
cohesion: number;
|
|
14
|
+
subCommunities: number;
|
|
15
|
+
}
|
|
16
|
+
export interface ClusterAggregationOptions {
|
|
17
|
+
minSymbolCount?: number;
|
|
18
|
+
configuredIdPrefix?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function aggregateClusters(clusters: RawCluster[], options?: ClusterAggregationOptions): AggregatedCluster[];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const DEFAULT_MIN_SYMBOL_COUNT = 5;
|
|
2
|
+
const DEFAULT_CONFIGURED_ID_PREFIX = 'comm_cfg_';
|
|
3
|
+
export function aggregateClusters(clusters, options = {}) {
|
|
4
|
+
const minSymbolCount = options.minSymbolCount ?? DEFAULT_MIN_SYMBOL_COUNT;
|
|
5
|
+
const configuredIdPrefix = options.configuredIdPrefix ?? DEFAULT_CONFIGURED_ID_PREFIX;
|
|
6
|
+
const groups = new Map();
|
|
7
|
+
for (const cluster of clusters) {
|
|
8
|
+
const label = cluster.heuristicLabel || cluster.label || 'Unknown';
|
|
9
|
+
const symbols = cluster.symbolCount || 0;
|
|
10
|
+
const cohesion = cluster.cohesion || 0;
|
|
11
|
+
const isConfigured = cluster.id.startsWith(configuredIdPrefix);
|
|
12
|
+
const existing = groups.get(label);
|
|
13
|
+
if (!existing) {
|
|
14
|
+
groups.set(label, {
|
|
15
|
+
ids: [cluster.id],
|
|
16
|
+
totalSymbols: symbols,
|
|
17
|
+
weightedCohesion: cohesion * symbols,
|
|
18
|
+
largest: cluster,
|
|
19
|
+
hasConfigured: isConfigured,
|
|
20
|
+
});
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
existing.ids.push(cluster.id);
|
|
24
|
+
existing.totalSymbols += symbols;
|
|
25
|
+
existing.weightedCohesion += cohesion * symbols;
|
|
26
|
+
existing.hasConfigured = existing.hasConfigured || isConfigured;
|
|
27
|
+
if (symbols > (existing.largest.symbolCount || 0)) {
|
|
28
|
+
existing.largest = cluster;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return Array.from(groups.entries())
|
|
32
|
+
.map(([label, g]) => ({
|
|
33
|
+
id: g.largest.id,
|
|
34
|
+
label,
|
|
35
|
+
heuristicLabel: label,
|
|
36
|
+
symbolCount: g.totalSymbols,
|
|
37
|
+
cohesion: g.totalSymbols > 0 ? g.weightedCohesion / g.totalSymbols : 0,
|
|
38
|
+
subCommunities: g.ids.length,
|
|
39
|
+
hasConfigured: g.hasConfigured,
|
|
40
|
+
}))
|
|
41
|
+
.filter((c) => c.symbolCount >= minSymbolCount || c.hasConfigured)
|
|
42
|
+
.sort((a, b) => {
|
|
43
|
+
if (a.symbolCount !== b.symbolCount)
|
|
44
|
+
return b.symbolCount - a.symbolCount;
|
|
45
|
+
return a.heuristicLabel.localeCompare(b.heuristicLabel);
|
|
46
|
+
})
|
|
47
|
+
.map(({ hasConfigured: _hasConfigured, ...rest }) => rest);
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { aggregateClusters } from './cluster-aggregation.js';
|
|
4
|
+
test('keeps auto tiny clusters filtered but includes empty configured modules', () => {
|
|
5
|
+
const out = aggregateClusters([
|
|
6
|
+
{ id: 'comm_1', label: 'AutoTiny', heuristicLabel: 'AutoTiny', cohesion: 0.2, symbolCount: 1 },
|
|
7
|
+
{ id: 'comm_cfg_battle', label: 'Battle', heuristicLabel: 'Battle', cohesion: 0, symbolCount: 0 },
|
|
8
|
+
{ id: 'comm_2', label: 'Gameplay', heuristicLabel: 'Gameplay', cohesion: 0.8, symbolCount: 9 },
|
|
9
|
+
], { minSymbolCount: 5, configuredIdPrefix: 'comm_cfg_' });
|
|
10
|
+
assert.ok(out.some((c) => c.heuristicLabel === 'Battle' && c.symbolCount === 0));
|
|
11
|
+
assert.ok(!out.some((c) => c.heuristicLabel === 'AutoTiny'));
|
|
12
|
+
assert.ok(out.some((c) => c.heuristicLabel === 'Gameplay' && c.symbolCount === 9));
|
|
13
|
+
});
|
|
14
|
+
test('aggregates same-name clusters with weighted cohesion', () => {
|
|
15
|
+
const out = aggregateClusters([
|
|
16
|
+
{ id: 'comm_1', label: 'Gameplay', heuristicLabel: 'Gameplay', cohesion: 0.5, symbolCount: 4 },
|
|
17
|
+
{ id: 'comm_2', label: 'Gameplay', heuristicLabel: 'Gameplay', cohesion: 1.0, symbolCount: 6 },
|
|
18
|
+
], { minSymbolCount: 5, configuredIdPrefix: 'comm_cfg_' });
|
|
19
|
+
assert.equal(out.length, 1);
|
|
20
|
+
assert.equal(out[0].symbolCount, 10);
|
|
21
|
+
assert.equal(Math.round(out[0].cohesion * 100), 80);
|
|
22
|
+
});
|
package/package.json
CHANGED