@boyingliu01/xp-gate 0.8.9 → 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 (132) hide show
  1. package/lib/init.js +36 -0
  2. package/mock-policy/AGENTS.md +94 -0
  3. package/mock-policy/__tests__/config.test.ts +292 -0
  4. package/mock-policy/__tests__/integration.test.ts +600 -0
  5. package/mock-policy/__tests__/mock-decision-engine.test.ts +231 -0
  6. package/mock-policy/__tests__/scope-scanner.test.ts +535 -0
  7. package/mock-policy/config.ts +106 -0
  8. package/mock-policy/gate-m3.ts +233 -0
  9. package/mock-policy/mock-decision-engine.ts +155 -0
  10. package/mock-policy/schema.ts +22 -0
  11. package/mock-policy/scope-scanner.ts +288 -0
  12. package/mock-policy/types.ts +65 -0
  13. package/mutation/AGENTS.md +73 -0
  14. package/mutation/__tests__/detect-ai-test.test.ts +152 -0
  15. package/mutation/__tests__/gate-m.test.ts +194 -0
  16. package/mutation/detect-ai-test.ts +128 -0
  17. package/mutation/gate-m.ts +619 -0
  18. package/mutation/init-baseline.ts +192 -0
  19. package/mutation/stryker-types.ts +14 -0
  20. package/mutation/types.ts +70 -0
  21. package/mutation/update-baseline.ts +160 -0
  22. package/package.json +5 -2
  23. package/plugins/claude-code/.claude-plugin/plugin.json +2 -2
  24. package/plugins/claude-code/skills/delphi-review/AGENTS.md +7 -7
  25. package/plugins/claude-code/skills/delphi-review/INSTALL.md +1 -1
  26. package/plugins/claude-code/skills/delphi-review/SKILL.md +12 -12
  27. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +1 -1
  28. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +7 -7
  29. package/plugins/claude-code/skills/sprint-flow/SKILL.md +1 -1
  30. package/plugins/claude-code/skills/sprint-flow/references/force-levels.md +1 -1
  31. package/plugins/claude-code/skills/sprint-flow/references/phase-3-review.md +2 -2
  32. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  33. package/plugins/opencode/package.json +1 -1
  34. package/plugins/opencode/skills/delphi-review/AGENTS.md +7 -7
  35. package/plugins/opencode/skills/delphi-review/INSTALL.md +1 -1
  36. package/plugins/opencode/skills/delphi-review/SKILL.md +12 -12
  37. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +1 -1
  38. package/plugins/opencode/skills/sprint-flow/AGENTS.md +7 -7
  39. package/plugins/opencode/skills/sprint-flow/SKILL.md +1 -1
  40. package/plugins/opencode/skills/sprint-flow/references/force-levels.md +1 -1
  41. package/plugins/opencode/skills/sprint-flow/references/phase-3-review.md +2 -2
  42. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  43. package/plugins/qoder/plugin.json +1 -1
  44. package/plugins/qoder/skills/delphi-review/AGENTS.md +7 -7
  45. package/plugins/qoder/skills/delphi-review/INSTALL.md +1 -1
  46. package/plugins/qoder/skills/delphi-review/SKILL.md +12 -12
  47. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +1 -1
  48. package/plugins/qoder/skills/sprint-flow/AGENTS.md +7 -7
  49. package/plugins/qoder/skills/sprint-flow/SKILL.md +51 -1
  50. package/plugins/qoder/skills/sprint-flow/references/force-levels.md +1 -1
  51. package/plugins/qoder/skills/sprint-flow/references/phase-3-review.md +2 -2
  52. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  53. package/principles/AGENTS.md +111 -0
  54. package/principles/__tests__/analyzer.test.ts +343 -0
  55. package/principles/__tests__/baseline-storage.test.ts +187 -0
  56. package/principles/__tests__/baseline.test.ts +328 -0
  57. package/principles/__tests__/boy-scout-integration.test.ts +264 -0
  58. package/principles/__tests__/boy-scout.test.ts +646 -0
  59. package/principles/__tests__/config.test.ts +78 -0
  60. package/principles/__tests__/index.test.ts +81 -0
  61. package/principles/__tests__/lint-baseline.test.ts +314 -0
  62. package/principles/__tests__/reporter.test.ts +832 -0
  63. package/principles/__tests__/types.test.ts +101 -0
  64. package/principles/adapters/__tests__/base.test.ts +95 -0
  65. package/principles/adapters/__tests__/cpp.test.ts +290 -0
  66. package/principles/adapters/__tests__/dart.test.ts +84 -0
  67. package/principles/adapters/__tests__/go.test.ts +68 -0
  68. package/principles/adapters/__tests__/java.test.ts +68 -0
  69. package/principles/adapters/__tests__/kotlin.test.ts +76 -0
  70. package/principles/adapters/__tests__/objectivec.test.ts +453 -0
  71. package/principles/adapters/__tests__/python.test.ts +84 -0
  72. package/principles/adapters/__tests__/swift.test.ts +68 -0
  73. package/principles/adapters/__tests__/typescript.test.ts +116 -0
  74. package/principles/adapters/base.ts +124 -0
  75. package/principles/adapters/cpp.ts +115 -0
  76. package/principles/adapters/dart.ts +44 -0
  77. package/principles/adapters/go.ts +55 -0
  78. package/principles/adapters/java.ts +56 -0
  79. package/principles/adapters/kotlin.ts +54 -0
  80. package/principles/adapters/objectivec.ts +82 -0
  81. package/principles/adapters/python.ts +83 -0
  82. package/principles/adapters/swift.ts +40 -0
  83. package/principles/adapters/typescript.ts +66 -0
  84. package/principles/analyzer.ts +190 -0
  85. package/principles/baseline.ts +273 -0
  86. package/principles/boy-scout.ts +416 -0
  87. package/principles/config.ts +118 -0
  88. package/principles/index.ts +87 -0
  89. package/principles/lint-baseline.ts +385 -0
  90. package/principles/reporter.ts +188 -0
  91. package/principles/rules/__tests__/clean-code/code-duplication.test.ts +84 -0
  92. package/principles/rules/__tests__/clean-code/deep-nesting.test.ts +74 -0
  93. package/principles/rules/__tests__/clean-code/god-class.test.ts +111 -0
  94. package/principles/rules/__tests__/clean-code/large-file.test.ts +73 -0
  95. package/principles/rules/__tests__/clean-code/long-function.test.ts +83 -0
  96. package/principles/rules/__tests__/clean-code/magic-numbers.test.ts +68 -0
  97. package/principles/rules/__tests__/clean-code/many-exports.test.ts +118 -0
  98. package/principles/rules/__tests__/clean-code/missing-error-handling.test.ts +173 -0
  99. package/principles/rules/__tests__/clean-code/too-many-params.test.ts +60 -0
  100. package/principles/rules/__tests__/clean-code/unused-imports.test.ts +107 -0
  101. package/principles/rules/__tests__/index.test.ts +49 -0
  102. package/principles/rules/__tests__/solid/dip.test.ts +121 -0
  103. package/principles/rules/__tests__/solid/isp.test.ts +60 -0
  104. package/principles/rules/__tests__/solid/lsp.test.ts +184 -0
  105. package/principles/rules/__tests__/solid/ocp.test.ts +69 -0
  106. package/principles/rules/__tests__/solid/srp.test.ts +89 -0
  107. package/principles/rules/clean-code/code-duplication.ts +31 -0
  108. package/principles/rules/clean-code/deep-nesting.ts +38 -0
  109. package/principles/rules/clean-code/god-class.ts +54 -0
  110. package/principles/rules/clean-code/large-file.ts +34 -0
  111. package/principles/rules/clean-code/long-function.ts +47 -0
  112. package/principles/rules/clean-code/magic-numbers.ts +59 -0
  113. package/principles/rules/clean-code/many-exports.ts +40 -0
  114. package/principles/rules/clean-code/missing-error-handling.ts +33 -0
  115. package/principles/rules/clean-code/too-many-params.ts +35 -0
  116. package/principles/rules/clean-code/unused-imports.ts +33 -0
  117. package/principles/rules/index.ts +61 -0
  118. package/principles/rules/solid/dip.ts +47 -0
  119. package/principles/rules/solid/isp.ts +37 -0
  120. package/principles/rules/solid/lsp.ts +79 -0
  121. package/principles/rules/solid/ocp.ts +40 -0
  122. package/principles/rules/solid/srp.ts +44 -0
  123. package/principles/types.ts +27 -0
  124. package/skills/delphi-review/AGENTS.md +7 -7
  125. package/skills/delphi-review/INSTALL.md +1 -1
  126. package/skills/delphi-review/SKILL.md +12 -12
  127. package/skills/delphi-review/references/code-walkthrough.md +1 -1
  128. package/skills/sprint-flow/AGENTS.md +7 -7
  129. package/skills/sprint-flow/SKILL.md +1 -1
  130. package/skills/sprint-flow/references/force-levels.md +1 -1
  131. package/skills/sprint-flow/references/phase-3-review.md +2 -2
  132. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -0,0 +1,535 @@
