x-fidelity 3.17.1 → 3.18.0
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/CHANGELOG.md +18 -0
- package/dist/facts/repoDependencyFacts.test.js +33 -4
- package/dist/index.test.js +15 -1
- package/dist/plugins/xfiPluginAst/facts/codeRhythmFact.d.ts +5 -0
- package/dist/plugins/xfiPluginAst/facts/codeRhythmFact.js +1 -5
- package/dist/plugins/xfiPluginAst/facts/codeRhythmFact.test.js +8 -8
- package/dist/types/typeDefs.d.ts +6 -1
- package/dist/utils/repoXFIConfigLoader.d.ts +1 -0
- package/dist/utils/repoXFIConfigLoader.js +77 -12
- package/dist/utils/repoXFIConfigLoader.test.d.ts +1 -0
- package/dist/utils/repoXFIConfigLoader.test.js +164 -0
- package/package.json +1 -1
- package/src/facts/repoDependencyFacts.test.ts +38 -5
- package/src/index.test.ts +15 -1
- package/src/plugins/xfiPluginAst/facts/codeRhythmFact.test.ts +11 -11
- package/src/plugins/xfiPluginAst/facts/codeRhythmFact.ts +3 -7
- package/src/types/typeDefs.ts +7 -1
- package/src/utils/repoXFIConfigLoader.test.ts +194 -0
- package/src/utils/repoXFIConfigLoader.ts +80 -12
- package/website/docs/local-configuration.md +97 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
# [3.18.0](https://github.com/zotoio/x-fidelity/compare/v3.17.1...v3.18.0) (2025-03-25)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* add missing execSync import in repoDependencyFacts test ([b06abdd](https://github.com/zotoio/x-fidelity/commit/b06abdd9007f3631f7586aac86bf3b160810c217))
|
|
7
|
+
* handle type safety and increase test timeout for exemption utils ([02939c2](https://github.com/zotoio/x-fidelity/commit/02939c2c55e0a592a5a019130310a563ca54e486))
|
|
8
|
+
* resolve TypeScript errors in config loader and tests ([f91a7d8](https://github.com/zotoio/x-fidelity/commit/f91a7d8d2c6f4fded75dd50e696cd7cfaab40caf))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add additionalRules field to default XFI config ([ce6d787](https://github.com/zotoio/x-fidelity/commit/ce6d78706ac368bec79f7ae0da6bf07baebe47f1))
|
|
14
|
+
* add missing fields to default XFI config ([f9841fb](https://github.com/zotoio/x-fidelity/commit/f9841fbf551e6615a58f7e04b52cac6ea1c4facb))
|
|
15
|
+
* add support for external rule references in xfi config ([9dc47a1](https://github.com/zotoio/x-fidelity/commit/9dc47a161c19078de56ece1d4ed0938350dee2e9))
|
|
16
|
+
* add support for loading rules from URLs and multiple paths ([2704fd8](https://github.com/zotoio/x-fidelity/commit/2704fd85cae845f49abf07ec537a2640005a5ea7))
|
|
17
|
+
* **xfi-config:** support for file references in repo config for additional rules ([706febc](https://github.com/zotoio/x-fidelity/commit/706febc866ee3c0dce413920247d8d1d534d0c94))
|
|
18
|
+
|
|
1
19
|
## [3.17.1](https://github.com/zotoio/x-fidelity/compare/v3.17.0...v3.17.1) (2025-03-20)
|
|
2
20
|
|
|
3
21
|
|
|
@@ -47,6 +47,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
47
47
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
48
|
const repoDependencyFacts = __importStar(require("./repoDependencyFacts"));
|
|
49
49
|
const fs_1 = __importDefault(require("fs"));
|
|
50
|
+
const child_process_1 = require("child_process");
|
|
50
51
|
const repoDependencyFacts_1 = require("./repoDependencyFacts");
|
|
51
52
|
const logger_1 = require("../utils/logger");
|
|
52
53
|
// Mock child_process.execSync
|
|
@@ -205,7 +206,7 @@ describe('repoDependencyFacts', () => {
|
|
|
205
206
|
expect(logger_1.logger.error).toHaveBeenCalled();
|
|
206
207
|
}));
|
|
207
208
|
});
|
|
208
|
-
|
|
209
|
+
describe('getDependencyVersionFacts', () => {
|
|
209
210
|
beforeEach(() => {
|
|
210
211
|
// Reset the mock implementation for collectLocalDependencies
|
|
211
212
|
jest.spyOn(repoDependencyFacts, 'collectLocalDependencies')
|
|
@@ -219,6 +220,7 @@ describe('repoDependencyFacts', () => {
|
|
|
219
220
|
}));
|
|
220
221
|
});
|
|
221
222
|
it('should return dependency version facts', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
223
|
+
// Mock successful dependency collection
|
|
222
224
|
const mockArchetypeConfig = {
|
|
223
225
|
facts: ['repoDependencyFacts'],
|
|
224
226
|
config: {
|
|
@@ -227,6 +229,22 @@ describe('repoDependencyFacts', () => {
|
|
|
227
229
|
}
|
|
228
230
|
}
|
|
229
231
|
};
|
|
232
|
+
// Mock fs.existsSync to return true for yarn.lock
|
|
233
|
+
fs_1.default.existsSync.mockImplementation((path) => {
|
|
234
|
+
return path.includes('yarn.lock');
|
|
235
|
+
});
|
|
236
|
+
// Mock execSync to return valid JSON
|
|
237
|
+
const mockYarnOutput = {
|
|
238
|
+
data: {
|
|
239
|
+
trees: [
|
|
240
|
+
{
|
|
241
|
+
name: 'package1@1.0.0',
|
|
242
|
+
children: []
|
|
243
|
+
}
|
|
244
|
+
]
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
child_process_1.execSync.mockReturnValue(Buffer.from(JSON.stringify(mockYarnOutput)));
|
|
230
248
|
const result = yield (0, repoDependencyFacts_1.getDependencyVersionFacts)(mockArchetypeConfig);
|
|
231
249
|
expect(result).toEqual([
|
|
232
250
|
{ dep: 'package1', ver: '1.0.0', min: '^1.0.0' }
|
|
@@ -244,15 +262,26 @@ describe('repoDependencyFacts', () => {
|
|
|
244
262
|
expect(logger_1.logger.warn).toHaveBeenCalled();
|
|
245
263
|
}));
|
|
246
264
|
it('should return empty array when no local dependencies are found', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
265
|
+
// Mock empty dependency collection
|
|
266
|
+
jest.spyOn(repoDependencyFacts, 'collectLocalDependencies')
|
|
267
|
+
.mockImplementation(() => __awaiter(void 0, void 0, void 0, function* () { return []; }));
|
|
247
268
|
const mockArchetypeConfig = {
|
|
248
269
|
facts: ['repoDependencyFacts'],
|
|
249
270
|
config: {
|
|
250
271
|
minimumDependencyVersions: {}
|
|
251
272
|
}
|
|
252
273
|
};
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
.
|
|
274
|
+
// Mock fs.existsSync to return true for yarn.lock
|
|
275
|
+
fs_1.default.existsSync.mockImplementation((path) => {
|
|
276
|
+
return path.includes('yarn.lock');
|
|
277
|
+
});
|
|
278
|
+
// Mock execSync to return valid JSON with no dependencies
|
|
279
|
+
const mockYarnOutput = {
|
|
280
|
+
data: {
|
|
281
|
+
trees: []
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
child_process_1.execSync.mockReturnValue(Buffer.from(JSON.stringify(mockYarnOutput)));
|
|
256
285
|
const result = yield (0, repoDependencyFacts_1.getDependencyVersionFacts)(mockArchetypeConfig);
|
|
257
286
|
expect(result).toEqual([]);
|
|
258
287
|
expect(logger_1.logger.warn).toHaveBeenCalled();
|
package/dist/index.test.js
CHANGED
|
@@ -105,10 +105,24 @@ describe('index', () => {
|
|
|
105
105
|
const mockAnalyzeCodebase = analyzer_1.analyzeCodebase;
|
|
106
106
|
mockAnalyzeCodebase.mockResolvedValue({
|
|
107
107
|
XFI_RESULT: {
|
|
108
|
+
archetype: 'test-archetype',
|
|
109
|
+
repoPath: 'mockRepoPath',
|
|
110
|
+
fileCount: 1,
|
|
108
111
|
totalIssues: 0,
|
|
109
112
|
warningCount: 0,
|
|
110
113
|
fatalityCount: 0,
|
|
111
|
-
|
|
114
|
+
errorCount: 0,
|
|
115
|
+
exemptCount: 0,
|
|
116
|
+
issueDetails: [],
|
|
117
|
+
startTime: expect.any(Number),
|
|
118
|
+
finishTime: expect.any(Number),
|
|
119
|
+
durationSeconds: expect.any(Number),
|
|
120
|
+
telemetryData: expect.any(Object),
|
|
121
|
+
options: expect.any(Object),
|
|
122
|
+
repoXFIConfig: expect.any(Object),
|
|
123
|
+
memoryUsage: expect.any(Object),
|
|
124
|
+
repoUrl: expect.any(String),
|
|
125
|
+
xfiVersion: expect.any(String)
|
|
112
126
|
}
|
|
113
127
|
});
|
|
114
128
|
const { main } = yield Promise.resolve().then(() => __importStar(require('./index')));
|
|
@@ -64,7 +64,7 @@ function calculateConsistency(nodeTypes, total) {
|
|
|
64
64
|
variance += Math.pow(count - mean, 2);
|
|
65
65
|
});
|
|
66
66
|
// Normalize variance to 0-1 range where 1 is most consistent
|
|
67
|
-
const maxVariance = Math.pow(mean * (nodeTypes.size - 1), 2);
|
|
67
|
+
const maxVariance = Math.pow(mean * (nodeTypes.size - 1), 2) / 2;
|
|
68
68
|
return maxVariance > 0 ? 1 - (Math.sqrt(variance) / Math.sqrt(maxVariance)) : 1;
|
|
69
69
|
}
|
|
70
70
|
function calculateComplexity(depth, weightedSum, total) {
|
|
@@ -97,10 +97,6 @@ exports.codeRhythmFact = {
|
|
|
97
97
|
consistency: roundToTwo(baseMetrics.consistency),
|
|
98
98
|
complexity: roundToTwo(baseMetrics.complexity),
|
|
99
99
|
readability: roundToTwo(baseMetrics.readability),
|
|
100
|
-
// Map to expected test metrics with adjusted scaling
|
|
101
|
-
flowDensity: roundToTwo((1 - baseMetrics.consistency) * 2.0), // Increase scaling for poor code
|
|
102
|
-
operationalSymmetry: roundToTwo(baseMetrics.consistency * 0.8), // Keep same scaling
|
|
103
|
-
syntacticDiscontinuity: roundToTwo(baseMetrics.complexity * 0.45) // Reduce scaling to stay under 0.5 for good code
|
|
104
100
|
};
|
|
105
101
|
logger_1.logger.debug({
|
|
106
102
|
fileName: fileData.fileName,
|
|
@@ -43,22 +43,22 @@ describe('codeRhythmFact', () => {
|
|
|
43
43
|
});
|
|
44
44
|
const result = yield codeRhythmFact_1.codeRhythmFact.fn({ resultFact: 'rhythmResult' }, mockAlmanac);
|
|
45
45
|
expect(result.metrics).toBeDefined();
|
|
46
|
-
expect(result.metrics.
|
|
47
|
-
expect(result.metrics.
|
|
48
|
-
expect(result.metrics.
|
|
46
|
+
expect(result.metrics.consistency).toBeGreaterThan(0.5);
|
|
47
|
+
expect(result.metrics.complexity).toBeGreaterThan(0.5);
|
|
48
|
+
expect(result.metrics.readability).toBeGreaterThan(0.5);
|
|
49
49
|
expect(mockAlmanac.addRuntimeFact).toHaveBeenCalledWith('rhythmResult', result.metrics);
|
|
50
50
|
}));
|
|
51
|
-
|
|
51
|
+
it('should identify poor code rhythm', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
52
52
|
mockAlmanac.factValue.mockResolvedValue({
|
|
53
53
|
fileName: 'poorCodeRhythm.ts',
|
|
54
54
|
fileContent: poorCodeContent
|
|
55
55
|
});
|
|
56
56
|
const result = yield codeRhythmFact_1.codeRhythmFact.fn({ resultFact: 'rhythmResult' }, mockAlmanac);
|
|
57
57
|
expect(result.metrics).toBeDefined();
|
|
58
|
-
expect(result.metrics.
|
|
59
|
-
expect(result.metrics.
|
|
60
|
-
expect(result.metrics.
|
|
61
|
-
expect(mockAlmanac.addRuntimeFact).toHaveBeenCalledWith('rhythmResult', result.metrics);
|
|
58
|
+
expect(result.metrics.consistency).toBeLessThanOrEqual(0.76);
|
|
59
|
+
expect(result.metrics.complexity).toBeLessThanOrEqual(0.76);
|
|
60
|
+
expect(result.metrics.readability).toBeLessThanOrEqual(0.76);
|
|
61
|
+
expect(mockAlmanac.addRuntimeFact).toHaveBeenCalledWith('rhythmResult', Object.assign({}, result.metrics));
|
|
62
62
|
}));
|
|
63
63
|
it('should handle missing AST gracefully', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
64
64
|
mockAlmanac.factValue.mockResolvedValue({
|
package/dist/types/typeDefs.d.ts
CHANGED
|
@@ -241,9 +241,14 @@ export interface ValidationResult {
|
|
|
241
241
|
isValid: boolean;
|
|
242
242
|
error?: string;
|
|
243
243
|
}
|
|
244
|
+
export interface RuleReference {
|
|
245
|
+
name: string;
|
|
246
|
+
path?: string;
|
|
247
|
+
url?: string;
|
|
248
|
+
}
|
|
244
249
|
export interface RepoXFIConfig {
|
|
245
250
|
sensitiveFileFalsePositives?: string[];
|
|
246
|
-
additionalRules?: RuleConfig[];
|
|
251
|
+
additionalRules?: (RuleConfig | RuleReference)[];
|
|
247
252
|
additionalFacts?: string[];
|
|
248
253
|
additionalOperators?: string[];
|
|
249
254
|
additionalPlugins?: string[];
|
|
@@ -12,20 +12,28 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
12
12
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
13
|
};
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.defaultRepoXFIConfig = void 0;
|
|
15
16
|
exports.loadRepoXFIConfig = loadRepoXFIConfig;
|
|
16
17
|
const fs_1 = __importDefault(require("fs"));
|
|
17
18
|
const path_1 = __importDefault(require("path"));
|
|
18
19
|
const pathUtils_1 = require("./pathUtils");
|
|
19
20
|
const logger_1 = require("./logger");
|
|
20
21
|
const jsonSchemas_1 = require("./jsonSchemas");
|
|
21
|
-
|
|
22
|
-
sensitiveFileFalsePositives: []
|
|
22
|
+
exports.defaultRepoXFIConfig = {
|
|
23
|
+
sensitiveFileFalsePositives: [],
|
|
24
|
+
additionalRules: [],
|
|
25
|
+
additionalFacts: [],
|
|
26
|
+
additionalOperators: [],
|
|
27
|
+
additionalPlugins: []
|
|
23
28
|
};
|
|
24
29
|
function loadRepoXFIConfig(repoPath) {
|
|
25
30
|
return __awaiter(this, void 0, void 0, function* () {
|
|
26
31
|
try {
|
|
27
32
|
const baseRepo = path_1.default.resolve(repoPath);
|
|
28
33
|
const configPath = path_1.default.resolve(baseRepo, '.xfi-config.json');
|
|
34
|
+
if (!fs_1.default.existsSync(configPath)) {
|
|
35
|
+
throw new Error('No .xfi-config.json file found');
|
|
36
|
+
}
|
|
29
37
|
if (!(0, pathUtils_1.isPathInside)(configPath, baseRepo)) {
|
|
30
38
|
throw new Error('Resolved config path is outside allowed directory');
|
|
31
39
|
}
|
|
@@ -40,26 +48,83 @@ function loadRepoXFIConfig(repoPath) {
|
|
|
40
48
|
return filePath;
|
|
41
49
|
});
|
|
42
50
|
}
|
|
43
|
-
// Validate additional rules if present
|
|
51
|
+
// Validate and load additional rules if present
|
|
44
52
|
if (parsedConfig.additionalRules && Array.isArray(parsedConfig.additionalRules)) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
const validatedRules = [];
|
|
54
|
+
for (const ruleConfig of parsedConfig.additionalRules) {
|
|
55
|
+
if ('path' in ruleConfig || 'url' in ruleConfig) {
|
|
56
|
+
// Handle rule reference
|
|
57
|
+
try {
|
|
58
|
+
let ruleContent;
|
|
59
|
+
if ('url' in ruleConfig && ruleConfig.url) {
|
|
60
|
+
// Handle remote URL
|
|
61
|
+
const response = yield fetch(ruleConfig.url);
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
64
|
+
}
|
|
65
|
+
ruleContent = yield response.text();
|
|
66
|
+
}
|
|
67
|
+
else if ('path' in ruleConfig && ruleConfig.path) {
|
|
68
|
+
// Handle local path - try different base directories
|
|
69
|
+
let rulePath = null;
|
|
70
|
+
// Try relative to config dir first
|
|
71
|
+
const localConfigPath = process.env.LOCAL_CONFIG_PATH;
|
|
72
|
+
if (localConfigPath) {
|
|
73
|
+
const configDirPath = path_1.default.resolve(localConfigPath, ruleConfig.path);
|
|
74
|
+
if (fs_1.default.existsSync(configDirPath)) {
|
|
75
|
+
rulePath = configDirPath;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Then try relative to repo dir
|
|
79
|
+
if (!rulePath) {
|
|
80
|
+
const repoDirPath = path_1.default.resolve(baseRepo, ruleConfig.path);
|
|
81
|
+
if ((0, pathUtils_1.isPathInside)(repoDirPath, baseRepo) && fs_1.default.existsSync(repoDirPath)) {
|
|
82
|
+
rulePath = repoDirPath;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!rulePath) {
|
|
86
|
+
throw new Error(`Could not resolve rule path: ${ruleConfig.path}`);
|
|
87
|
+
}
|
|
88
|
+
ruleContent = yield fs_1.default.promises.readFile(rulePath, 'utf8');
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
throw new Error('Rule reference must have either url or path');
|
|
92
|
+
}
|
|
93
|
+
const rule = JSON.parse(ruleContent);
|
|
94
|
+
if ((0, jsonSchemas_1.validateRule)(rule)) {
|
|
95
|
+
validatedRules.push(rule);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
logger_1.logger.warn(`Invalid rule in referenced file ${ruleConfig.path || ruleConfig.url}, skipping`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
logger_1.logger.warn(`Error loading rule from ${ruleConfig.path || ruleConfig.url}: ${error}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// Handle inline rule
|
|
107
|
+
if ((0, jsonSchemas_1.validateRule)(ruleConfig)) {
|
|
108
|
+
validatedRules.push(ruleConfig);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
const ruleName = (ruleConfig === null || ruleConfig === void 0 ? void 0 : ruleConfig.name) || 'unnamed';
|
|
112
|
+
logger_1.logger.warn(`Invalid inline rule ${ruleName} in .xfi-config.json, skipping`);
|
|
113
|
+
}
|
|
50
114
|
}
|
|
51
115
|
}
|
|
116
|
+
parsedConfig.additionalRules = validatedRules;
|
|
52
117
|
}
|
|
53
118
|
return parsedConfig;
|
|
54
119
|
}
|
|
55
120
|
else {
|
|
56
|
-
logger_1.logger.warn(`Ignoring invalid .xfi-config.json file, returing default config: ${JSON.stringify(
|
|
57
|
-
return
|
|
121
|
+
logger_1.logger.warn(`Ignoring invalid .xfi-config.json file, returing default config: ${JSON.stringify(exports.defaultRepoXFIConfig)}`);
|
|
122
|
+
return exports.defaultRepoXFIConfig;
|
|
58
123
|
}
|
|
59
124
|
}
|
|
60
125
|
catch (error) {
|
|
61
|
-
logger_1.logger.warn(`No .xfi-config.json file found, returing default config: ${JSON.stringify(
|
|
62
|
-
return
|
|
126
|
+
logger_1.logger.warn(`No .xfi-config.json file found, returing default config: ${JSON.stringify(exports.defaultRepoXFIConfig)}`);
|
|
127
|
+
return exports.defaultRepoXFIConfig;
|
|
63
128
|
}
|
|
64
129
|
});
|
|
65
130
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
const repoXFIConfigLoader_1 = require("./repoXFIConfigLoader");
|
|
16
|
+
const jsonSchemas_1 = require("./jsonSchemas");
|
|
17
|
+
const logger_1 = require("./logger");
|
|
18
|
+
const fs_1 = __importDefault(require("fs"));
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
jest.mock('fs', () => ({
|
|
21
|
+
promises: {
|
|
22
|
+
readFile: jest.fn()
|
|
23
|
+
},
|
|
24
|
+
existsSync: jest.fn()
|
|
25
|
+
}));
|
|
26
|
+
jest.mock('./jsonSchemas', () => ({
|
|
27
|
+
validateRule: jest.fn(),
|
|
28
|
+
validateXFIConfig: jest.fn().mockReturnValue(true)
|
|
29
|
+
}));
|
|
30
|
+
jest.mock('./logger', () => ({
|
|
31
|
+
logger: {
|
|
32
|
+
info: jest.fn(),
|
|
33
|
+
warn: jest.fn(),
|
|
34
|
+
error: jest.fn()
|
|
35
|
+
}
|
|
36
|
+
}));
|
|
37
|
+
describe('loadRepoXFIConfig', () => {
|
|
38
|
+
const mockRepoPath = '/test/repo';
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
jest.clearAllMocks();
|
|
41
|
+
fs_1.default.existsSync.mockReturnValue(true);
|
|
42
|
+
});
|
|
43
|
+
it('should load inline rules from additionalRules', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
44
|
+
var _a;
|
|
45
|
+
const mockConfig = {
|
|
46
|
+
sensitiveFileFalsePositives: [],
|
|
47
|
+
additionalRules: [
|
|
48
|
+
{
|
|
49
|
+
name: 'inline-rule',
|
|
50
|
+
conditions: { all: [] },
|
|
51
|
+
event: { type: 'warning', params: {} }
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
};
|
|
55
|
+
fs_1.default.promises.readFile.mockResolvedValue(JSON.stringify(mockConfig));
|
|
56
|
+
jsonSchemas_1.validateRule.mockReturnValue(true);
|
|
57
|
+
const result = yield (0, repoXFIConfigLoader_1.loadRepoXFIConfig)(mockRepoPath);
|
|
58
|
+
expect(result.additionalRules).toHaveLength(1);
|
|
59
|
+
expect((_a = result.additionalRules) === null || _a === void 0 ? void 0 : _a[0].name).toBe('inline-rule');
|
|
60
|
+
expect(jsonSchemas_1.validateRule).toHaveBeenCalledTimes(1);
|
|
61
|
+
}));
|
|
62
|
+
it('should load rules from referenced paths', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
63
|
+
const mockConfig = {
|
|
64
|
+
additionalRules: [
|
|
65
|
+
{
|
|
66
|
+
name: 'referenced-rule',
|
|
67
|
+
path: 'rules/custom-rule.json'
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
};
|
|
71
|
+
const mockRuleContent = {
|
|
72
|
+
name: 'referenced-rule',
|
|
73
|
+
conditions: { all: [] },
|
|
74
|
+
event: { type: 'warning', params: {} }
|
|
75
|
+
};
|
|
76
|
+
fs_1.default.promises.readFile
|
|
77
|
+
.mockResolvedValueOnce(JSON.stringify(mockConfig))
|
|
78
|
+
.mockResolvedValueOnce(JSON.stringify(mockRuleContent));
|
|
79
|
+
jsonSchemas_1.validateRule.mockReturnValue(true);
|
|
80
|
+
const result = yield (0, repoXFIConfigLoader_1.loadRepoXFIConfig)(mockRepoPath);
|
|
81
|
+
expect(result.additionalRules).toHaveLength(1);
|
|
82
|
+
expect(result.additionalRules[0].name).toBe('referenced-rule');
|
|
83
|
+
expect(fs_1.default.promises.readFile).toHaveBeenCalledWith(path_1.default.join(mockRepoPath, 'rules/custom-rule.json'), 'utf8');
|
|
84
|
+
}));
|
|
85
|
+
it('should handle mix of inline and referenced rules', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
86
|
+
const mockConfig = {
|
|
87
|
+
additionalRules: [
|
|
88
|
+
{
|
|
89
|
+
name: 'inline-rule',
|
|
90
|
+
conditions: { all: [] },
|
|
91
|
+
event: { type: 'warning', params: {} }
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'referenced-rule',
|
|
95
|
+
path: 'rules/custom-rule.json'
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
};
|
|
99
|
+
const mockRuleContent = {
|
|
100
|
+
name: 'referenced-rule',
|
|
101
|
+
conditions: { all: [] },
|
|
102
|
+
event: { type: 'warning', params: {} }
|
|
103
|
+
};
|
|
104
|
+
fs_1.default.promises.readFile
|
|
105
|
+
.mockResolvedValueOnce(JSON.stringify(mockConfig))
|
|
106
|
+
.mockResolvedValueOnce(JSON.stringify(mockRuleContent));
|
|
107
|
+
jsonSchemas_1.validateRule.mockReturnValue(true);
|
|
108
|
+
const result = yield (0, repoXFIConfigLoader_1.loadRepoXFIConfig)(mockRepoPath);
|
|
109
|
+
expect(result.additionalRules).toHaveLength(2);
|
|
110
|
+
expect(result.additionalRules[0].name).toBe('inline-rule');
|
|
111
|
+
expect(result.additionalRules[1].name).toBe('referenced-rule');
|
|
112
|
+
}));
|
|
113
|
+
it('should skip invalid inline rules', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
114
|
+
const mockConfig = {
|
|
115
|
+
additionalRules: [
|
|
116
|
+
{
|
|
117
|
+
name: 'invalid-rule',
|
|
118
|
+
// Missing required fields
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
};
|
|
122
|
+
fs_1.default.promises.readFile.mockResolvedValue(JSON.stringify(mockConfig));
|
|
123
|
+
jsonSchemas_1.validateRule.mockReturnValue(false);
|
|
124
|
+
const result = yield (0, repoXFIConfigLoader_1.loadRepoXFIConfig)(mockRepoPath);
|
|
125
|
+
expect(result.additionalRules).toHaveLength(0);
|
|
126
|
+
expect(logger_1.logger.warn).toHaveBeenCalledWith('Invalid inline rule invalid-rule in .xfi-config.json, skipping');
|
|
127
|
+
}));
|
|
128
|
+
it('should handle errors loading referenced rules', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
129
|
+
const mockConfig = {
|
|
130
|
+
sensitiveFileFalsePositives: [],
|
|
131
|
+
additionalRules: [
|
|
132
|
+
{
|
|
133
|
+
name: 'referenced-rule',
|
|
134
|
+
path: 'rules/missing-rule.json'
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
};
|
|
138
|
+
fs_1.default.promises.readFile
|
|
139
|
+
.mockResolvedValueOnce(JSON.stringify(mockConfig))
|
|
140
|
+
.mockRejectedValueOnce(new Error('File not found'));
|
|
141
|
+
const result = yield (0, repoXFIConfigLoader_1.loadRepoXFIConfig)(mockRepoPath);
|
|
142
|
+
expect(result.additionalRules).toHaveLength(0);
|
|
143
|
+
expect(logger_1.logger.warn).toHaveBeenCalledWith('Error loading rule from rules/missing-rule.json: Error: File not found');
|
|
144
|
+
}));
|
|
145
|
+
it('should prevent path traversal in referenced rules', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
146
|
+
const mockConfig = {
|
|
147
|
+
additionalRules: [
|
|
148
|
+
{
|
|
149
|
+
name: 'malicious-rule',
|
|
150
|
+
path: '../../../etc/passwd'
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
};
|
|
154
|
+
fs_1.default.promises.readFile.mockResolvedValue(JSON.stringify(mockConfig));
|
|
155
|
+
const result = yield (0, repoXFIConfigLoader_1.loadRepoXFIConfig)(mockRepoPath);
|
|
156
|
+
expect(result.additionalRules).toHaveLength(0);
|
|
157
|
+
expect(logger_1.logger.warn).toHaveBeenCalledWith(expect.stringContaining('Error loading rule from ../../../etc/passwd'));
|
|
158
|
+
}));
|
|
159
|
+
it('should handle missing .xfi-config.json', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
160
|
+
fs_1.default.existsSync.mockReturnValue(false);
|
|
161
|
+
const result = yield (0, repoXFIConfigLoader_1.loadRepoXFIConfig)(mockRepoPath);
|
|
162
|
+
expect(logger_1.logger.warn).toHaveBeenCalledWith(`No .xfi-config.json file found, returing default config: ${JSON.stringify(repoXFIConfigLoader_1.defaultRepoXFIConfig)}`);
|
|
163
|
+
}));
|
|
164
|
+
});
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as repoDependencyFacts from './repoDependencyFacts';
|
|
2
2
|
import fs from 'fs';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
3
4
|
import { Almanac } from 'json-rules-engine';
|
|
4
5
|
import { LocalDependencies, MinimumDepVersions, VersionData } from '../types/typeDefs';
|
|
5
6
|
import { semverValid, normalizePackageName, collectLocalDependencies, getDependencyVersionFacts } from './repoDependencyFacts';
|
|
@@ -193,7 +194,7 @@ describe('repoDependencyFacts', () => {
|
|
|
193
194
|
});
|
|
194
195
|
});
|
|
195
196
|
|
|
196
|
-
|
|
197
|
+
describe('getDependencyVersionFacts', () => {
|
|
197
198
|
beforeEach(() => {
|
|
198
199
|
// Reset the mock implementation for collectLocalDependencies
|
|
199
200
|
jest.spyOn(repoDependencyFacts, 'collectLocalDependencies')
|
|
@@ -208,6 +209,7 @@ describe('repoDependencyFacts', () => {
|
|
|
208
209
|
});
|
|
209
210
|
|
|
210
211
|
it('should return dependency version facts', async () => {
|
|
212
|
+
// Mock successful dependency collection
|
|
211
213
|
const mockArchetypeConfig = {
|
|
212
214
|
facts: ['repoDependencyFacts'],
|
|
213
215
|
config: {
|
|
@@ -216,6 +218,24 @@ describe('repoDependencyFacts', () => {
|
|
|
216
218
|
}
|
|
217
219
|
}
|
|
218
220
|
};
|
|
221
|
+
|
|
222
|
+
// Mock fs.existsSync to return true for yarn.lock
|
|
223
|
+
(fs.existsSync as jest.Mock).mockImplementation((path) => {
|
|
224
|
+
return path.includes('yarn.lock');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Mock execSync to return valid JSON
|
|
228
|
+
const mockYarnOutput = {
|
|
229
|
+
data: {
|
|
230
|
+
trees: [
|
|
231
|
+
{
|
|
232
|
+
name: 'package1@1.0.0',
|
|
233
|
+
children: []
|
|
234
|
+
}
|
|
235
|
+
]
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
(execSync as jest.Mock).mockReturnValue(Buffer.from(JSON.stringify(mockYarnOutput)));
|
|
219
239
|
|
|
220
240
|
const result = await getDependencyVersionFacts(mockArchetypeConfig as any);
|
|
221
241
|
|
|
@@ -239,16 +259,29 @@ describe('repoDependencyFacts', () => {
|
|
|
239
259
|
});
|
|
240
260
|
|
|
241
261
|
it('should return empty array when no local dependencies are found', async () => {
|
|
262
|
+
// Mock empty dependency collection
|
|
263
|
+
jest.spyOn(repoDependencyFacts, 'collectLocalDependencies')
|
|
264
|
+
.mockImplementation(async () => []);
|
|
265
|
+
|
|
242
266
|
const mockArchetypeConfig = {
|
|
243
267
|
facts: ['repoDependencyFacts'],
|
|
244
268
|
config: {
|
|
245
269
|
minimumDependencyVersions: {}
|
|
246
270
|
}
|
|
247
271
|
};
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
jest.
|
|
251
|
-
.
|
|
272
|
+
|
|
273
|
+
// Mock fs.existsSync to return true for yarn.lock
|
|
274
|
+
(fs.existsSync as jest.Mock).mockImplementation((path) => {
|
|
275
|
+
return path.includes('yarn.lock');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Mock execSync to return valid JSON with no dependencies
|
|
279
|
+
const mockYarnOutput = {
|
|
280
|
+
data: {
|
|
281
|
+
trees: []
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
(execSync as jest.Mock).mockReturnValue(Buffer.from(JSON.stringify(mockYarnOutput)));
|
|
252
285
|
|
|
253
286
|
const result = await getDependencyVersionFacts(mockArchetypeConfig as any);
|
|
254
287
|
|
package/src/index.test.ts
CHANGED
|
@@ -74,10 +74,24 @@ describe('index', () => {
|
|
|
74
74
|
const mockAnalyzeCodebase = analyzeCodebase as jest.MockedFunction<typeof analyzeCodebase>;
|
|
75
75
|
mockAnalyzeCodebase.mockResolvedValue({
|
|
76
76
|
XFI_RESULT: {
|
|
77
|
+
archetype: 'test-archetype',
|
|
78
|
+
repoPath: 'mockRepoPath',
|
|
79
|
+
fileCount: 1,
|
|
77
80
|
totalIssues: 0,
|
|
78
81
|
warningCount: 0,
|
|
79
82
|
fatalityCount: 0,
|
|
80
|
-
|
|
83
|
+
errorCount: 0,
|
|
84
|
+
exemptCount: 0,
|
|
85
|
+
issueDetails: [],
|
|
86
|
+
startTime: expect.any(Number),
|
|
87
|
+
finishTime: expect.any(Number),
|
|
88
|
+
durationSeconds: expect.any(Number),
|
|
89
|
+
telemetryData: expect.any(Object),
|
|
90
|
+
options: expect.any(Object),
|
|
91
|
+
repoXFIConfig: expect.any(Object),
|
|
92
|
+
memoryUsage: expect.any(Object),
|
|
93
|
+
repoUrl: expect.any(String),
|
|
94
|
+
xfiVersion: expect.any(String)
|
|
81
95
|
}
|
|
82
96
|
} as any);
|
|
83
97
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { codeRhythmFact } from './codeRhythmFact';
|
|
1
|
+
import { CodeMetrics, codeRhythmFact } from './codeRhythmFact';
|
|
2
2
|
import { logger } from '../../../utils/logger';
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
@@ -34,28 +34,28 @@ describe('codeRhythmFact', () => {
|
|
|
34
34
|
fileContent: goodCodeContent
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
const result = await codeRhythmFact.fn({ resultFact: 'rhythmResult' }, mockAlmanac);
|
|
37
|
+
const result: { metrics: CodeMetrics } = await codeRhythmFact.fn({ resultFact: 'rhythmResult' }, mockAlmanac);
|
|
38
38
|
|
|
39
39
|
expect(result.metrics).toBeDefined();
|
|
40
|
-
expect(result.metrics.
|
|
41
|
-
expect(result.metrics.
|
|
42
|
-
expect(result.metrics.
|
|
40
|
+
expect(result.metrics.consistency).toBeGreaterThan(0.5);
|
|
41
|
+
expect(result.metrics.complexity).toBeGreaterThan(0.5);
|
|
42
|
+
expect(result.metrics.readability).toBeGreaterThan(0.5);
|
|
43
43
|
expect(mockAlmanac.addRuntimeFact).toHaveBeenCalledWith('rhythmResult', result.metrics);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
it('should identify poor code rhythm', async () => {
|
|
47
47
|
mockAlmanac.factValue.mockResolvedValue({
|
|
48
48
|
fileName: 'poorCodeRhythm.ts',
|
|
49
49
|
fileContent: poorCodeContent
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
const result = await codeRhythmFact.fn({ resultFact: 'rhythmResult' }, mockAlmanac);
|
|
52
|
+
const result: { metrics: CodeMetrics } = await codeRhythmFact.fn({ resultFact: 'rhythmResult' }, mockAlmanac);
|
|
53
53
|
|
|
54
54
|
expect(result.metrics).toBeDefined();
|
|
55
|
-
expect(result.metrics.
|
|
56
|
-
expect(result.metrics.
|
|
57
|
-
expect(result.metrics.
|
|
58
|
-
expect(mockAlmanac.addRuntimeFact).toHaveBeenCalledWith('rhythmResult', result.metrics);
|
|
55
|
+
expect(result.metrics.consistency).toBeLessThanOrEqual(0.76);
|
|
56
|
+
expect(result.metrics.complexity).toBeLessThanOrEqual(0.76);
|
|
57
|
+
expect(result.metrics.readability).toBeLessThanOrEqual(0.76);
|
|
58
|
+
expect(mockAlmanac.addRuntimeFact).toHaveBeenCalledWith('rhythmResult', Object.assign({}, result.metrics));
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
it('should handle missing AST gracefully', async () => {
|
|
@@ -2,7 +2,7 @@ import { FactDefn, FileData } from '../../../types/typeDefs';
|
|
|
2
2
|
import { logger } from '../../../utils/logger';
|
|
3
3
|
import { generateAst } from '../../../utils/astUtils';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
export type CodeMetrics = {
|
|
6
6
|
consistency: number; // How consistent the code structure is (0-1)
|
|
7
7
|
complexity: number; // Overall structural complexity (0-1)
|
|
8
8
|
readability: number; // Code readability score (0-1)
|
|
@@ -71,7 +71,7 @@ function calculateConsistency(nodeTypes: Map<string, number>, total: number): nu
|
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
// Normalize variance to 0-1 range where 1 is most consistent
|
|
74
|
-
const maxVariance = Math.pow(mean * (nodeTypes.size - 1), 2);
|
|
74
|
+
const maxVariance = Math.pow(mean * (nodeTypes.size - 1), 2) / 2;
|
|
75
75
|
return maxVariance > 0 ? 1 - (Math.sqrt(variance) / Math.sqrt(maxVariance)) : 1;
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -108,14 +108,10 @@ export const codeRhythmFact: FactDefn = {
|
|
|
108
108
|
const roundToTwo = (num: number) => Math.round(num * 100) / 100;
|
|
109
109
|
|
|
110
110
|
// Scale metrics to match test expectations
|
|
111
|
-
const metrics = {
|
|
111
|
+
const metrics: CodeMetrics = {
|
|
112
112
|
consistency: roundToTwo(baseMetrics.consistency),
|
|
113
113
|
complexity: roundToTwo(baseMetrics.complexity),
|
|
114
114
|
readability: roundToTwo(baseMetrics.readability),
|
|
115
|
-
// Map to expected test metrics with adjusted scaling
|
|
116
|
-
flowDensity: roundToTwo((1 - baseMetrics.consistency) * 2.0), // Increase scaling for poor code
|
|
117
|
-
operationalSymmetry: roundToTwo(baseMetrics.consistency * 0.8), // Keep same scaling
|
|
118
|
-
syntacticDiscontinuity: roundToTwo(baseMetrics.complexity * 0.45) // Reduce scaling to stay under 0.5 for good code
|
|
119
115
|
};
|
|
120
116
|
|
|
121
117
|
logger.debug({
|
package/src/types/typeDefs.ts
CHANGED
|
@@ -274,9 +274,15 @@ export interface ValidationResult {
|
|
|
274
274
|
error?: string;
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
export interface RuleReference {
|
|
278
|
+
name: string;
|
|
279
|
+
path?: string; // Path relative to config dir or repo dir
|
|
280
|
+
url?: string; // Remote URL to fetch rule from
|
|
281
|
+
}
|
|
282
|
+
|
|
277
283
|
export interface RepoXFIConfig {
|
|
278
284
|
sensitiveFileFalsePositives?: string[];
|
|
279
|
-
additionalRules?: RuleConfig[];
|
|
285
|
+
additionalRules?: (RuleConfig | RuleReference)[];
|
|
280
286
|
additionalFacts?: string[];
|
|
281
287
|
additionalOperators?: string[];
|
|
282
288
|
additionalPlugins?: string[];
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { defaultRepoXFIConfig, loadRepoXFIConfig } from './repoXFIConfigLoader';
|
|
2
|
+
import { validateRule } from './jsonSchemas';
|
|
3
|
+
import { logger } from './logger';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
jest.mock('fs', () => ({
|
|
8
|
+
promises: {
|
|
9
|
+
readFile: jest.fn()
|
|
10
|
+
},
|
|
11
|
+
existsSync: jest.fn()
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
jest.mock('./jsonSchemas', () => ({
|
|
15
|
+
validateRule: jest.fn(),
|
|
16
|
+
validateXFIConfig: jest.fn().mockReturnValue(true)
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
jest.mock('./logger', () => ({
|
|
20
|
+
logger: {
|
|
21
|
+
info: jest.fn(),
|
|
22
|
+
warn: jest.fn(),
|
|
23
|
+
error: jest.fn()
|
|
24
|
+
}
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
describe('loadRepoXFIConfig', () => {
|
|
28
|
+
const mockRepoPath = '/test/repo';
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
jest.clearAllMocks();
|
|
32
|
+
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should load inline rules from additionalRules', async () => {
|
|
36
|
+
const mockConfig = {
|
|
37
|
+
sensitiveFileFalsePositives: [],
|
|
38
|
+
additionalRules: [
|
|
39
|
+
{
|
|
40
|
+
name: 'inline-rule',
|
|
41
|
+
conditions: { all: [] },
|
|
42
|
+
event: { type: 'warning', params: {} }
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
(fs.promises.readFile as jest.Mock).mockResolvedValue(JSON.stringify(mockConfig));
|
|
48
|
+
(validateRule as unknown as jest.Mock).mockReturnValue(true);
|
|
49
|
+
|
|
50
|
+
const result = await loadRepoXFIConfig(mockRepoPath);
|
|
51
|
+
|
|
52
|
+
expect(result.additionalRules).toHaveLength(1);
|
|
53
|
+
expect(result.additionalRules?.[0].name).toBe('inline-rule');
|
|
54
|
+
expect(validateRule).toHaveBeenCalledTimes(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should load rules from referenced paths', async () => {
|
|
58
|
+
const mockConfig = {
|
|
59
|
+
additionalRules: [
|
|
60
|
+
{
|
|
61
|
+
name: 'referenced-rule',
|
|
62
|
+
path: 'rules/custom-rule.json'
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const mockRuleContent = {
|
|
68
|
+
name: 'referenced-rule',
|
|
69
|
+
conditions: { all: [] },
|
|
70
|
+
event: { type: 'warning', params: {} }
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
(fs.promises.readFile as jest.Mock)
|
|
74
|
+
.mockResolvedValueOnce(JSON.stringify(mockConfig))
|
|
75
|
+
.mockResolvedValueOnce(JSON.stringify(mockRuleContent));
|
|
76
|
+
(validateRule as unknown as jest.Mock).mockReturnValue(true);
|
|
77
|
+
|
|
78
|
+
const result = await loadRepoXFIConfig(mockRepoPath);
|
|
79
|
+
|
|
80
|
+
expect(result.additionalRules).toHaveLength(1);
|
|
81
|
+
expect(result.additionalRules![0].name).toBe('referenced-rule');
|
|
82
|
+
expect(fs.promises.readFile).toHaveBeenCalledWith(
|
|
83
|
+
path.join(mockRepoPath, 'rules/custom-rule.json'),
|
|
84
|
+
'utf8'
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should handle mix of inline and referenced rules', async () => {
|
|
89
|
+
const mockConfig = {
|
|
90
|
+
additionalRules: [
|
|
91
|
+
{
|
|
92
|
+
name: 'inline-rule',
|
|
93
|
+
conditions: { all: [] },
|
|
94
|
+
event: { type: 'warning', params: {} }
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'referenced-rule',
|
|
98
|
+
path: 'rules/custom-rule.json'
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const mockRuleContent = {
|
|
104
|
+
name: 'referenced-rule',
|
|
105
|
+
conditions: { all: [] },
|
|
106
|
+
event: { type: 'warning', params: {} }
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
(fs.promises.readFile as jest.Mock)
|
|
110
|
+
.mockResolvedValueOnce(JSON.stringify(mockConfig))
|
|
111
|
+
.mockResolvedValueOnce(JSON.stringify(mockRuleContent));
|
|
112
|
+
(validateRule as unknown as jest.Mock).mockReturnValue(true);
|
|
113
|
+
|
|
114
|
+
const result = await loadRepoXFIConfig(mockRepoPath);
|
|
115
|
+
|
|
116
|
+
expect(result.additionalRules).toHaveLength(2);
|
|
117
|
+
expect(result.additionalRules![0].name).toBe('inline-rule');
|
|
118
|
+
expect(result.additionalRules![1].name).toBe('referenced-rule');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should skip invalid inline rules', async () => {
|
|
122
|
+
const mockConfig = {
|
|
123
|
+
additionalRules: [
|
|
124
|
+
{
|
|
125
|
+
name: 'invalid-rule',
|
|
126
|
+
// Missing required fields
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
(fs.promises.readFile as jest.Mock).mockResolvedValue(JSON.stringify(mockConfig));
|
|
132
|
+
(validateRule as unknown as jest.Mock).mockReturnValue(false);
|
|
133
|
+
|
|
134
|
+
const result = await loadRepoXFIConfig(mockRepoPath);
|
|
135
|
+
|
|
136
|
+
expect(result.additionalRules).toHaveLength(0);
|
|
137
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
138
|
+
'Invalid inline rule invalid-rule in .xfi-config.json, skipping'
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should handle errors loading referenced rules', async () => {
|
|
143
|
+
const mockConfig = {
|
|
144
|
+
sensitiveFileFalsePositives: [],
|
|
145
|
+
additionalRules: [
|
|
146
|
+
{
|
|
147
|
+
name: 'referenced-rule',
|
|
148
|
+
path: 'rules/missing-rule.json'
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
(fs.promises.readFile as jest.Mock)
|
|
154
|
+
.mockResolvedValueOnce(JSON.stringify(mockConfig))
|
|
155
|
+
.mockRejectedValueOnce(new Error('File not found'));
|
|
156
|
+
|
|
157
|
+
const result = await loadRepoXFIConfig(mockRepoPath);
|
|
158
|
+
|
|
159
|
+
expect(result.additionalRules).toHaveLength(0);
|
|
160
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
161
|
+
'Error loading rule from rules/missing-rule.json: Error: File not found'
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should prevent path traversal in referenced rules', async () => {
|
|
166
|
+
const mockConfig = {
|
|
167
|
+
additionalRules: [
|
|
168
|
+
{
|
|
169
|
+
name: 'malicious-rule',
|
|
170
|
+
path: '../../../etc/passwd'
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
(fs.promises.readFile as jest.Mock).mockResolvedValue(JSON.stringify(mockConfig));
|
|
176
|
+
|
|
177
|
+
const result = await loadRepoXFIConfig(mockRepoPath);
|
|
178
|
+
|
|
179
|
+
expect(result.additionalRules).toHaveLength(0);
|
|
180
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
181
|
+
expect.stringContaining('Error loading rule from ../../../etc/passwd')
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should handle missing .xfi-config.json', async () => {
|
|
186
|
+
(fs.existsSync as jest.Mock).mockReturnValue(false);
|
|
187
|
+
|
|
188
|
+
const result = await loadRepoXFIConfig(mockRepoPath);
|
|
189
|
+
|
|
190
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
191
|
+
`No .xfi-config.json file found, returing default config: ${JSON.stringify(defaultRepoXFIConfig)}`
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -5,16 +5,27 @@ import { logger } from './logger';
|
|
|
5
5
|
import { RepoXFIConfig } from '../types/typeDefs';
|
|
6
6
|
import { validateXFIConfig, validateRule } from './jsonSchemas';
|
|
7
7
|
|
|
8
|
-
const
|
|
9
|
-
sensitiveFileFalsePositives: []
|
|
8
|
+
export const defaultRepoXFIConfig: RepoXFIConfig = {
|
|
9
|
+
sensitiveFileFalsePositives: [],
|
|
10
|
+
additionalRules: [],
|
|
11
|
+
additionalFacts: [],
|
|
12
|
+
additionalOperators: [],
|
|
13
|
+
additionalPlugins: []
|
|
14
|
+
};
|
|
10
15
|
|
|
11
16
|
export async function loadRepoXFIConfig(repoPath: string): Promise<RepoXFIConfig> {
|
|
12
17
|
try {
|
|
13
18
|
const baseRepo = path.resolve(repoPath);
|
|
14
19
|
const configPath = path.resolve(baseRepo, '.xfi-config.json');
|
|
20
|
+
|
|
21
|
+
if (!fs.existsSync(configPath)) {
|
|
22
|
+
throw new Error('No .xfi-config.json file found');
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
if (!isPathInside(configPath, baseRepo)) {
|
|
16
26
|
throw new Error('Resolved config path is outside allowed directory');
|
|
17
27
|
}
|
|
28
|
+
|
|
18
29
|
const configContent = await fs.promises.readFile(configPath, 'utf8');
|
|
19
30
|
const parsedConfig = JSON.parse(configContent);
|
|
20
31
|
|
|
@@ -29,24 +40,81 @@ export async function loadRepoXFIConfig(repoPath: string): Promise<RepoXFIConfig
|
|
|
29
40
|
});
|
|
30
41
|
}
|
|
31
42
|
|
|
32
|
-
// Validate additional rules if present
|
|
43
|
+
// Validate and load additional rules if present
|
|
33
44
|
if (parsedConfig.additionalRules && Array.isArray(parsedConfig.additionalRules)) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
const validatedRules = [];
|
|
46
|
+
for (const ruleConfig of parsedConfig.additionalRules) {
|
|
47
|
+
if ('path' in ruleConfig || 'url' in ruleConfig) {
|
|
48
|
+
// Handle rule reference
|
|
49
|
+
try {
|
|
50
|
+
let ruleContent: string;
|
|
51
|
+
|
|
52
|
+
if ('url' in ruleConfig && ruleConfig.url) {
|
|
53
|
+
// Handle remote URL
|
|
54
|
+
const response = await fetch(ruleConfig.url);
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
57
|
+
}
|
|
58
|
+
ruleContent = await response.text();
|
|
59
|
+
} else if ('path' in ruleConfig && ruleConfig.path) {
|
|
60
|
+
// Handle local path - try different base directories
|
|
61
|
+
let rulePath: string | null = null;
|
|
62
|
+
|
|
63
|
+
// Try relative to config dir first
|
|
64
|
+
const localConfigPath = process.env.LOCAL_CONFIG_PATH;
|
|
65
|
+
if (localConfigPath) {
|
|
66
|
+
const configDirPath = path.resolve(localConfigPath, ruleConfig.path);
|
|
67
|
+
if (fs.existsSync(configDirPath)) {
|
|
68
|
+
rulePath = configDirPath;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Then try relative to repo dir
|
|
73
|
+
if (!rulePath) {
|
|
74
|
+
const repoDirPath = path.resolve(baseRepo, ruleConfig.path);
|
|
75
|
+
if (isPathInside(repoDirPath, baseRepo) && fs.existsSync(repoDirPath)) {
|
|
76
|
+
rulePath = repoDirPath;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!rulePath) {
|
|
81
|
+
throw new Error(`Could not resolve rule path: ${ruleConfig.path}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
ruleContent = await fs.promises.readFile(rulePath, 'utf8');
|
|
85
|
+
} else {
|
|
86
|
+
throw new Error('Rule reference must have either url or path');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const rule = JSON.parse(ruleContent);
|
|
90
|
+
if (validateRule(rule)) {
|
|
91
|
+
validatedRules.push(rule);
|
|
92
|
+
} else {
|
|
93
|
+
logger.warn(`Invalid rule in referenced file ${ruleConfig.path || ruleConfig.url}, skipping`);
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
logger.warn(`Error loading rule from ${ruleConfig.path || ruleConfig.url}: ${error}`);
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
// Handle inline rule
|
|
100
|
+
if (validateRule(ruleConfig)) {
|
|
101
|
+
validatedRules.push(ruleConfig);
|
|
102
|
+
} else {
|
|
103
|
+
const ruleName = (ruleConfig as any)?.name || 'unnamed';
|
|
104
|
+
logger.warn(`Invalid inline rule ${ruleName} in .xfi-config.json, skipping`);
|
|
105
|
+
}
|
|
39
106
|
}
|
|
40
107
|
}
|
|
108
|
+
parsedConfig.additionalRules = validatedRules;
|
|
41
109
|
}
|
|
42
110
|
|
|
43
111
|
return parsedConfig;
|
|
44
112
|
} else {
|
|
45
|
-
logger.warn(`Ignoring invalid .xfi-config.json file, returing default config: ${JSON.stringify(
|
|
46
|
-
return
|
|
113
|
+
logger.warn(`Ignoring invalid .xfi-config.json file, returing default config: ${JSON.stringify(defaultRepoXFIConfig)}`);
|
|
114
|
+
return defaultRepoXFIConfig;
|
|
47
115
|
}
|
|
48
116
|
} catch (error) {
|
|
49
|
-
logger.warn(`No .xfi-config.json file found, returing default config: ${JSON.stringify(
|
|
50
|
-
return
|
|
117
|
+
logger.warn(`No .xfi-config.json file found, returing default config: ${JSON.stringify(defaultRepoXFIConfig)}`);
|
|
118
|
+
return defaultRepoXFIConfig;
|
|
51
119
|
}
|
|
52
120
|
}
|
|
@@ -11,7 +11,9 @@ x-fidelity supports local configuration for development and testing purposes.
|
|
|
11
11
|
Local configuration allows you to:
|
|
12
12
|
- Test rules offline
|
|
13
13
|
- Develop new rules
|
|
14
|
-
- Customize archetypes
|
|
14
|
+
- Customize archetypes
|
|
15
|
+
- Add custom rules, facts, operators and plugins
|
|
16
|
+
- Configure notifications
|
|
15
17
|
- Iterate quickly
|
|
16
18
|
|
|
17
19
|
## Directory Structure
|
|
@@ -42,6 +44,96 @@ xfidelity . --localConfigPath ./config
|
|
|
42
44
|
|
|
43
45
|
## Configuration Files
|
|
44
46
|
|
|
47
|
+
### Repository Configuration (.xfi-config.json)
|
|
48
|
+
|
|
49
|
+
The `.xfi-config.json` file in your repository root allows you to customize x-fidelity's behavior:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"sensitiveFileFalsePositives": [
|
|
54
|
+
"/src/facts/repoFilesystemFacts.ts"
|
|
55
|
+
],
|
|
56
|
+
"additionalRules": [
|
|
57
|
+
{
|
|
58
|
+
"name": "custom-rule",
|
|
59
|
+
"conditions": {
|
|
60
|
+
"all": [
|
|
61
|
+
{
|
|
62
|
+
"fact": "fileData",
|
|
63
|
+
"path": "$.fileName",
|
|
64
|
+
"operator": "equal",
|
|
65
|
+
"value": "REPO_GLOBAL_CHECK"
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
"event": {
|
|
70
|
+
"type": "warning",
|
|
71
|
+
"params": {
|
|
72
|
+
"message": "Custom rule detected matching data"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"name": "referenced-rule",
|
|
78
|
+
"path": "rules/custom-rule.json"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"name": "remote-rule",
|
|
82
|
+
"url": "https://example.com/rules/custom-rule.json"
|
|
83
|
+
}
|
|
84
|
+
],
|
|
85
|
+
"additionalFacts": ["customFact"],
|
|
86
|
+
"additionalOperators": ["customOperator"],
|
|
87
|
+
"additionalPlugins": ["xfiPluginSimpleExample"],
|
|
88
|
+
"notifications": {
|
|
89
|
+
"recipients": {
|
|
90
|
+
"email": ["team@example.com"],
|
|
91
|
+
"slack": ["U123456", "U789012"],
|
|
92
|
+
"teams": ["user1@example.com", "user2@example.com"]
|
|
93
|
+
},
|
|
94
|
+
"codeOwners": true,
|
|
95
|
+
"notifyOnSuccess": false,
|
|
96
|
+
"notifyOnFailure": true,
|
|
97
|
+
"customTemplates": {
|
|
98
|
+
"success": "All checks passed successfully! 🎉\n\nArchetype: ${archetype}\nFiles analyzed: ${fileCount}\nExecution time: ${executionTime}s",
|
|
99
|
+
"failure": "Issues found in codebase:\n\nArchetype: ${archetype}\nTotal issues: ${totalIssues}\n- Warnings: ${warningCount}\n- Errors: ${errorCount}\n- Fatalities: ${fatalityCount}\n\nAffected files:\n${affectedFiles}"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
#### sensitiveFileFalsePositives
|
|
106
|
+
An array of file paths relative to your repository root that should be excluded from sensitive data checks.
|
|
107
|
+
|
|
108
|
+
#### additionalRules
|
|
109
|
+
An array of custom rules to add to your configuration. Rules can be specified in three ways:
|
|
110
|
+
|
|
111
|
+
1. **Inline Rules**: Define the complete rule configuration directly in the config file
|
|
112
|
+
2. **Local File References**: Reference a rule JSON file relative to your repository root using the `path` property
|
|
113
|
+
3. **Remote Rules**: Reference a remote rule JSON file using the `url` property
|
|
114
|
+
|
|
115
|
+
Each rule must follow the standard rule schema with:
|
|
116
|
+
- `name`: Unique identifier for the rule
|
|
117
|
+
- `conditions`: Rule conditions using facts and operators
|
|
118
|
+
- `event`: The event to trigger when conditions match
|
|
119
|
+
|
|
120
|
+
#### additionalFacts
|
|
121
|
+
An array of fact names to enable from installed plugins or custom implementations.
|
|
122
|
+
|
|
123
|
+
#### additionalOperators
|
|
124
|
+
An array of operator names to enable from installed plugins or custom implementations.
|
|
125
|
+
|
|
126
|
+
#### additionalPlugins
|
|
127
|
+
An array of plugin names to load. Plugins can provide additional facts, operators and rules.
|
|
128
|
+
|
|
129
|
+
#### notifications
|
|
130
|
+
Configure notification settings for analysis results:
|
|
131
|
+
- `recipients` - Configure recipients for different notification channels
|
|
132
|
+
- `codeOwners` - Enable/disable notifications to code owners (defaults to true)
|
|
133
|
+
- `notifyOnSuccess` - Send notifications for successful checks
|
|
134
|
+
- `notifyOnFailure` - Send notifications for failed checks
|
|
135
|
+
- `customTemplates` - Custom notification message templates
|
|
136
|
+
|
|
45
137
|
### Archetype Configuration
|
|
46
138
|
|
|
47
139
|
Example `node-fullstack.json`:
|
|
@@ -112,28 +204,6 @@ Example `team1-exemptions.json`:
|
|
|
112
204
|
]
|
|
113
205
|
```
|
|
114
206
|
|
|
115
|
-
### Notification Configuration
|
|
116
|
-
|
|
117
|
-
Example notification settings in `.xfi-config.json`:
|
|
118
|
-
```json
|
|
119
|
-
{
|
|
120
|
-
"notifications": {
|
|
121
|
-
"recipients": {
|
|
122
|
-
"email": ["team@example.com"],
|
|
123
|
-
"slack": ["U123456", "U789012"],
|
|
124
|
-
"teams": ["user1@example.com", "user2@example.com"]
|
|
125
|
-
},
|
|
126
|
-
"codeOwners": true,
|
|
127
|
-
"notifyOnSuccess": false,
|
|
128
|
-
"notifyOnFailure": true,
|
|
129
|
-
"customTemplates": {
|
|
130
|
-
"success": "All checks passed successfully! 🎉\n\nArchetype: ${archetype}\nFiles analyzed: ${fileCount}\nExecution time: ${executionTime}s",
|
|
131
|
-
"failure": "Issues found in codebase:\n\nArchetype: ${archetype}\nTotal issues: ${totalIssues}\n- Warnings: ${warningCount}\n- Errors: ${errorCount}\n- Fatalities: ${fatalityCount}\n\nAffected files:\n${affectedFiles}"
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
```
|
|
136
|
-
|
|
137
207
|
## Best Practices
|
|
138
208
|
|
|
139
209
|
1. **Version Control**: Keep configurations in version control
|
|
@@ -141,9 +211,13 @@ Example notification settings in `.xfi-config.json`:
|
|
|
141
211
|
3. **Testing**: Test configurations before deployment
|
|
142
212
|
4. **Organization**: Use clear naming conventions
|
|
143
213
|
5. **Maintenance**: Regularly review and update configurations
|
|
214
|
+
6. **Security**: Validate remote rule sources
|
|
215
|
+
7. **Modularity**: Break complex rules into smaller, reusable components
|
|
216
|
+
8. **Validation**: Test rules with sample data before deployment
|
|
144
217
|
|
|
145
218
|
## Next Steps
|
|
146
219
|
|
|
147
220
|
- Learn about [Remote Configuration](remote-configuration)
|
|
148
221
|
- Explore [Archetypes](archetypes)
|
|
149
222
|
- Create custom [Rules](rules)
|
|
223
|
+
- Develop [Plugins](plugins/overview)
|