@veewo/gitnexus 1.3.9 → 1.3.10
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/dist/cli/ai-context.js +1 -1
- package/dist/cli/analyze-multi-scope-regression.test.js +1 -1
- package/dist/cli/analyze-options.d.ts +19 -0
- package/dist/cli/analyze-options.js +35 -0
- package/dist/cli/analyze-options.test.js +42 -1
- package/dist/cli/analyze.d.ts +1 -0
- package/dist/cli/analyze.js +53 -26
- package/dist/cli/index.js +1 -0
- package/dist/cli/repo-manager-alias.test.js +24 -1
- package/dist/mcp/resources.js +1 -1
- package/dist/mcp/staleness.js +1 -1
- package/dist/storage/repo-manager.d.ts +6 -0
- package/package.json +3 -3
- package/dist/cli/analyze-custom-modules-regression.test.d.ts +0 -1
- package/dist/cli/analyze-custom-modules-regression.test.js +0 -75
- package/dist/cli/analyze-modules-diagnostics.test.d.ts +0 -1
- package/dist/cli/analyze-modules-diagnostics.test.js +0 -36
- package/dist/core/ingestion/modules/assignment-engine.d.ts +0 -33
- package/dist/core/ingestion/modules/assignment-engine.js +0 -179
- package/dist/core/ingestion/modules/assignment-engine.test.d.ts +0 -1
- package/dist/core/ingestion/modules/assignment-engine.test.js +0 -111
- package/dist/core/ingestion/modules/config-loader.d.ts +0 -2
- package/dist/core/ingestion/modules/config-loader.js +0 -186
- package/dist/core/ingestion/modules/config-loader.test.d.ts +0 -1
- package/dist/core/ingestion/modules/config-loader.test.js +0 -57
- package/dist/core/ingestion/modules/rule-matcher.d.ts +0 -12
- package/dist/core/ingestion/modules/rule-matcher.js +0 -63
- package/dist/core/ingestion/modules/rule-matcher.test.d.ts +0 -1
- package/dist/core/ingestion/modules/rule-matcher.test.js +0 -58
- package/dist/core/ingestion/modules/types.d.ts +0 -44
- package/dist/core/ingestion/modules/types.js +0 -2
- package/dist/mcp/local/cluster-aggregation.d.ts +0 -20
- package/dist/mcp/local/cluster-aggregation.js +0 -48
- package/dist/mcp/local/cluster-aggregation.test.d.ts +0 -1
- package/dist/mcp/local/cluster-aggregation.test.js +0 -22
|
@@ -1,179 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,111 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,186 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,57 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,12 +0,0 @@
|
|
|
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;
|
|
@@ -1,63 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,58 +0,0 @@
|
|
|
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
|
-
});
|