1
+ /**
2
+ * @test REQ-SCOPE-001 Scope Scanner
3
+ * @intent Verify project scope scanning classifies imports correctly
4
+ * @covers AC-SCOPE-01, AC-SCOPE-02, AC-SCOPE-03
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
8
+ import { mkdirSync, writeFileSync, rmSync } from 'fs';
9
+ import { join, resolve } from 'path';
10
+ import { tmpdir } from 'os';
11
+ import {
12
+ simpleGlobMatch,
13
+ isExternalImport,
14
+ resolveToRealPath,
15
+ loadExternalDependencies,
16
+ classifyDependency,
17
+ scanProjectScope,
18
+ } from '../scope-scanner';
19
+ import type { ScanOptions } from '../scope-scanner';
20
+ import type { ProjectScope } from '../types';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // simpleGlobMatch
24
+ // ---------------------------------------------------------------------------
25
+ describe('simpleGlobMatch', () => {
26
+ it('matches exact literal patterns', () => {
27
+ expect(simpleGlobMatch('src/index.ts', 'src/index.ts')).toBe(true);
28
+ });
29
+
30
+ it('rejects non-matching literal patterns', () => {
31
+ expect(simpleGlobMatch('src/index.ts', 'src/foo.ts')).toBe(false);
32
+ });
33
+
34
+ it('matches ** (globstar) across path separators', () => {
35
+ expect(simpleGlobMatch('src/**/*.ts', 'src/a/b/c.ts')).toBe(true);
36
+ });
37
+
38
+ it('matches * (single-star) within a single path segment', () => {
39
+ expect(simpleGlobMatch('src/*.ts', 'src/index.ts')).toBe(true);
40
+ });
41
+
42
+ it('rejects * crossing path separators', () => {
43
+ expect(simpleGlobMatch('src/*.ts', 'src/a/b.ts')).toBe(false);
44
+ });
45
+
46
+ it('matches ** at the root', () => {
47
+ expect(simpleGlobMatch('**/*.ts', 'some/deep/file.ts')).toBe(true);
48
+ });
49
+
50
+ it('handles leading **/ patterns', () => {
51
+ expect(simpleGlobMatch('**/foo', 'a/b/c/foo')).toBe(true);
52
+ });
53
+
54
+ it('handles trailing /** patterns', () => {
55
+ expect(simpleGlobMatch('src/**', 'src/a/b/c')).toBe(true);
56
+ });
57
+
58
+ it('handles dots in filenames correctly', () => {
59
+ expect(simpleGlobMatch('*.test.ts', 'foo.test.ts')).toBe(true);
60
+ expect(simpleGlobMatch('*.test.ts', 'footest.ts')).toBe(false);
61
+ });
62
+
63
+ it('matches ? as a single non-slash character', () => {
64
+ expect(simpleGlobMatch('src/?.ts', 'src/a.ts')).toBe(true);
65
+ expect(simpleGlobMatch('src/?.ts', 'src/ab.ts')).toBe(false);
66
+ });
67
+
68
+ it('escapes regex special characters in patterns', () => {
69
+ expect(simpleGlobMatch('src/file+[test].ts', 'src/file+[test].ts')).toBe(true);
70
+ expect(simpleGlobMatch('src/file+[test].ts', 'src/fileXtest.ts')).toBe(false);
71
+ });
72
+
73
+ it('returns false for invalid regex patterns gracefully', () => {
74
+ expect(simpleGlobMatch('src/[invalid', 'src/foo')).toBe(false);
75
+ });
76
+
77
+ it('handles mixed character types in sequence', () => {
78
+ expect(simpleGlobMatch('src/**/test.?.*', 'src/a/b/test.c.ts')).toBe(true);
79
+ expect(simpleGlobMatch('src/**/test.?.*', 'src/sub/test.c.ts')).toBe(true);
80
+ });
81
+
82
+ it('handles regex special chars (+^${}()|[]) in patterns', () => {
83
+ expect(simpleGlobMatch('src/file+test', 'src/file+test')).toBe(true);
84
+ expect(simpleGlobMatch('src/file^test', 'src/file^test')).toBe(true);
85
+ expect(simpleGlobMatch('src/file(test)', 'src/file(test)')).toBe(true);
86
+ expect(simpleGlobMatch('src/file{test}', 'src/file{test}')).toBe(true);
87
+ expect(simpleGlobMatch('src/file|test', 'src/file|test')).toBe(true);
88
+ });
89
+ });
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // isExternalImport
93
+ // ---------------------------------------------------------------------------
94
+ describe('isExternalImport', () => {
95
+ const defaultOptions: ScanOptions = {
96
+ projectRoot: '/fake/project',
97
+ imports: [],
98
+ boundary: ['@/components/**', '@/utils/**'],
99
+ };
100
+
101
+ it('returns true for Node.js builtins', () => {
102
+ expect(isExternalImport('fs', defaultOptions)).toBe(true);
103
+ expect(isExternalImport('path', defaultOptions)).toBe(true);
104
+ expect(isExternalImport('os', defaultOptions)).toBe(true);
105
+ expect(isExternalImport('http', defaultOptions)).toBe(true);
106
+ expect(isExternalImport('https', defaultOptions)).toBe(true);
107
+ expect(isExternalImport('crypto', defaultOptions)).toBe(true);
108
+ expect(isExternalImport('stream', defaultOptions)).toBe(true);
109
+ expect(isExternalImport('events', defaultOptions)).toBe(true);
110
+ expect(isExternalImport('util', defaultOptions)).toBe(true);
111
+ expect(isExternalImport('url', defaultOptions)).toBe(true);
112
+ expect(isExternalImport('querystring', defaultOptions)).toBe(true);
113
+ expect(isExternalImport('assert', defaultOptions)).toBe(true);
114
+ expect(isExternalImport('buffer', defaultOptions)).toBe(true);
115
+ expect(isExternalImport('child_process', defaultOptions)).toBe(true);
116
+ expect(isExternalImport('cluster', defaultOptions)).toBe(true);
117
+ expect(isExternalImport('dns', defaultOptions)).toBe(true);
118
+ expect(isExternalImport('net', defaultOptions)).toBe(true);
119
+ expect(isExternalImport('tls', defaultOptions)).toBe(true);
120
+ expect(isExternalImport('readline', defaultOptions)).toBe(true);
121
+ expect(isExternalImport('process', defaultOptions)).toBe(true);
122
+ expect(isExternalImport('v8', defaultOptions)).toBe(true);
123
+ expect(isExternalImport('vm', defaultOptions)).toBe(true);
124
+ expect(isExternalImport('zlib', defaultOptions)).toBe(true);
125
+ });
126
+
127
+ it('returns true for node: prefix builtins', () => {
128
+ expect(isExternalImport('node:fs', defaultOptions)).toBe(true);
129
+ expect(isExternalImport('node:path', defaultOptions)).toBe(true);
130
+ expect(isExternalImport('node:crypto', defaultOptions)).toBe(true);
131
+ });
132
+
133
+ it('returns true for bare npm packages', () => {
134
+ expect(isExternalImport('lodash', defaultOptions)).toBe(true);
135
+ expect(isExternalImport('express', defaultOptions)).toBe(true);
136
+ expect(isExternalImport('react', defaultOptions)).toBe(true);
137
+ expect(isExternalImport('@scope/package', defaultOptions)).toBe(true);
138
+ });
139
+
140
+ it('returns false for relative imports starting with .', () => {
141
+ expect(isExternalImport('./foo', defaultOptions)).toBe(false);
142
+ expect(isExternalImport('../bar', defaultOptions)).toBe(false);
143
+ expect(isExternalImport('./utils/helper', defaultOptions)).toBe(false);
144
+ });
145
+
146
+ it('returns false for @/ alias imports', () => {
147
+ expect(isExternalImport('@/components/Button', defaultOptions)).toBe(false);
148
+ expect(isExternalImport('@/utils/format', defaultOptions)).toBe(false);
149
+ });
150
+
151
+ it('returns false for absolute imports matching boundary', () => {
152
+ const boundaryOptions: ScanOptions = {
153
+ ...defaultOptions,
154
+ boundary: ['@/components/**', '@/utils/**', 'src/modules/**'],
155
+ };
156
+ expect(isExternalImport('src/modules/auth', boundaryOptions)).toBe(false);
157
+ });
158
+
159
+ it('returns false for absolute imports starting with /', () => {
160
+ expect(isExternalImport('/opt/node/foo', defaultOptions)).toBe(false);
161
+ });
162
+
163
+ it('returns true for bare imports not matching boundary', () => {
164
+ const strictOptions: ScanOptions = {
165
+ ...defaultOptions,
166
+ boundary: ['src/components/**'],
167
+ };
168
+ expect(isExternalImport('src/utils/helper', strictOptions)).toBe(true);
169
+ });
170
+ });
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // resolveToRealPath
174
+ // ---------------------------------------------------------------------------
175
+ describe('resolveToRealPath', () => {
176
+ it('replaces @/ prefix with src/', () => {
177
+ const result = resolveToRealPath('@/components/Button', '/project');
178
+ expect(result).toBe(resolve('/project', 'src/components/Button'));
179
+ });
180
+
181
+ it('preserves paths without @/ alias', () => {
182
+ const result = resolveToRealPath('./foo/bar', '/project');
183
+ // resolve normalizes ./ away
184
+ expect(result).toBe(resolve('/project', 'foo/bar'));
185
+ });
186
+
187
+ it('resolves absolute paths correctly', () => {
188
+ const result = resolveToRealPath('/absolute/path', '/project');
189
+ // Absolute paths remain absolute (resolve treats them as-is)
190
+ expect(result).toBe(resolve('/absolute/path'));
191
+ });
192
+ });
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // loadExternalDependencies
196
+ // ---------------------------------------------------------------------------
197
+ describe('loadExternalDependencies', () => {
198
+ let tmpDir: string;
199
+
200
+ beforeEach(() => {
201
+ tmpDir = join(tmpdir(), `scope-scanner-test-${Date.now()}`);
202
+ mkdirSync(tmpDir, { recursive: true });
203
+ });
204
+
205
+ afterEach(() => {
206
+ rmSync(tmpDir, { recursive: true, force: true });
207
+ });
208
+
209
+ it('returns empty array when no package.json', async () => {
210
+ const deps = await loadExternalDependencies(tmpDir);
211
+ expect(deps).toEqual([]);
212
+ });
213
+
214
+ it('reads dependencies from package.json', async () => {
215
+ writeFileSync(
216
+ join(tmpDir, 'package.json'),
217
+ JSON.stringify({
218
+ dependencies: {
219
+ react: '^18.0.0',
220
+ lodash: '^4.17.21',
221
+ },
222
+ }),
223
+ );
224
+ const deps = await loadExternalDependencies(tmpDir);
225
+ expect(deps).toEqual(['lodash', 'react']);
226
+ });
227
+
228
+ it('includes devDependencies and peerDependencies', async () => {
229
+ writeFileSync(
230
+ join(tmpDir, 'package.json'),
231
+ JSON.stringify({
232
+ dependencies: { react: '^18.0.0' },
233
+ devDependencies: { vitest: '^1.0.0' },
234
+ peerDependencies: { react: '^18.0.0' },
235
+ }),
236
+ );
237
+ const deps = await loadExternalDependencies(tmpDir);
238
+ expect(deps).toEqual(['react', 'vitest']);
239
+ });
240
+
241
+ it('skips workspace:* protocol packages', async () => {
242
+ writeFileSync(
243
+ join(tmpDir, 'package.json'),
244
+ JSON.stringify({
245
+ dependencies: {
246
+ react: '^18.0.0',
247
+ 'my-lib': 'workspace:*',
248
+ 'other-lib': 'workspace:^1.0.0',
249
+ },
250
+ }),
251
+ );
252
+ const deps = await loadExternalDependencies(tmpDir);
253
+ expect(deps).toEqual(['react']);
254
+ });
255
+
256
+ it('handles invalid package.json gracefully', async () => {
257
+ writeFileSync(join(tmpDir, 'package.json'), 'not-json');
258
+ const deps = await loadExternalDependencies(tmpDir);
259
+ expect(deps).toEqual([]);
260
+ });
261
+
262
+ it('handles empty package.json gracefully', async () => {
263
+ writeFileSync(join(tmpDir, 'package.json'), '{}');
264
+ const deps = await loadExternalDependencies(tmpDir);
265
+ expect(deps).toEqual([]);
266
+ });
267
+
268
+ it('returns sorted package names', async () => {
269
+ writeFileSync(
270
+ join(tmpDir, 'package.json'),
271
+ JSON.stringify({
272
+ dependencies: {
273
+ zod: '^3.0.0',
274
+ axios: '^1.0.0',
275
+ },
276
+ }),
277
+ );
278
+ const deps = await loadExternalDependencies(tmpDir);
279
+ expect(deps).toEqual(['axios', 'zod']);
280
+ });
281
+ });
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // classifyDependency
285
+ // ---------------------------------------------------------------------------
286
+ describe('classifyDependency', () => {
287
+ const baseScope: ProjectScope = {
288
+ implementedModules: ['@/components/Button', '@/utils/format'],
289
+ unimplementedModules: ['@/components/FutureComponent', '@/utils/pendingUtil'],
290
+ externalPackages: ['react', 'lodash', '@scope/package'],
291
+ projectBoundary: ['@/components/**', '@/utils/**'],
292
+ };
293
+
294
+ const baseOptions: ScanOptions = {
295
+ projectRoot: '/fake/project',
296
+ imports: [],
297
+ boundary: ['@/components/**', '@/utils/**'],
298
+ };
299
+
300
+ it('classifies implemented modules as internal', () => {
301
+ expect(classifyDependency('@/components/Button', baseScope, baseOptions)).toBe('internal');
302
+ expect(classifyDependency('@/utils/format', baseScope, baseOptions)).toBe('internal');
303
+ });
304
+
305
+ it('classifies unimplemented modules as pending', () => {
306
+ expect(classifyDependency('@/components/FutureComponent', baseScope, baseOptions)).toBe('pending');
307
+ expect(classifyDependency('@/utils/pendingUtil', baseScope, baseOptions)).toBe('pending');
308
+ });
309
+
310
+ it('classifies known external packages as external', () => {
311
+ expect(classifyDependency('react', baseScope, baseOptions)).toBe('external');
312
+ expect(classifyDependency('lodash', baseScope, baseOptions)).toBe('external');
313
+ });
314
+
315
+ it('classifies scoped external packages as external', () => {
316
+ expect(classifyDependency('@scope/package', baseScope, baseOptions)).toBe('external');
317
+ });
318
+
319
+ it('classifies sub-path of external packages as external', () => {
320
+ expect(classifyDependency('react/dom', baseScope, baseOptions)).toBe('external');
321
+ expect(classifyDependency('lodash/map', baseScope, baseOptions)).toBe('external');
322
+ });
323
+
324
+ it('returns external for Node.js builtins', () => {
325
+ expect(classifyDependency('fs', baseScope, baseOptions)).toBe('external');
326
+ expect(classifyDependency('path', baseScope, baseOptions)).toBe('external');
327
+ });
328
+
329
+ it('uses existCache when provided', () => {
330
+ const cache = new Map<string, boolean>();
331
+ // Cache key must match the resolved path that classifyDependency computes internally
332
+ cache.set(resolveToRealPath('@/foo', '/fake/project'), true);
333
+
334
+ const result = classifyDependency('@/foo', baseScope, {
335
+ ...baseOptions,
336
+ boundary: ['@/**'],
337
+ }, cache);
338
+
339
+ expect(result).toBe('internal');
340
+ });
341
+
342
+ it('classifies non-existent boundary path as pending', () => {
343
+ const cache = new Map<string, boolean>();
344
+ cache.set('/fake/project/src/missing/helper', false);
345
+
346
+ const result = classifyDependency('@/missing/helper', baseScope, {
347
+ ...baseOptions,
348
+ boundary: ['@/missing/**'],
349
+ }, cache);
350
+
351
+ expect(result).toBe('pending');
352
+ });
353
+
354
+ it('classifies unknown bare import as external', () => {
355
+ expect(classifyDependency('unknown-pkg', baseScope, baseOptions)).toBe('external');
356
+ });
357
+ });
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // scanProjectScope (integration-level tests with real tmp dir)
361
+ // ---------------------------------------------------------------------------
362
+ describe('scanProjectScope', () => {
363
+ let tmpDir: string;
364
+
365
+ beforeEach(() => {
366
+ tmpDir = join(tmpdir(), `scope-scanner-integration-${Date.now()}`);
367
+ mkdirSync(tmpDir, { recursive: true });
368
+
369
+ // Create a real project structure
370
+ mkdirSync(join(tmpDir, 'src'), { recursive: true });
371
+ mkdirSync(join(tmpDir, 'src', 'components'), { recursive: true });
372
+ mkdirSync(join(tmpDir, 'src', 'utils'), { recursive: true });
373
+
374
+ // Create some files that exist
375
+ writeFileSync(join(tmpDir, 'src', 'components', 'Button.ts'), '');
376
+ writeFileSync(join(tmpDir, 'src', 'utils', 'format.ts'), '');
377
+ writeFileSync(join(tmpDir, 'src', 'index.ts'), '');
378
+
379
+ // package.json with some dependencies
380
+ writeFileSync(
381
+ join(tmpDir, 'package.json'),
382
+ JSON.stringify({
383
+ dependencies: {
384
+ react: '^18.0.0',
385
+ lodash: '^4.17.21',
386
+ },
387
+ devDependencies: {
388
+ vitest: '^1.0.0',
389
+ },
390
+ }),
391
+ );
392
+ });
393
+
394
+ afterEach(() => {
395
+ rmSync(tmpDir, { recursive: true, force: true });
396
+ });
397
+
398
+ it('classifies existing src files as implemented', async () => {
399
+ const result = await scanProjectScope({
400
+ projectRoot: tmpDir,
401
+ imports: ['./src/components/Button.ts', './src/utils/format.ts'],
402
+ boundary: [],
403
+ });
404
+
405
+ expect(result.implementedModules).toContain('./src/components/Button.ts');
406
+ expect(result.implementedModules).toContain('./src/utils/format.ts');
407
+ expect(result.unimplementedModules).toEqual([]);
408
+ });
409
+
410
+ it('classifies non-existent files as unimplemented', async () => {
411
+ const result = await scanProjectScope({
412
+ projectRoot: tmpDir,
413
+ imports: ['./src/missing.ts'],
414
+ boundary: [],
415
+ });
416
+
417
+ expect(result.implementedModules).toEqual([]);
418
+ expect(result.unimplementedModules).toContain('./src/missing.ts');
419
+ });
420
+
421
+ it('classifies external npm packages', async () => {
422
+ const result = await scanProjectScope({
423
+ projectRoot: tmpDir,
424
+ imports: ['react', 'lodash', 'vitest'],
425
+ boundary: [],
426
+ });
427
+
428
+ expect(result.externalPackages).toContain('react');
429
+ expect(result.externalPackages).toContain('lodash');
430
+ expect(result.externalPackages).toContain('vitest');
431
+ });
432
+
433
+ it('classifies Node.js builtins as external', async () => {
434
+ const result = await scanProjectScope({
435
+ projectRoot: tmpDir,
436
+ imports: ['fs', 'path', 'os'],
437
+ boundary: [],
438
+ });
439
+
440
+ expect(result.externalPackages).toContain('fs');
441
+ expect(result.externalPackages).toContain('path');
442
+ expect(result.externalPackages).toContain('os');
443
+ });
444
+
445
+ it('handles @/ alias resolution', async () => {
446
+ const result = await scanProjectScope({
447
+ projectRoot: tmpDir,
448
+ imports: ['@/components/Button.ts', '@/utils/format.ts'],
449
+ boundary: [],
450
+ });
451
+
452
+ expect(result.implementedModules).toContain('@/components/Button.ts');
453
+ expect(result.implementedModules).toContain('@/utils/format.ts');
454
+ });
455
+
456
+ it('classifies boundary-matching imports as implemented', async () => {
457
+ const result = await scanProjectScope({
458
+ projectRoot: tmpDir,
459
+ imports: ['@/components/Button.ts'],
460
+ boundary: ['@/components/**'],
461
+ });
462
+
463
+ expect(result.implementedModules).toContain('@/components/Button.ts');
464
+ });
465
+
466
+ it('removes duplicate entries from results', async () => {
467
+ const result = await scanProjectScope({
468
+ projectRoot: tmpDir,
469
+ imports: ['./src/index.ts', './src/index.ts', './src/index.ts'],
470
+ boundary: [],
471
+ });
472
+
473
+ expect(result.implementedModules).toEqual(['./src/index.ts']);
474
+ });
475
+
476
+ it('stores projectBoundary in result', async () => {
477
+ const boundary = ['@/components/**', '@/utils/**'];
478
+ const result = await scanProjectScope({
479
+ projectRoot: tmpDir,
480
+ imports: [],
481
+ boundary,
482
+ });
483
+
484
+ expect(result.projectBoundary).toEqual(boundary);
485
+ });
486
+
487
+ it('classifies mixed imports correctly', async () => {
488
+ const result = await scanProjectScope({
489
+ projectRoot: tmpDir,
490
+ imports: [
491
+ // External
492
+ 'react',
493
+ 'lodash',
494
+ 'fs',
495
+ // Implemented
496
+ './src/components/Button.ts',
497
+ '@/utils/format.ts',
498
+ // Unimplemented
499
+ './src/missing.ts',
500
+ '@/components/NonExistent.ts',
501
+ ],
502
+ boundary: ['@/components/**', '@/utils/**'],
503
+ });
504
+
505
+ expect(result.externalPackages).toContain('react');
506
+ expect(result.externalPackages).toContain('lodash');
507
+ expect(result.externalPackages).toContain('fs');
508
+
509
+ expect(result.implementedModules).toContain('./src/components/Button.ts');
510
+ expect(result.implementedModules).toContain('@/utils/format.ts');
511
+
512
+ expect(result.unimplementedModules).toContain('./src/missing.ts');
513
+ expect(result.unimplementedModules).toContain('@/components/NonExistent.ts');
514
+ });
515
+
516
+ it('handles @scope/npm-package bare imports', async () => {
517
+ // Add a scoped package to package.json
518
+ writeFileSync(
519
+ join(tmpDir, 'package.json'),
520
+ JSON.stringify({
521
+ dependencies: {
522
+ '@scope/package': '^1.0.0',
523
+ },
524
+ }),
525
+ );
526
+
527
+ const result = await scanProjectScope({
528
+ projectRoot: tmpDir,
529
+ imports: ['@scope/package'],
530
+ boundary: [],
531
+ });
532
+
533
+ expect(result.externalPackages).toContain('@scope/package');
534
+ });
535
+ });
@@ -0,0 +1,106 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { existsSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { load } from 'js-yaml';
5
+ import { MockPolicyConfigSchema } from './schema';
6
+ import type { z } from 'zod';
7
+
8
+ type MockPolicyConfig = z.infer<typeof MockPolicyConfigSchema>;
9
+
10
+ export const DEFAULT_CONFIG: MockPolicyConfig = {
11
+ version: 1,
12
+ layers: {
13
+ unit: {
14
+ mockPolicy: 'lenient',
15
+ requireRealForImplemented: false,
16
+ allowExternalMock: true,
17
+ requirePendingRemoval: false,
18
+ maxMockDensity: 100,
19
+ },
20
+ integration: {
21
+ mockPolicy: 'strict',
22
+ requireRealForImplemented: true,
23
+ allowExternalMock: true,
24
+ requirePendingRemoval: true,
25
+ maxMockDensity: 30,
26
+ },
27
+ e2e: {
28
+ mockPolicy: 'strict',
29
+ requireRealForImplemented: true,
30
+ allowExternalMock: false,
31
+ requirePendingRemoval: false,
32
+ maxMockDensity: 0,
33
+ },
34
+ },
35
+ projectBoundary: ['src/**'],
36
+ severity: 'warning',
37
+ };
38
+
39
+ function findConfigPaths(root: string): { jsonPath: string; yamlPath: string } {
40
+ return {
41
+ jsonPath: join(root, '.mockpolicyrc'),
42
+ yamlPath: join(root, '.xp-gate', 'mock-policy.yaml'),
43
+ };
44
+ }
45
+
46
+ async function readJsonConfig(path: string): Promise<Record<string, unknown>> {
47
+ const content = await readFile(path, 'utf-8');
48
+ return JSON.parse(content) as Record<string, unknown>;
49
+ }
50
+
51
+ async function readYamlConfig(path: string): Promise<Record<string, unknown>> {
52
+ const content = await readFile(path, 'utf-8');
53
+ return load(content) as Record<string, unknown>;
54
+ }
55
+
56
+ function deepMerge(
57
+ defaults: MockPolicyConfig,
58
+ overrides: Partial<MockPolicyConfig>,
59
+ ): MockPolicyConfig {
60
+ return {
61
+ ...defaults,
62
+ ...overrides,
63
+ layers: {
64
+ unit: { ...defaults.layers.unit, ...overrides.layers?.unit },
65
+ integration: { ...defaults.layers.integration, ...overrides.layers?.integration },
66
+ e2e: { ...defaults.layers.e2e, ...overrides.layers?.e2e },
67
+ },
68
+ projectBoundary: overrides.projectBoundary ?? defaults.projectBoundary,
69
+ severity: overrides.severity ?? defaults.severity,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Load mock policy configuration from the project root.
75
+ *
76
+ * Resolution order:
77
+ * 1. `.mockpolicyrc` (JSON) — highest priority
78
+ * 2. `.xp-gate/mock-policy.yaml` (YAML) — fallback
79
+ * 3. Falls back to `DEFAULT_CONFIG`
80
+ *
81
+ * @param root - Project root directory (defaults to `process.cwd()`)
82
+ * @returns Validated MockPolicyConfig
83
+ * @throws {Error} If the config file exists but the content fails validation
84
+ */
85
+ export async function loadMockPolicyConfig(
86
+ root?: string,
87
+ ): Promise<MockPolicyConfig> {
88
+ const projectRoot = root ?? process.cwd();
89
+ const { jsonPath, yamlPath } = findConfigPaths(projectRoot);
90
+
91
+ if (existsSync(jsonPath)) {
92
+ const raw = await readJsonConfig(jsonPath);
93
+ // Validate the raw user config before merging so that invalid values
94
+ // are caught regardless of defaults.
95
+ MockPolicyConfigSchema.parse(deepMerge(DEFAULT_CONFIG, raw));
96
+ return deepMerge(DEFAULT_CONFIG, raw);
97
+ }
98
+
99
+ if (existsSync(yamlPath)) {
100
+ const raw = await readYamlConfig(yamlPath);
101
+ MockPolicyConfigSchema.parse(deepMerge(DEFAULT_CONFIG, raw));
102
+ return deepMerge(DEFAULT_CONFIG, raw);
103
+ }
104
+
105
+ return DEFAULT_CONFIG;
106
+ }