@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.
Files changed (26) hide show
  1. package/README.md +4 -4
  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/setup.js +24 -3
  7. package/dist/cli/setup.test.js +6 -4
  8. package/dist/core/ingestion/modules/assignment-engine.d.ts +33 -0
  9. package/dist/core/ingestion/modules/assignment-engine.js +179 -0
  10. package/dist/core/ingestion/modules/assignment-engine.test.d.ts +1 -0
  11. package/dist/core/ingestion/modules/assignment-engine.test.js +111 -0
  12. package/dist/core/ingestion/modules/config-loader.d.ts +2 -0
  13. package/dist/core/ingestion/modules/config-loader.js +186 -0
  14. package/dist/core/ingestion/modules/config-loader.test.d.ts +1 -0
  15. package/dist/core/ingestion/modules/config-loader.test.js +57 -0
  16. package/dist/core/ingestion/modules/rule-matcher.d.ts +12 -0
  17. package/dist/core/ingestion/modules/rule-matcher.js +63 -0
  18. package/dist/core/ingestion/modules/rule-matcher.test.d.ts +1 -0
  19. package/dist/core/ingestion/modules/rule-matcher.test.js +58 -0
  20. package/dist/core/ingestion/modules/types.d.ts +44 -0
  21. package/dist/core/ingestion/modules/types.js +2 -0
  22. package/dist/mcp/local/cluster-aggregation.d.ts +20 -0
  23. package/dist/mcp/local/cluster-aggregation.js +48 -0
  24. package/dist/mcp/local/cluster-aggregation.test.d.ts +1 -0
  25. package/dist/mcp/local/cluster-aggregation.test.js +22 -0
  26. 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,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', 'gitnexus@latest', 'mcp'],
64
+ args: ['/c', 'npx', '-y', MCP_PACKAGE_SPEC, 'mcp'],
44
65
  };
45
66
  }
46
67
  return {
47
68
  command: 'npx',
48
- args: ['-y', 'gitnexus@latest', 'mcp'],
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(' claude mcp add gitnexus -- npx -y gitnexus mcp');
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
  }
@@ -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('gitnexus@latest'));
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', 'gitnexus@latest', 'mcp']);
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', 'gitnexus@latest', 'mcp']);
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', 'gitnexus@latest', 'mcp']);
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,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>;
@@ -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,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,2 @@
1
+ export const MODULE_FIELDS = ['symbol.name', 'symbol.kind', 'symbol.fqn', 'file.path'];
2
+ export const MODULE_OPERATORS = ['eq', 'contains', 'regex', 'in'];
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veewo/gitnexus",
3
- "version": "1.3.7",
3
+ "version": "1.3.8",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",