@veewo/gitnexus 1.3.6 → 1.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -13
- package/dist/cli/analyze-custom-modules-regression.test.d.ts +1 -0
- package/dist/cli/analyze-custom-modules-regression.test.js +75 -0
- package/dist/cli/analyze-modules-diagnostics.test.d.ts +1 -0
- package/dist/cli/analyze-modules-diagnostics.test.js +36 -0
- package/dist/cli/index.js +3 -2
- package/dist/cli/setup.d.ts +4 -3
- package/dist/cli/setup.js +163 -20
- package/dist/cli/setup.test.js +180 -34
- package/dist/core/ingestion/modules/assignment-engine.d.ts +33 -0
- package/dist/core/ingestion/modules/assignment-engine.js +179 -0
- package/dist/core/ingestion/modules/assignment-engine.test.d.ts +1 -0
- package/dist/core/ingestion/modules/assignment-engine.test.js +111 -0
- package/dist/core/ingestion/modules/config-loader.d.ts +2 -0
- package/dist/core/ingestion/modules/config-loader.js +186 -0
- package/dist/core/ingestion/modules/config-loader.test.d.ts +1 -0
- package/dist/core/ingestion/modules/config-loader.test.js +57 -0
- package/dist/core/ingestion/modules/rule-matcher.d.ts +12 -0
- package/dist/core/ingestion/modules/rule-matcher.js +63 -0
- package/dist/core/ingestion/modules/rule-matcher.test.d.ts +1 -0
- package/dist/core/ingestion/modules/rule-matcher.test.js +58 -0
- package/dist/core/ingestion/modules/types.d.ts +44 -0
- package/dist/core/ingestion/modules/types.js +2 -0
- package/dist/mcp/local/cluster-aggregation.d.ts +20 -0
- package/dist/mcp/local/cluster-aggregation.js +48 -0
- package/dist/mcp/local/cluster-aggregation.test.d.ts +1 -0
- package/dist/mcp/local/cluster-aggregation.test.js +22 -0
- package/package.json +1 -1
|
@@ -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