@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.
Files changed (141) hide show
  1. package/bin/xp-gate.js +18 -0
  2. package/hooks/pre-commit +565 -120
  3. package/lib/arch.js +49 -0
  4. package/lib/check.js +50 -0
  5. package/lib/init.js +36 -0
  6. package/lib/principles.js +48 -0
  7. package/mock-policy/AGENTS.md +94 -0
  8. package/mock-policy/__tests__/config.test.ts +292 -0
  9. package/mock-policy/__tests__/integration.test.ts +600 -0
  10. package/mock-policy/__tests__/mock-decision-engine.test.ts +231 -0
  11. package/mock-policy/__tests__/scope-scanner.test.ts +535 -0
  12. package/mock-policy/config.ts +106 -0
  13. package/mock-policy/gate-m3.ts +233 -0
  14. package/mock-policy/mock-decision-engine.ts +155 -0
  15. package/mock-policy/schema.ts +22 -0
  16. package/mock-policy/scope-scanner.ts +288 -0
  17. package/mock-policy/types.ts +65 -0
  18. package/mutation/AGENTS.md +73 -0
  19. package/mutation/__tests__/detect-ai-test.test.ts +152 -0
  20. package/mutation/__tests__/gate-m.test.ts +194 -0
  21. package/mutation/detect-ai-test.ts +128 -0
  22. package/mutation/gate-m.ts +619 -0
  23. package/mutation/init-baseline.ts +192 -0
  24. package/mutation/stryker-types.ts +14 -0
  25. package/mutation/types.ts +70 -0
  26. package/mutation/update-baseline.ts +160 -0
  27. package/package.json +5 -2
  28. package/plugins/claude-code/.claude-plugin/plugin.json +2 -2
  29. package/plugins/claude-code/skills/delphi-review/AGENTS.md +7 -7
  30. package/plugins/claude-code/skills/delphi-review/INSTALL.md +1 -1
  31. package/plugins/claude-code/skills/delphi-review/SKILL.md +12 -12
  32. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +1 -1
  33. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +83 -36
  34. package/plugins/claude-code/skills/sprint-flow/SKILL.md +1 -1
  35. package/plugins/claude-code/skills/sprint-flow/references/force-levels.md +1 -1
  36. package/plugins/claude-code/skills/sprint-flow/references/phase-3-review.md +2 -2
  37. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  38. package/plugins/opencode/README.md +23 -7
  39. package/plugins/opencode/index.ts +36 -21
  40. package/plugins/opencode/package.json +1 -1
  41. package/plugins/opencode/skills/delphi-review/AGENTS.md +7 -7
  42. package/plugins/opencode/skills/delphi-review/INSTALL.md +1 -1
  43. package/plugins/opencode/skills/delphi-review/SKILL.md +12 -12
  44. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +1 -1
  45. package/plugins/opencode/skills/sprint-flow/AGENTS.md +83 -36
  46. package/plugins/opencode/skills/sprint-flow/SKILL.md +1 -1
  47. package/plugins/opencode/skills/sprint-flow/references/force-levels.md +1 -1
  48. package/plugins/opencode/skills/sprint-flow/references/phase-3-review.md +2 -2
  49. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  50. package/plugins/qoder/plugin.json +20 -0
  51. package/plugins/qoder/skills/delphi-review/AGENTS.md +7 -7
  52. package/plugins/qoder/skills/delphi-review/INSTALL.md +1 -1
  53. package/plugins/qoder/skills/delphi-review/SKILL.md +12 -12
  54. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +1 -1
  55. package/plugins/qoder/skills/sprint-flow/AGENTS.md +83 -36
  56. package/plugins/qoder/skills/sprint-flow/SKILL.md +51 -1
  57. package/plugins/qoder/skills/sprint-flow/references/force-levels.md +1 -1
  58. package/plugins/qoder/skills/sprint-flow/references/phase-3-review.md +2 -2
  59. package/plugins/qoder/skills/test-driven-development/SKILL.md +371 -0
  60. package/plugins/qoder/skills/test-driven-development/testing-anti-patterns.md +299 -0
  61. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  62. package/principles/AGENTS.md +111 -0
  63. package/principles/__tests__/analyzer.test.ts +343 -0
  64. package/principles/__tests__/baseline-storage.test.ts +187 -0
  65. package/principles/__tests__/baseline.test.ts +328 -0
  66. package/principles/__tests__/boy-scout-integration.test.ts +264 -0
  67. package/principles/__tests__/boy-scout.test.ts +646 -0
  68. package/principles/__tests__/config.test.ts +78 -0
  69. package/principles/__tests__/index.test.ts +81 -0
  70. package/principles/__tests__/lint-baseline.test.ts +314 -0
  71. package/principles/__tests__/reporter.test.ts +832 -0
  72. package/principles/__tests__/types.test.ts +101 -0
  73. package/principles/adapters/__tests__/base.test.ts +95 -0
  74. package/principles/adapters/__tests__/cpp.test.ts +290 -0
  75. package/principles/adapters/__tests__/dart.test.ts +84 -0
  76. package/principles/adapters/__tests__/go.test.ts +68 -0
  77. package/principles/adapters/__tests__/java.test.ts +68 -0
  78. package/principles/adapters/__tests__/kotlin.test.ts +76 -0
  79. package/principles/adapters/__tests__/objectivec.test.ts +453 -0
  80. package/principles/adapters/__tests__/python.test.ts +84 -0
  81. package/principles/adapters/__tests__/swift.test.ts +68 -0
  82. package/principles/adapters/__tests__/typescript.test.ts +116 -0
  83. package/principles/adapters/base.ts +124 -0
  84. package/principles/adapters/cpp.ts +115 -0
  85. package/principles/adapters/dart.ts +44 -0
  86. package/principles/adapters/go.ts +55 -0
  87. package/principles/adapters/java.ts +56 -0
  88. package/principles/adapters/kotlin.ts +54 -0
  89. package/principles/adapters/objectivec.ts +82 -0
  90. package/principles/adapters/python.ts +83 -0
  91. package/principles/adapters/swift.ts +40 -0
  92. package/principles/adapters/typescript.ts +66 -0
  93. package/principles/analyzer.ts +190 -0
  94. package/principles/baseline.ts +273 -0
  95. package/principles/boy-scout.ts +416 -0
  96. package/principles/config.ts +118 -0
  97. package/principles/index.ts +87 -0
  98. package/principles/lint-baseline.ts +385 -0
  99. package/principles/reporter.ts +188 -0
  100. package/principles/rules/__tests__/clean-code/code-duplication.test.ts +84 -0
  101. package/principles/rules/__tests__/clean-code/deep-nesting.test.ts +74 -0
  102. package/principles/rules/__tests__/clean-code/god-class.test.ts +111 -0
  103. package/principles/rules/__tests__/clean-code/large-file.test.ts +73 -0
  104. package/principles/rules/__tests__/clean-code/long-function.test.ts +83 -0
  105. package/principles/rules/__tests__/clean-code/magic-numbers.test.ts +68 -0
  106. package/principles/rules/__tests__/clean-code/many-exports.test.ts +118 -0
  107. package/principles/rules/__tests__/clean-code/missing-error-handling.test.ts +173 -0
  108. package/principles/rules/__tests__/clean-code/too-many-params.test.ts +60 -0
  109. package/principles/rules/__tests__/clean-code/unused-imports.test.ts +107 -0
  110. package/principles/rules/__tests__/index.test.ts +49 -0
  111. package/principles/rules/__tests__/solid/dip.test.ts +121 -0
  112. package/principles/rules/__tests__/solid/isp.test.ts +60 -0
  113. package/principles/rules/__tests__/solid/lsp.test.ts +184 -0
  114. package/principles/rules/__tests__/solid/ocp.test.ts +69 -0
  115. package/principles/rules/__tests__/solid/srp.test.ts +89 -0
  116. package/principles/rules/clean-code/code-duplication.ts +31 -0
  117. package/principles/rules/clean-code/deep-nesting.ts +38 -0
  118. package/principles/rules/clean-code/god-class.ts +54 -0
  119. package/principles/rules/clean-code/large-file.ts +34 -0
  120. package/principles/rules/clean-code/long-function.ts +47 -0
  121. package/principles/rules/clean-code/magic-numbers.ts +59 -0
  122. package/principles/rules/clean-code/many-exports.ts +40 -0
  123. package/principles/rules/clean-code/missing-error-handling.ts +33 -0
  124. package/principles/rules/clean-code/too-many-params.ts +35 -0
  125. package/principles/rules/clean-code/unused-imports.ts +33 -0
  126. package/principles/rules/index.ts +61 -0
  127. package/principles/rules/solid/dip.ts +47 -0
  128. package/principles/rules/solid/isp.ts +37 -0
  129. package/principles/rules/solid/lsp.ts +79 -0
  130. package/principles/rules/solid/ocp.ts +40 -0
  131. package/principles/rules/solid/srp.ts +44 -0
  132. package/principles/types.ts +27 -0
  133. package/skills/delphi-review/AGENTS.md +7 -7
  134. package/skills/delphi-review/INSTALL.md +1 -1
  135. package/skills/delphi-review/SKILL.md +12 -12
  136. package/skills/delphi-review/references/code-walkthrough.md +1 -1
  137. package/skills/sprint-flow/AGENTS.md +83 -36
  138. package/skills/sprint-flow/SKILL.md +1 -1
  139. package/skills/sprint-flow/references/force-levels.md +1 -1
  140. package/skills/sprint-flow/references/phase-3-review.md +2 -2
  141. 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
+ }