@boyingliu01/xp-gate 0.8.8 → 0.8.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/bin/xp-gate.js +18 -0
- package/hooks/pre-commit +565 -120
- package/lib/arch.js +49 -0
- package/lib/check.js +50 -0
- package/lib/init.js +36 -0
- package/lib/principles.js +48 -0
- package/mock-policy/AGENTS.md +94 -0
- package/mock-policy/__tests__/config.test.ts +292 -0
- package/mock-policy/__tests__/integration.test.ts +600 -0
- package/mock-policy/__tests__/mock-decision-engine.test.ts +231 -0
- package/mock-policy/__tests__/scope-scanner.test.ts +535 -0
- package/mock-policy/config.ts +106 -0
- package/mock-policy/gate-m3.ts +233 -0
- package/mock-policy/mock-decision-engine.ts +155 -0
- package/mock-policy/schema.ts +22 -0
- package/mock-policy/scope-scanner.ts +288 -0
- package/mock-policy/types.ts +65 -0
- package/mutation/AGENTS.md +73 -0
- package/mutation/__tests__/detect-ai-test.test.ts +152 -0
- package/mutation/__tests__/gate-m.test.ts +194 -0
- package/mutation/detect-ai-test.ts +128 -0
- package/mutation/gate-m.ts +619 -0
- package/mutation/init-baseline.ts +192 -0
- package/mutation/stryker-types.ts +14 -0
- package/mutation/types.ts +70 -0
- package/mutation/update-baseline.ts +160 -0
- package/package.json +5 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +2 -2
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +7 -7
- package/plugins/claude-code/skills/delphi-review/INSTALL.md +1 -1
- package/plugins/claude-code/skills/delphi-review/SKILL.md +12 -12
- package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +83 -36
- package/plugins/claude-code/skills/sprint-flow/SKILL.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/force-levels.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/phase-3-review.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/README.md +23 -7
- package/plugins/opencode/index.ts +36 -21
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +7 -7
- package/plugins/opencode/skills/delphi-review/INSTALL.md +1 -1
- package/plugins/opencode/skills/delphi-review/SKILL.md +12 -12
- package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +1 -1
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +83 -36
- package/plugins/opencode/skills/sprint-flow/SKILL.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/force-levels.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/phase-3-review.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +20 -0
- package/plugins/qoder/skills/delphi-review/AGENTS.md +7 -7
- package/plugins/qoder/skills/delphi-review/INSTALL.md +1 -1
- package/plugins/qoder/skills/delphi-review/SKILL.md +12 -12
- package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +1 -1
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +83 -36
- package/plugins/qoder/skills/sprint-flow/SKILL.md +51 -1
- package/plugins/qoder/skills/sprint-flow/references/force-levels.md +1 -1
- package/plugins/qoder/skills/sprint-flow/references/phase-3-review.md +2 -2
- package/plugins/qoder/skills/test-driven-development/SKILL.md +371 -0
- package/plugins/qoder/skills/test-driven-development/testing-anti-patterns.md +299 -0
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +111 -0
- package/principles/__tests__/analyzer.test.ts +343 -0
- package/principles/__tests__/baseline-storage.test.ts +187 -0
- package/principles/__tests__/baseline.test.ts +328 -0
- package/principles/__tests__/boy-scout-integration.test.ts +264 -0
- package/principles/__tests__/boy-scout.test.ts +646 -0
- package/principles/__tests__/config.test.ts +78 -0
- package/principles/__tests__/index.test.ts +81 -0
- package/principles/__tests__/lint-baseline.test.ts +314 -0
- package/principles/__tests__/reporter.test.ts +832 -0
- package/principles/__tests__/types.test.ts +101 -0
- package/principles/adapters/__tests__/base.test.ts +95 -0
- package/principles/adapters/__tests__/cpp.test.ts +290 -0
- package/principles/adapters/__tests__/dart.test.ts +84 -0
- package/principles/adapters/__tests__/go.test.ts +68 -0
- package/principles/adapters/__tests__/java.test.ts +68 -0
- package/principles/adapters/__tests__/kotlin.test.ts +76 -0
- package/principles/adapters/__tests__/objectivec.test.ts +453 -0
- package/principles/adapters/__tests__/python.test.ts +84 -0
- package/principles/adapters/__tests__/swift.test.ts +68 -0
- package/principles/adapters/__tests__/typescript.test.ts +116 -0
- package/principles/adapters/base.ts +124 -0
- package/principles/adapters/cpp.ts +115 -0
- package/principles/adapters/dart.ts +44 -0
- package/principles/adapters/go.ts +55 -0
- package/principles/adapters/java.ts +56 -0
- package/principles/adapters/kotlin.ts +54 -0
- package/principles/adapters/objectivec.ts +82 -0
- package/principles/adapters/python.ts +83 -0
- package/principles/adapters/swift.ts +40 -0
- package/principles/adapters/typescript.ts +66 -0
- package/principles/analyzer.ts +190 -0
- package/principles/baseline.ts +273 -0
- package/principles/boy-scout.ts +416 -0
- package/principles/config.ts +118 -0
- package/principles/index.ts +87 -0
- package/principles/lint-baseline.ts +385 -0
- package/principles/reporter.ts +188 -0
- package/principles/rules/__tests__/clean-code/code-duplication.test.ts +84 -0
- package/principles/rules/__tests__/clean-code/deep-nesting.test.ts +74 -0
- package/principles/rules/__tests__/clean-code/god-class.test.ts +111 -0
- package/principles/rules/__tests__/clean-code/large-file.test.ts +73 -0
- package/principles/rules/__tests__/clean-code/long-function.test.ts +83 -0
- package/principles/rules/__tests__/clean-code/magic-numbers.test.ts +68 -0
- package/principles/rules/__tests__/clean-code/many-exports.test.ts +118 -0
- package/principles/rules/__tests__/clean-code/missing-error-handling.test.ts +173 -0
- package/principles/rules/__tests__/clean-code/too-many-params.test.ts +60 -0
- package/principles/rules/__tests__/clean-code/unused-imports.test.ts +107 -0
- package/principles/rules/__tests__/index.test.ts +49 -0
- package/principles/rules/__tests__/solid/dip.test.ts +121 -0
- package/principles/rules/__tests__/solid/isp.test.ts +60 -0
- package/principles/rules/__tests__/solid/lsp.test.ts +184 -0
- package/principles/rules/__tests__/solid/ocp.test.ts +69 -0
- package/principles/rules/__tests__/solid/srp.test.ts +89 -0
- package/principles/rules/clean-code/code-duplication.ts +31 -0
- package/principles/rules/clean-code/deep-nesting.ts +38 -0
- package/principles/rules/clean-code/god-class.ts +54 -0
- package/principles/rules/clean-code/large-file.ts +34 -0
- package/principles/rules/clean-code/long-function.ts +47 -0
- package/principles/rules/clean-code/magic-numbers.ts +59 -0
- package/principles/rules/clean-code/many-exports.ts +40 -0
- package/principles/rules/clean-code/missing-error-handling.ts +33 -0
- package/principles/rules/clean-code/too-many-params.ts +35 -0
- package/principles/rules/clean-code/unused-imports.ts +33 -0
- package/principles/rules/index.ts +61 -0
- package/principles/rules/solid/dip.ts +47 -0
- package/principles/rules/solid/isp.ts +37 -0
- package/principles/rules/solid/lsp.ts +79 -0
- package/principles/rules/solid/ocp.ts +40 -0
- package/principles/rules/solid/srp.ts +44 -0
- package/principles/types.ts +27 -0
- package/skills/delphi-review/AGENTS.md +7 -7
- package/skills/delphi-review/INSTALL.md +1 -1
- package/skills/delphi-review/SKILL.md +12 -12
- package/skills/delphi-review/references/code-walkthrough.md +1 -1
- package/skills/sprint-flow/AGENTS.md +83 -36
- package/skills/sprint-flow/SKILL.md +1 -1
- package/skills/sprint-flow/references/force-levels.md +1 -1
- package/skills/sprint-flow/references/phase-3-review.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { isAbsolute, join } from 'path';
|
|
3
|
+
import { loadMockPolicyConfig } from './config';
|
|
4
|
+
import { scanProjectScope } from './scope-scanner';
|
|
5
|
+
import MockDecisionEngine from './mock-decision-engine';
|
|
6
|
+
import { MockPolicyResult, MockPolicyViolation, MockStrategy } from './types';
|
|
7
|
+
import { detectTestLayer } from '../mutation/detect-ai-test';
|
|
8
|
+
|
|
9
|
+
function filterTestFiles(files: string[]): string[] {
|
|
10
|
+
return files.filter(f => f.endsWith('.test.ts') || f.endsWith('.spec.ts'));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const IMPORT_REGEXES = [
|
|
14
|
+
/import\s*(?:type\s*)?(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)?\s*(?:from\s*)?['"]([^'"]+)['"]/g,
|
|
15
|
+
/(?:const|let|var)\s+(?:\{[^}]*\}|\w+)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
16
|
+
/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
17
|
+
/export\s+(?:type\s+)?(?:\{[^}]*\}|\*\s+as\s+\w+|\*|\w+)\s+from\s+['"]([^'"]+)['"]/g,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function extractMatches(regex: RegExp, content: string): string[] {
|
|
21
|
+
const results: string[] = [];
|
|
22
|
+
let match: RegExpExecArray | null;
|
|
23
|
+
while ((match = regex.exec(content)) !== null) {
|
|
24
|
+
results.push(match[1]);
|
|
25
|
+
}
|
|
26
|
+
return results;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function collectImports(testContent: string): string[] {
|
|
30
|
+
const imports: string[] = [];
|
|
31
|
+
for (const regex of IMPORT_REGEXES) {
|
|
32
|
+
imports.push(...extractMatches(regex, testContent));
|
|
33
|
+
}
|
|
34
|
+
return [...new Set(imports)];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function detectMockUsage(testContent: string, importPath: string): MockStrategy {
|
|
38
|
+
const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
39
|
+
const mockPatterns = [
|
|
40
|
+
new RegExp(`(?:vi|jest)\\.(?:do)?mock\\(['"]${escaped}['"]`),
|
|
41
|
+
new RegExp(`(?:vi|jest)\\.spyOn\\([^,]+,['"]${escaped}['"]`),
|
|
42
|
+
];
|
|
43
|
+
return mockPatterns.some(p => p.test(testContent)) ? 'mock' : 'real';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface StrategyViolationInput {
|
|
47
|
+
testFile: string;
|
|
48
|
+
importPath: string;
|
|
49
|
+
actualStrategy: MockStrategy;
|
|
50
|
+
expectedStrategy: MockStrategy;
|
|
51
|
+
reason: string;
|
|
52
|
+
severity: 'warning' | 'error';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildStrategyViolation(input: StrategyViolationInput): MockPolicyViolation {
|
|
56
|
+
return {
|
|
57
|
+
file: input.testFile,
|
|
58
|
+
line: 0,
|
|
59
|
+
dependency: input.importPath,
|
|
60
|
+
actualStrategy: input.actualStrategy,
|
|
61
|
+
expectedStrategy: input.expectedStrategy,
|
|
62
|
+
reason: input.reason,
|
|
63
|
+
severity: input.severity,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildPendingRemovalViolation(
|
|
68
|
+
testFile: string,
|
|
69
|
+
importPath: string,
|
|
70
|
+
severity: 'warning' | 'error',
|
|
71
|
+
): MockPolicyViolation {
|
|
72
|
+
return buildStrategyViolation({
|
|
73
|
+
testFile,
|
|
74
|
+
importPath,
|
|
75
|
+
actualStrategy: 'mock',
|
|
76
|
+
expectedStrategy: 'mock',
|
|
77
|
+
reason: `Pending dependency "${importPath}" requires @mock-justified annotation with removal plan`,
|
|
78
|
+
severity,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ValidateImportInput {
|
|
83
|
+
testFile: string;
|
|
84
|
+
content: string;
|
|
85
|
+
importPath: string;
|
|
86
|
+
engine: MockDecisionEngine;
|
|
87
|
+
layer: ReturnType<typeof detectTestLayer>;
|
|
88
|
+
severity: 'warning' | 'error';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validateImport(input: ValidateImportInput): MockPolicyViolation[] {
|
|
92
|
+
const { testFile, content, importPath, engine, layer, severity } = input;
|
|
93
|
+
const decision = engine.decide(importPath, layer);
|
|
94
|
+
const actualStrategy = detectMockUsage(content, importPath);
|
|
95
|
+
const violations: MockPolicyViolation[] = [];
|
|
96
|
+
|
|
97
|
+
if (decision.strategy !== actualStrategy) {
|
|
98
|
+
violations.push(buildStrategyViolation({
|
|
99
|
+
testFile,
|
|
100
|
+
importPath,
|
|
101
|
+
actualStrategy,
|
|
102
|
+
expectedStrategy: decision.strategy,
|
|
103
|
+
reason: decision.reason,
|
|
104
|
+
severity,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (decision.pendingRemoval && actualStrategy === 'mock' && !content.includes('@mock-justified')) {
|
|
109
|
+
violations.push(buildPendingRemovalViolation(testFile, importPath, severity));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return violations;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function validateFile(
|
|
116
|
+
testFile: string,
|
|
117
|
+
engine: MockDecisionEngine,
|
|
118
|
+
projectRoot: string,
|
|
119
|
+
severity: 'warning' | 'error',
|
|
120
|
+
): Promise<MockPolicyViolation[]> {
|
|
121
|
+
const fullPath = isAbsolute(testFile) ? testFile : join(projectRoot, testFile);
|
|
122
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
123
|
+
const layer = detectTestLayer(testFile);
|
|
124
|
+
const imports = collectImports(content);
|
|
125
|
+
|
|
126
|
+
return imports.flatMap(importPath => validateImport({
|
|
127
|
+
testFile,
|
|
128
|
+
content,
|
|
129
|
+
importPath,
|
|
130
|
+
engine,
|
|
131
|
+
layer,
|
|
132
|
+
severity,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function runGateM3(
|
|
137
|
+
changedFiles: string[],
|
|
138
|
+
projectRoot: string = process.cwd(),
|
|
139
|
+
): Promise<MockPolicyResult> {
|
|
140
|
+
const testFiles = filterTestFiles(changedFiles);
|
|
141
|
+
|
|
142
|
+
if (testFiles.length === 0) {
|
|
143
|
+
return {
|
|
144
|
+
exitCode: 0,
|
|
145
|
+
status: 'skip',
|
|
146
|
+
violations: [],
|
|
147
|
+
scores: { totalTests: 0, integrationTests: 0, mockDensity: 0, pendingMocks: 0 },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const config = await loadMockPolicyConfig(projectRoot);
|
|
152
|
+
const severity: 'warning' | 'error' = config.severity;
|
|
153
|
+
|
|
154
|
+
// Collect all imports from all test files
|
|
155
|
+
const allImports: string[] = [];
|
|
156
|
+
for (const testFile of testFiles) {
|
|
157
|
+
try {
|
|
158
|
+
const fullPath = isAbsolute(testFile) ? testFile : join(projectRoot, testFile);
|
|
159
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
160
|
+
allImports.push(...collectImports(content));
|
|
161
|
+
} catch {
|
|
162
|
+
// File not readable — skip
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const scope = await scanProjectScope({
|
|
167
|
+
projectRoot,
|
|
168
|
+
imports: [...new Set(allImports)],
|
|
169
|
+
boundary: config.projectBoundary,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const engine = new MockDecisionEngine(scope, config, projectRoot);
|
|
173
|
+
const allViolations: MockPolicyViolation[] = [];
|
|
174
|
+
|
|
175
|
+
let integrationCount = 0;
|
|
176
|
+
for (const testFile of testFiles) {
|
|
177
|
+
const layer = detectTestLayer(testFile);
|
|
178
|
+
if (layer === 'integration') integrationCount++;
|
|
179
|
+
|
|
180
|
+
const violations = await validateFile(testFile, engine, projectRoot, severity);
|
|
181
|
+
allViolations.push(...violations);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const hasBlockableViolations = allViolations.some(v => v.severity === 'error');
|
|
185
|
+
const blocked = severity === 'error' && hasBlockableViolations;
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
exitCode: blocked ? 1 : 0,
|
|
189
|
+
status: blocked ? 'block' : 'pass',
|
|
190
|
+
violations: allViolations,
|
|
191
|
+
scores: {
|
|
192
|
+
totalTests: testFiles.length,
|
|
193
|
+
integrationTests: integrationCount,
|
|
194
|
+
mockDensity: 0,
|
|
195
|
+
pendingMocks: allViolations.filter(v => v.reason.includes('pending')).length,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function main(args: string[]): Promise<number> {
|
|
201
|
+
if (args.length === 0) {
|
|
202
|
+
console.error('Usage: npx tsx src/mock-policy/gate-m3.ts <file1> <file2> ...');
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
console.log('Gate M3: Mock Layering Validation');
|
|
207
|
+
console.log(` Changed files: ${args.length}`);
|
|
208
|
+
|
|
209
|
+
const result = await runGateM3(args);
|
|
210
|
+
|
|
211
|
+
if (result.violations.length > 0) {
|
|
212
|
+
console.log('\nViolations:');
|
|
213
|
+
for (const v of result.violations) {
|
|
214
|
+
const icon = v.severity === 'error' ? String.fromCodePoint(0x2717) : String.fromCodePoint(0x26A0);
|
|
215
|
+
console.log(` ${icon} ${v.file}: ${v.reason}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const label = result.status === 'block' ? `${String.fromCodePoint(0x2717)} BLOCK` : `${String.fromCodePoint(0x2713)} PASS`;
|
|
220
|
+
console.log(`\n${label} Integration tests: ${result.scores.integrationTests} Pending mocks: ${result.scores.pendingMocks}`);
|
|
221
|
+
|
|
222
|
+
return result.exitCode;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Entry point — process.argv detection for ESM compatibility
|
|
226
|
+
const scriptIndex = process.argv.findIndex(a => a.endsWith('gate-m3.ts'));
|
|
227
|
+
const cliArgs = scriptIndex >= 0 ? process.argv.slice(scriptIndex + 1) : process.argv.slice(2);
|
|
228
|
+
|
|
229
|
+
if (cliArgs.length > 0) {
|
|
230
|
+
main(cliArgs)
|
|
231
|
+
.then(exitCode => { if (exitCode !== 0) process.exit(exitCode); })
|
|
232
|
+
.catch(err => { console.error('Gate M3 failed:', err.message); process.exit(1); });
|
|
233
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { ProjectScope, MockDecision, MockPolicyConfig, TestLayer, MockPolicyLayerRules } from './types';
|
|
2
|
+
import { classifyDependency } from './scope-scanner';
|
|
3
|
+
|
|
4
|
+
type DecisionFn = (importPath: string, layer: TestLayer, layerRules: MockPolicyLayerRules, scope: ProjectScope) => MockDecision;
|
|
5
|
+
|
|
6
|
+
function internalDecision(
|
|
7
|
+
importPath: string,
|
|
8
|
+
layer: TestLayer,
|
|
9
|
+
layerRules: MockPolicyLayerRules,
|
|
10
|
+
scope: ProjectScope,
|
|
11
|
+
): MockDecision {
|
|
12
|
+
const isImplemented = scope.implementedModules.some(
|
|
13
|
+
mod => importPath === mod || importPath.endsWith('/' + mod) || importPath.startsWith(mod),
|
|
14
|
+
);
|
|
15
|
+
if (isImplemented && layerRules.requireRealForImplemented) {
|
|
16
|
+
return {
|
|
17
|
+
strategy: 'real',
|
|
18
|
+
reason: `Internal and implemented dependency '${importPath}' should use real implementation`,
|
|
19
|
+
layer,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
strategy: 'mock',
|
|
24
|
+
reason: `Internal dependency '${importPath}' should be mocked for isolation`,
|
|
25
|
+
layer,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function externalDecision(
|
|
30
|
+
importPath: string,
|
|
31
|
+
layer: TestLayer,
|
|
32
|
+
layerRules: MockPolicyLayerRules,
|
|
33
|
+
): MockDecision {
|
|
34
|
+
if (layerRules.allowExternalMock) {
|
|
35
|
+
return {
|
|
36
|
+
strategy: 'mock',
|
|
37
|
+
reason: `External dependency '${importPath}' should be mocked per policy`,
|
|
38
|
+
layer,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
strategy: 'real',
|
|
43
|
+
reason: `External dependency '${importPath}' allowed as real (allowExternalMock is disabled)`,
|
|
44
|
+
layer,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pendingDecision(
|
|
49
|
+
importPath: string,
|
|
50
|
+
layer: TestLayer,
|
|
51
|
+
layerRules: MockPolicyLayerRules,
|
|
52
|
+
scope: ProjectScope,
|
|
53
|
+
): MockDecision {
|
|
54
|
+
const pendingTicket = scope.unimplementedModules.find(
|
|
55
|
+
mod => importPath === mod || importPath.endsWith('/' + mod) || importPath.startsWith(mod),
|
|
56
|
+
);
|
|
57
|
+
if (layerRules.requirePendingRemoval && pendingTicket) {
|
|
58
|
+
return {
|
|
59
|
+
strategy: 'mock',
|
|
60
|
+
reason: `Pending dependency '${importPath}' must be mocked until implemented`,
|
|
61
|
+
layer,
|
|
62
|
+
pendingRemoval: {
|
|
63
|
+
ticket: pendingTicket,
|
|
64
|
+
reason: `Pending dependency '${importPath}' is not yet implemented; mock will be removed once the real implementation is available`,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
strategy: 'mock',
|
|
70
|
+
reason: `Pending dependency '${importPath}' should be mocked (not yet implemented)`,
|
|
71
|
+
layer,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function unknownDecision(importPath: string, layer: TestLayer): MockDecision {
|
|
76
|
+
return {
|
|
77
|
+
strategy: 'mock',
|
|
78
|
+
reason: `Unknown dependency '${importPath}' should be mocked for safety`,
|
|
79
|
+
layer,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Determines whether a given dependency should be mocked or used as-is
|
|
85
|
+
* for a specific test layer, based on the project scope and policy configuration.
|
|
86
|
+
*
|
|
87
|
+
* Decision matrix:
|
|
88
|
+
* - **e2e**: Always returns `real` — no mocks in end-to-end tests.
|
|
89
|
+
* - **unit** with `lenient` policy: Always returns `mock` for all dependencies.
|
|
90
|
+
* - **unit** with `strict` policy: Applies integration-like rules.
|
|
91
|
+
* - **integration**: Applies rules based on dependency scope and policy flags.
|
|
92
|
+
*/
|
|
93
|
+
class MockDecisionEngine {
|
|
94
|
+
private readonly scope: ProjectScope;
|
|
95
|
+
private readonly config: MockPolicyConfig;
|
|
96
|
+
private readonly projectRoot: string;
|
|
97
|
+
|
|
98
|
+
constructor(scope: ProjectScope, config: MockPolicyConfig, projectRoot?: string) {
|
|
99
|
+
this.scope = scope;
|
|
100
|
+
this.config = config;
|
|
101
|
+
this.projectRoot = projectRoot || process.cwd();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Decide the mock strategy for a given import path in the specified test layer.
|
|
106
|
+
*
|
|
107
|
+
* @param importPath - The dependency import path to evaluate
|
|
108
|
+
* @param layer - The test layer (unit, integration, or e2e)
|
|
109
|
+
* @returns A MockDecision with strategy, reason, and optional pending removal info
|
|
110
|
+
*/
|
|
111
|
+
decide(importPath: string, layer: TestLayer): MockDecision {
|
|
112
|
+
if (layer === 'e2e') {
|
|
113
|
+
return {
|
|
114
|
+
strategy: 'real',
|
|
115
|
+
reason: `E2E tests must use real dependencies; no mocks allowed in ${layer} layer`,
|
|
116
|
+
layer,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const layerRules = this.config.layers[layer === 'unknown' ? 'unit' : layer];
|
|
121
|
+
|
|
122
|
+
if (layer === 'unit' && layerRules.mockPolicy === 'lenient') {
|
|
123
|
+
return {
|
|
124
|
+
strategy: 'mock',
|
|
125
|
+
reason: `Unit tests with lenient policy: all dependencies should be mocked`,
|
|
126
|
+
layer,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const depScope = classifyDependency(
|
|
131
|
+
importPath,
|
|
132
|
+
this.scope,
|
|
133
|
+
{
|
|
134
|
+
projectRoot: this.projectRoot,
|
|
135
|
+
imports: [importPath],
|
|
136
|
+
boundary: this.config.projectBoundary,
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const decider: Record<string, DecisionFn> = {
|
|
141
|
+
internal: internalDecision,
|
|
142
|
+
external: externalDecision,
|
|
143
|
+
pending: pendingDecision,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const handler = decider[depScope];
|
|
147
|
+
if (handler) {
|
|
148
|
+
return handler(importPath, layer, layerRules, this.scope);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return unknownDecision(importPath, layer);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export default MockDecisionEngine;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const MockPolicyLayerRulesSchema = z.object({
|
|
4
|
+
mockPolicy: z.enum(['strict', 'lenient']),
|
|
5
|
+
requireRealForImplemented: z.boolean(),
|
|
6
|
+
allowExternalMock: z.boolean(),
|
|
7
|
+
requirePendingRemoval: z.boolean(),
|
|
8
|
+
maxMockDensity: z.number().min(0).max(100).optional(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const MockPolicyConfigSchema = z.object({
|
|
12
|
+
version: z.literal(1),
|
|
13
|
+
layers: z.object({
|
|
14
|
+
unit: MockPolicyLayerRulesSchema,
|
|
15
|
+
integration: MockPolicyLayerRulesSchema,
|
|
16
|
+
e2e: MockPolicyLayerRulesSchema,
|
|
17
|
+
}),
|
|
18
|
+
projectBoundary: z.array(z.string()).min(1),
|
|
19
|
+
severity: z.enum(['error', 'warning']),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type MockPolicyConfig = z.infer<typeof MockPolicyConfigSchema>;
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { readFile } from 'fs/promises';
|
|
3
|
+
import { isAbsolute, join, resolve } from 'path';
|
|
4
|
+
import type { ProjectScope, DependencyScope } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options for the scope scanner.
|
|
8
|
+
*/
|
|
9
|
+
export interface ScanOptions {
|
|
10
|
+
/** Root directory of the project */
|
|
11
|
+
projectRoot: string;
|
|
12
|
+
/** List of import paths to classify */
|
|
13
|
+
imports: string[];
|
|
14
|
+
/** Boundary patterns (glob-style) that define the project boundary */
|
|
15
|
+
boundary: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Convert a glob pattern to a RegExp.
|
|
20
|
+
* - `**` matches across directory separators → `.*`
|
|
21
|
+
* - `*` matches within a single path segment → `[^/]*`
|
|
22
|
+
*
|
|
23
|
+
* @param pattern - Glob pattern to convert
|
|
24
|
+
* @returns RegExp equivalent of the glob pattern
|
|
25
|
+
*/
|
|
26
|
+
type GlobCharResult = { regex: string; advance: number };
|
|
27
|
+
|
|
28
|
+
function globCharToRegex(ch: string, nextCh: string): GlobCharResult {
|
|
29
|
+
if (ch === '*' && nextCh === '*') return { regex: '.*', advance: 2 };
|
|
30
|
+
if (ch === '*') return { regex: '[^/]*', advance: 1 };
|
|
31
|
+
if (ch === '.') return { regex: '\\.', advance: 1 };
|
|
32
|
+
if (ch === '?') return { regex: '[^/]', advance: 1 };
|
|
33
|
+
if (ch === '/') return { regex: '/', advance: 1 };
|
|
34
|
+
const special = '.+^${}()|[]\\';
|
|
35
|
+
if (special.includes(ch)) return { regex: '\\' + ch, advance: 1 };
|
|
36
|
+
return { regex: ch, advance: 1 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function simpleGlobMatch(pattern: string, inputPath: string): boolean {
|
|
40
|
+
let regexStr = '';
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (i < pattern.length) {
|
|
43
|
+
const result = globCharToRegex(pattern[i], pattern[i + 1] || '');
|
|
44
|
+
regexStr += result.regex;
|
|
45
|
+
i += result.advance;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const re = new RegExp(`^${regexStr}$`);
|
|
50
|
+
return re.test(inputPath);
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Map of all Node.js built-in module names.
|
|
58
|
+
*/
|
|
59
|
+
const NODE_BUILTINS = new Set([
|
|
60
|
+
'fs', 'path', 'os', 'http', 'https', 'crypto', 'stream',
|
|
61
|
+
'events', 'util', 'url', 'querystring', 'assert', 'buffer',
|
|
62
|
+
'child_process', 'cluster', 'dns', 'net', 'tls', 'readline',
|
|
63
|
+
'process', 'v8', 'vm', 'zlib',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check whether an import path is external (not part of the project).
|
|
68
|
+
*
|
|
69
|
+
* An import is considered external if:
|
|
70
|
+
* 1. It is a Node.js built-in module
|
|
71
|
+
* 2. It is a bare npm package (not starting with `.`, `/`, or `@/`)
|
|
72
|
+
* 3. It matches any boundary pattern
|
|
73
|
+
*
|
|
74
|
+
* @param importPath - The import path to check
|
|
75
|
+
* @param options - Scan options with boundary patterns
|
|
76
|
+
* @returns `true` if the import is external
|
|
77
|
+
*/
|
|
78
|
+
export function isExternalImport(importPath: string, options: ScanOptions): boolean {
|
|
79
|
+
// Node.js builtins are always external
|
|
80
|
+
const bareName = importPath.startsWith('node:') ? importPath.slice(5) : importPath;
|
|
81
|
+
if (NODE_BUILTINS.has(bareName)) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Relative imports (starting with .) are internal
|
|
86
|
+
if (importPath.startsWith('.')) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// @/ alias is internal (project path alias)
|
|
91
|
+
if (importPath.startsWith('@/')) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Absolute imports (Unix /... or Windows C:\...) are internal
|
|
96
|
+
if (isAbsolute(importPath)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check boundary patterns — if any pattern matches, it's within the project boundary
|
|
101
|
+
for (const pattern of options.boundary) {
|
|
102
|
+
if (simpleGlobMatch(pattern, importPath)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Bare import (npm package) — external
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve a `@/` path alias to a real file path.
|
|
113
|
+
* `@/` maps to `src/` by default.
|
|
114
|
+
*
|
|
115
|
+
* @param importPath - Import path (may start with `@/`)
|
|
116
|
+
* @param projectRoot - Root of the project
|
|
117
|
+
* @returns Resolved absolute file path
|
|
118
|
+
*/
|
|
119
|
+
export function resolveToRealPath(importPath: string, projectRoot: string): string {
|
|
120
|
+
const normalized = importPath.replace(/^@\//, 'src/');
|
|
121
|
+
return resolve(projectRoot, normalized);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Load external dependencies from the project's package.json.
|
|
126
|
+
* Reads `dependencies`, `devDependencies`, and `peerDependencies`,
|
|
127
|
+
* skipping `workspace:*` protocol packages.
|
|
128
|
+
*
|
|
129
|
+
* @param projectRoot - Root of the project
|
|
130
|
+
* @returns Array of external package names
|
|
131
|
+
*/
|
|
132
|
+
export async function loadExternalDependencies(projectRoot: string): Promise<string[]> {
|
|
133
|
+
const pkgPath = join(projectRoot, 'package.json');
|
|
134
|
+
|
|
135
|
+
if (!existsSync(pkgPath)) {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const content = await readFile(pkgPath, 'utf-8');
|
|
141
|
+
const pkg = JSON.parse(content) as Record<string, unknown>;
|
|
142
|
+
|
|
143
|
+
const deps: Record<string, string> = pkg.dependencies as Record<string, string> || {};
|
|
144
|
+
const devDeps: Record<string, string> = pkg.devDependencies as Record<string, string> || {};
|
|
145
|
+
const peerDeps: Record<string, string> = pkg.peerDependencies as Record<string, string> || {};
|
|
146
|
+
|
|
147
|
+
const allDeps = { ...deps, ...devDeps, ...peerDeps };
|
|
148
|
+
const packages: string[] = [];
|
|
149
|
+
|
|
150
|
+
for (const [name, version] of Object.entries(allDeps)) {
|
|
151
|
+
// Skip workspace:* protocol packages — they are internal monorepo references
|
|
152
|
+
if (typeof version === 'string' && version.startsWith('workspace:')) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
packages.push(name);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return packages.sort();
|
|
159
|
+
} catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Check if an import path matches any module in a list.
|
|
166
|
+
* Matches: exact name, subpath (ends with /module), or prefix (starts with module).
|
|
167
|
+
*/
|
|
168
|
+
function matchesModule(importPath: string, modules: string[]): boolean {
|
|
169
|
+
return modules.some(
|
|
170
|
+
(mod) => importPath === mod || importPath.endsWith('/' + mod) || importPath.startsWith(mod),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check filesystem existence with optional cache.
|
|
176
|
+
* Returns [exists, resolvedPath].
|
|
177
|
+
*/
|
|
178
|
+
function checkExists(
|
|
179
|
+
importPath: string,
|
|
180
|
+
projectRoot: string,
|
|
181
|
+
existCache?: Map<string, boolean>,
|
|
182
|
+
): [boolean, string] {
|
|
183
|
+
const resolved = resolveToRealPath(importPath, projectRoot);
|
|
184
|
+
const exists = existCache?.get(resolved) ?? existsSync(resolved);
|
|
185
|
+
if (existCache) existCache.set(resolved, exists);
|
|
186
|
+
return [exists, resolved];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isExternalPackage(importPath: string, packages: string[]): boolean {
|
|
190
|
+
return packages.some(
|
|
191
|
+
(pkg) => importPath === pkg || importPath.startsWith(pkg + '/'),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Classify a single dependency as internal, external, or pending.
|
|
197
|
+
*
|
|
198
|
+
* - **internal**: The import resolves to a file within the project boundary,
|
|
199
|
+
* or it's a relative/aliased import that exists on disk.
|
|
200
|
+
* - **external**: The import is a Node.js builtin or npm package.
|
|
201
|
+
* - **pending**: The import cannot be resolved yet (checked via existCache or filesystem).
|
|
202
|
+
*
|
|
203
|
+
* @param importPath - The import path to classify
|
|
204
|
+
* @param scope - The project scope information
|
|
205
|
+
* @param options - Scan options (used for boundary checks)
|
|
206
|
+
* @param existCache - Cache of filesystem existence checks (path → boolean)
|
|
207
|
+
* @returns The dependency scope classification
|
|
208
|
+
*/
|
|
209
|
+
export function classifyDependency(
|
|
210
|
+
importPath: string,
|
|
211
|
+
scope: ProjectScope,
|
|
212
|
+
options: ScanOptions,
|
|
213
|
+
existCache: Map<string, boolean> = new Map(),
|
|
214
|
+
): DependencyScope {
|
|
215
|
+
if (matchesModule(importPath, scope.implementedModules)) return 'internal';
|
|
216
|
+
if (matchesModule(importPath, scope.unimplementedModules)) return 'pending';
|
|
217
|
+
if (isExternalPackage(importPath, scope.externalPackages)) return 'external';
|
|
218
|
+
|
|
219
|
+
for (const pattern of options.boundary) {
|
|
220
|
+
if (simpleGlobMatch(pattern, importPath)) {
|
|
221
|
+
const [exists] = checkExists(importPath, options.projectRoot, existCache);
|
|
222
|
+
if (exists) return 'internal';
|
|
223
|
+
return 'pending';
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (isExternalImport(importPath, options)) return 'external';
|
|
228
|
+
|
|
229
|
+
const [exists] = checkExists(importPath, options.projectRoot, existCache);
|
|
230
|
+
if (exists) return 'internal';
|
|
231
|
+
return 'pending';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isAlreadyClassified(
|
|
235
|
+
importPath: string,
|
|
236
|
+
implemented: string[],
|
|
237
|
+
unimplemented: string[],
|
|
238
|
+
external: string[],
|
|
239
|
+
): boolean {
|
|
240
|
+
return implemented.includes(importPath)
|
|
241
|
+
|| unimplemented.includes(importPath)
|
|
242
|
+
|| external.includes(importPath);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Scan the project scope — classify a list of imports into
|
|
247
|
+
* implemented, unimplemented, and external categories.
|
|
248
|
+
*
|
|
249
|
+
* Uses a filesystem existence cache (`existCache` Map) to
|
|
250
|
+
* avoid repeated `existsSync` calls for the same path.
|
|
251
|
+
*
|
|
252
|
+
* @param options - Scan options
|
|
253
|
+
* @returns The project scope with classified modules
|
|
254
|
+
*/
|
|
255
|
+
export async function scanProjectScope(options: ScanOptions): Promise<ProjectScope> {
|
|
256
|
+
const { projectRoot, imports, boundary } = options;
|
|
257
|
+
const existCache = new Map<string, boolean>();
|
|
258
|
+
|
|
259
|
+
const implemented: string[] = [];
|
|
260
|
+
const unimplemented: string[] = [];
|
|
261
|
+
const external: string[] = [];
|
|
262
|
+
|
|
263
|
+
for (const importPath of imports) {
|
|
264
|
+
if (isAlreadyClassified(importPath, implemented, unimplemented, external)) continue;
|
|
265
|
+
|
|
266
|
+
if (isExternalImport(importPath, options)) {
|
|
267
|
+
external.push(importPath);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const resolved = resolveToRealPath(importPath, projectRoot);
|
|
272
|
+
const exists = existCache.get(resolved) ?? existsSync(resolved);
|
|
273
|
+
existCache.set(resolved, exists);
|
|
274
|
+
|
|
275
|
+
if (exists) {
|
|
276
|
+
implemented.push(importPath);
|
|
277
|
+
} else {
|
|
278
|
+
unimplemented.push(importPath);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
implementedModules: [...new Set(implemented)],
|
|
284
|
+
unimplementedModules: [...new Set(unimplemented)],
|
|
285
|
+
externalPackages: [...new Set(external)],
|
|
286
|
+
projectBoundary: [...boundary],
|
|
287
|
+
};
|
|
288
|
+
}
|