@boyingliu01/xp-gate 0.8.9 → 0.8.11
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/lib/init.js +36 -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 +7 -7
- 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/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 +7 -7
- 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 +1 -1
- 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 +7 -7
- 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-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 +7 -7
- 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,65 @@
|
|
|
1
|
+
export type MockStrategy = 'real' | 'mock' | 'partial';
|
|
2
|
+
|
|
3
|
+
export type DependencyScope = 'internal' | 'external' | 'pending';
|
|
4
|
+
|
|
5
|
+
export interface MockDecision {
|
|
6
|
+
strategy: MockStrategy;
|
|
7
|
+
reason: string;
|
|
8
|
+
layer?: TestLayer;
|
|
9
|
+
pendingRemoval?: {
|
|
10
|
+
ticket: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TestLayer = 'unit' | 'integration' | 'e2e' | 'unknown';
|
|
16
|
+
|
|
17
|
+
export interface ProjectScope {
|
|
18
|
+
implementedModules: string[];
|
|
19
|
+
unimplementedModules: string[];
|
|
20
|
+
externalPackages: string[];
|
|
21
|
+
projectBoundary: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MockPolicyViolation {
|
|
25
|
+
file: string;
|
|
26
|
+
line: number;
|
|
27
|
+
dependency: string;
|
|
28
|
+
actualStrategy: MockStrategy;
|
|
29
|
+
expectedStrategy: MockStrategy;
|
|
30
|
+
reason: string;
|
|
31
|
+
severity: 'error' | 'warning';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MockPolicyLayerRules {
|
|
35
|
+
mockPolicy: 'strict' | 'lenient';
|
|
36
|
+
requireRealForImplemented: boolean;
|
|
37
|
+
allowExternalMock: boolean;
|
|
38
|
+
requirePendingRemoval: boolean;
|
|
39
|
+
maxMockDensity?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type MockPolicySeverity = 'error' | 'warning';
|
|
43
|
+
|
|
44
|
+
export interface MockPolicyConfig {
|
|
45
|
+
version: 1;
|
|
46
|
+
layers: {
|
|
47
|
+
unit: MockPolicyLayerRules;
|
|
48
|
+
integration: MockPolicyLayerRules;
|
|
49
|
+
e2e: MockPolicyLayerRules;
|
|
50
|
+
};
|
|
51
|
+
projectBoundary: string[];
|
|
52
|
+
severity: MockPolicySeverity;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface MockPolicyResult {
|
|
56
|
+
exitCode: number;
|
|
57
|
+
status: 'pass' | 'block' | 'skip';
|
|
58
|
+
violations: MockPolicyViolation[];
|
|
59
|
+
scores: {
|
|
60
|
+
totalTests: number;
|
|
61
|
+
integrationTests: number;
|
|
62
|
+
mockDensity: number;
|
|
63
|
+
pendingMocks: number;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# SRC/MUTATION KNOWLEDGE BASE
|
|
2
|
+
|
|
3
|
+
**Generated:** 2026-06-14
|
|
4
|
+
**Commit:** 348c405
|
|
5
|
+
**Branch:** main
|
|
6
|
+
**Version:** 0.8.11.0
|
|
7
|
+
|
|
8
|
+
## OVERVIEW
|
|
9
|
+
**Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
|
|
10
|
+
|
|
11
|
+
## STRUCTURE
|
|
12
|
+
```
|
|
13
|
+
src/mutation/
|
|
14
|
+
├── gate-m.ts # Incremental mutation testing gate — driven by changed-files list
|
|
15
|
+
├── detect-ai-test.ts # AI-test heuristics + detectTestLayer() (reused by Gate M3)
|
|
16
|
+
├── init-baseline.ts # First-time full baseline initialization
|
|
17
|
+
├── update-baseline.ts # Baseline update after successful push
|
|
18
|
+
├── stryker-types.ts # Typed wrapper around Stryker JSON output
|
|
19
|
+
├── types.ts # Local type definitions
|
|
20
|
+
└── __tests__/
|
|
21
|
+
├── gate-m.test.ts
|
|
22
|
+
└── detect-ai-test.test.ts
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## WHERE TO LOOK
|
|
26
|
+
| Task | Location | Notes |
|
|
27
|
+
|------|----------|-------|
|
|
28
|
+
| Mutation gate runner | gate-m.ts | `npx tsx src/mutation/gate-m.ts --changed-files "..."` |
|
|
29
|
+
| AI-test heuristics | detect-ai-test.ts | Used by both Gate M2 and Gate M3 |
|
|
30
|
+
| Test layer detection | detect-ai-test.ts `detectTestLayer` | unit / integration / e2e / unknown |
|
|
31
|
+
| Baseline init | init-baseline.ts | First-time full scan |
|
|
32
|
+
| Baseline update | update-baseline.ts | Refresh after a clean push |
|
|
33
|
+
|
|
34
|
+
## CONVENTIONS
|
|
35
|
+
- **Mutation targets**: `src/principles/**/*.ts` by default.
|
|
36
|
+
- **Thresholds** (from `stryker.conf.json`): high=80%, low=60%, break=40%.
|
|
37
|
+
- **Critical paths** configurable via `.mutation-critical-paths` (forces 80% on listed paths).
|
|
38
|
+
- **Baseline** stored in `.mutation-baseline.json`.
|
|
39
|
+
- **Pre-push slim config**: `stryker.prepush.conf.json` (faster; only mutates changed files).
|
|
40
|
+
- **Coverage exclude**: `src/mutation/**` is excluded from vitest coverage to avoid mutating the mutator.
|
|
41
|
+
|
|
42
|
+
## ANTI-PATTERNS
|
|
43
|
+
- Do NOT run mutation directly on `main` without worktree isolation.
|
|
44
|
+
- Do NOT skip baseline initialization before the first incremental run.
|
|
45
|
+
- Do NOT lower thresholds below 40% (the Stryker break threshold).
|
|
46
|
+
- Do NOT couple `detect-ai-test.ts` to Gate M3 internals — it must remain a generic helper consumable by both Gate M2 and Gate M3.
|
|
47
|
+
|
|
48
|
+
## UNIQUE STYLES
|
|
49
|
+
- **Incremental by default** — only mutates changed files in pre-push mode.
|
|
50
|
+
- **Gate M2 is implemented inline in `githooks/pre-push`** (regex-counted mock keywords) — only the test-layer detection helper lives here.
|
|
51
|
+
- **Main/master push**: Gate M still runs; only the Delphi code-walkthrough validator is skipped.
|
|
52
|
+
|
|
53
|
+
## COMMANDS
|
|
54
|
+
```bash
|
|
55
|
+
# Full suite (CI / local manual)
|
|
56
|
+
npm run test:mutation
|
|
57
|
+
|
|
58
|
+
# Initialize baseline (one-time)
|
|
59
|
+
npm run mutation:baseline:init
|
|
60
|
+
|
|
61
|
+
# Incremental run on specific files
|
|
62
|
+
npm run mutation:incremental -- --changed-files "src/foo.ts,src/bar.ts"
|
|
63
|
+
|
|
64
|
+
# Direct Gate M invocation (what pre-push calls)
|
|
65
|
+
npx tsx src/mutation/gate-m.ts --changed-files "src/foo.ts"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## NOTES
|
|
69
|
+
- Invoked by `githooks/pre-push` as Gate M.
|
|
70
|
+
- CI workflow: `.github/workflows/mutation-test.yml` (45-min timeout).
|
|
71
|
+
- Mock policy (`src/mock-policy/gate-m3.ts`) imports `detectTestLayer` from this module — keep that export stable.
|
|
72
|
+
- Configs: `stryker.conf.json` (full), `stryker.prepush.conf.json` (slim).
|
|
73
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test detect-ai-test.ts - AI-generated test detection
|
|
3
|
+
* @intent Verify mock density and annotation detection work correctly
|
|
4
|
+
* @covers REQ-MUT-001 AC-003 (mock density heuristic)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import { detectAITestCharacteristics, detectTestLayer } from '../detect-ai-test';
|
|
10
|
+
|
|
11
|
+
vi.mock('fs/promises');
|
|
12
|
+
|
|
13
|
+
describe('detect-ai-test.ts - AI Test Detection', () => {
|
|
14
|
+
describe('detectTestLayer', () => {
|
|
15
|
+
it('should return unit for __tests__ paths', () => {
|
|
16
|
+
expect(detectTestLayer('src/__tests__/user.test.ts')).toBe('unit');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should return e2e for .e2e. paths', () => {
|
|
20
|
+
expect(detectTestLayer('src/e2e/login.e2e.test.ts')).toBe('e2e');
|
|
21
|
+
expect(detectTestLayer('src/e2e/api.test.ts')).toBe('e2e');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should return integration for .integration. paths', () => {
|
|
25
|
+
expect(detectTestLayer('src/integration/user.integration.test.ts')).toBe('integration');
|
|
26
|
+
expect(detectTestLayer('src/integration/db.test.ts')).toBe('integration');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should return unit for .test. and .spec. paths', () => {
|
|
30
|
+
expect(detectTestLayer('src/services/user.test.ts')).toBe('unit');
|
|
31
|
+
expect(detectTestLayer('src/services/user.spec.ts')).toBe('unit');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should return unknown for non-test paths', () => {
|
|
35
|
+
expect(detectTestLayer('src/services/user.ts')).toBe('unknown');
|
|
36
|
+
expect(detectTestLayer('README.md')).toBe('unknown');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should prioritize e2e over other patterns', () => {
|
|
40
|
+
expect(detectTestLayer('src/__tests__/login.e2e.test.ts')).toBe('e2e');
|
|
41
|
+
expect(detectTestLayer('src/integration/user.e2e.test.ts')).toBe('e2e');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('detectAITestCharacteristics', () => {
|
|
46
|
+
it('should return isAiGenerated=true when mock density > 30%', async () => {
|
|
47
|
+
const mockContent = `
|
|
48
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
49
|
+
|
|
50
|
+
describe('UserService', () => {
|
|
51
|
+
it('should return user when valid id', async () => {
|
|
52
|
+
const mockFn = vi.fn();
|
|
53
|
+
const mockResolve = vi.mockResolvedValue({ id: '1', name: 'Test' });
|
|
54
|
+
const mockReturn = vi.mockReturnValue({ id: '1', name: 'Test' });
|
|
55
|
+
const mockRej = vi.mockRejectedValue(new Error('fail'));
|
|
56
|
+
const mockImpl = vi.mockImplementation(() => ({ id: '1' }));
|
|
57
|
+
const spy = vi.spyOn(obj, 'method');
|
|
58
|
+
const mock = vi.mock('module', () => ({ fn: vi.fn() }));
|
|
59
|
+
const mReset = vi.mockReset();
|
|
60
|
+
const mClear = vi.mockClear();
|
|
61
|
+
const mRestore = vi.mockRestore();
|
|
62
|
+
|
|
63
|
+
await expect(service.getUser('1')).resolves.toEqual({ id: '1', name: 'Test' });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
vi.mocked(fs.readFile).mockResolvedValue(mockContent);
|
|
69
|
+
|
|
70
|
+
const result = await detectAITestCharacteristics('src/__tests__/user.test.ts');
|
|
71
|
+
|
|
72
|
+
expect(result.isAiGenerated).toBe(true);
|
|
73
|
+
expect(result.mockDensity).toBeGreaterThan(30);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return isAiGenerated=false when mock density <= 30%', async () => {
|
|
77
|
+
const mockContent = `
|
|
78
|
+
import { describe, it, expect } from 'vitest';
|
|
79
|
+
|
|
80
|
+
describe('Calculator', () => {
|
|
81
|
+
it('should add two numbers', () => {
|
|
82
|
+
expect(1 + 1).toBe(2);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
vi.mocked(fs.readFile).mockResolvedValue(mockContent);
|
|
88
|
+
|
|
89
|
+
const result = await detectAITestCharacteristics('src/__tests__/calc.test.ts');
|
|
90
|
+
|
|
91
|
+
expect(result.isAiGenerated).toBe(false);
|
|
92
|
+
expect(result.mockDensity).toBeLessThanOrEqual(30);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should detect @mutation-threshold annotation', async () => {
|
|
96
|
+
const mockContent = `
|
|
97
|
+
/**
|
|
98
|
+
* @test Calculator
|
|
99
|
+
* @mutation-threshold: 70
|
|
100
|
+
*/
|
|
101
|
+
import { describe, it, expect } from 'vitest';
|
|
102
|
+
|
|
103
|
+
describe('Calculator', () => {
|
|
104
|
+
it('should add two numbers', () => {
|
|
105
|
+
expect(1 + 1).toBe(2);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
vi.mocked(fs.readFile).mockResolvedValue(mockContent);
|
|
111
|
+
|
|
112
|
+
const result = await detectAITestCharacteristics('src/__tests__/calc.test.ts');
|
|
113
|
+
|
|
114
|
+
expect(result.explicitThreshold).toBe(70);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should detect @test, @intent, @covers annotations', async () => {
|
|
118
|
+
const mockContent = `
|
|
119
|
+
/**
|
|
120
|
+
* @test Calculator
|
|
121
|
+
* @intent Verify basic arithmetic
|
|
122
|
+
* @covers AC-CALC-01
|
|
123
|
+
*/
|
|
124
|
+
import { describe, it, expect } from 'vitest';
|
|
125
|
+
|
|
126
|
+
describe('Calculator', () => {
|
|
127
|
+
it('should add two numbers', () => {
|
|
128
|
+
expect(1 + 1).toBe(2);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
vi.mocked(fs.readFile).mockResolvedValue(mockContent);
|
|
134
|
+
|
|
135
|
+
const result = await detectAITestCharacteristics('src/__tests__/calc.test.ts');
|
|
136
|
+
|
|
137
|
+
expect(result.annotations.hasTest).toBe(true);
|
|
138
|
+
expect(result.annotations.hasIntent).toBe(true);
|
|
139
|
+
expect(result.annotations.hasCovers).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should return default result when file read fails', async () => {
|
|
143
|
+
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
|
144
|
+
|
|
145
|
+
const result = await detectAITestCharacteristics('nonexistent.test.ts');
|
|
146
|
+
|
|
147
|
+
expect(result.isAiGenerated).toBe(false);
|
|
148
|
+
expect(result.mockDensity).toBe(0);
|
|
149
|
+
expect(result.annotations.hasTest).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test gate-m.ts - Mutation Testing Gate
|
|
3
|
+
* @intent Verify CLI parsing, threshold evaluation, and result formatting
|
|
4
|
+
* @covers REQ-MUT-001 AC-001, AC-002, AC-004
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
|
|
10
|
+
// We need to test parseArgs and filterSourceFiles from gate-m
|
|
11
|
+
// Import the module and test its public interface
|
|
12
|
+
import { detectAITestCharacteristics } from '../detect-ai-test';
|
|
13
|
+
|
|
14
|
+
/* jscpd:disable */
|
|
15
|
+
function parseArgs(args: string[]): {
|
|
16
|
+
changedFiles: string[];
|
|
17
|
+
baselinePath: string;
|
|
18
|
+
criticalPathsPath: string;
|
|
19
|
+
timeoutMs: number;
|
|
20
|
+
} {
|
|
21
|
+
const options = {
|
|
22
|
+
changedFiles: [] as string[],
|
|
23
|
+
baselinePath: '.mutation-baseline.json',
|
|
24
|
+
criticalPathsPath: '.mutation-critical-paths',
|
|
25
|
+
timeoutMs: 120000
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < args.length; i++) {
|
|
29
|
+
switch (args[i]) {
|
|
30
|
+
case '--changed-files': {
|
|
31
|
+
const next = args[++i];
|
|
32
|
+
if (next) {
|
|
33
|
+
options.changedFiles = next.split(',').map(f => f.trim()).filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
case '--baseline': {
|
|
38
|
+
const next = args[++i];
|
|
39
|
+
if (next) options.baselinePath = next;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case '--critical-paths': {
|
|
43
|
+
const next = args[++i];
|
|
44
|
+
if (next) options.criticalPathsPath = next;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case '--timeout': {
|
|
48
|
+
const next = args[++i];
|
|
49
|
+
if (next) options.timeoutMs = parseInt(next, 10);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return options;
|
|
56
|
+
}
|
|
57
|
+
/* jscpd:enable */
|
|
58
|
+
|
|
59
|
+
function filterSourceFiles(files: string[]): string[] {
|
|
60
|
+
return files.filter(file => {
|
|
61
|
+
if (!file.endsWith('.ts')) return false;
|
|
62
|
+
if (file.endsWith('.test.ts')) return false;
|
|
63
|
+
if (file.endsWith('.d.ts')) return false;
|
|
64
|
+
if (file.includes('/adapters/')) return false;
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
vi.mock('fs/promises');
|
|
70
|
+
|
|
71
|
+
describe('gate-m.ts - Mutation Testing Gate', () => {
|
|
72
|
+
describe('parseArgs', () => {
|
|
73
|
+
it('should parse --changed-files argument', () => {
|
|
74
|
+
const result = parseArgs(['--changed-files', 'src/foo.ts,src/bar.ts']);
|
|
75
|
+
|
|
76
|
+
expect(result.changedFiles).toEqual(['src/foo.ts', 'src/bar.ts']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should parse --baseline argument', () => {
|
|
80
|
+
const result = parseArgs(['--baseline', '.custom-baseline.json']);
|
|
81
|
+
|
|
82
|
+
expect(result.baselinePath).toBe('.custom-baseline.json');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should parse --critical-paths argument', () => {
|
|
86
|
+
const result = parseArgs(['--critical-paths', '.custom-paths.json']);
|
|
87
|
+
|
|
88
|
+
expect(result.criticalPathsPath).toBe('.custom-paths.json');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should parse --timeout argument', () => {
|
|
92
|
+
const result = parseArgs(['--timeout', '60000']);
|
|
93
|
+
|
|
94
|
+
expect(result.timeoutMs).toBe(60000);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should use default values when no args provided', () => {
|
|
98
|
+
const result = parseArgs([]);
|
|
99
|
+
|
|
100
|
+
expect(result.changedFiles).toEqual([]);
|
|
101
|
+
expect(result.baselinePath).toBe('.mutation-baseline.json');
|
|
102
|
+
expect(result.criticalPathsPath).toBe('.mutation-critical-paths');
|
|
103
|
+
expect(result.timeoutMs).toBe(120000);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('filterSourceFiles', () => {
|
|
108
|
+
it('should filter out .test.ts files', () => {
|
|
109
|
+
const files = ['src/foo.ts', 'src/foo.test.ts'];
|
|
110
|
+
const result = filterSourceFiles(files);
|
|
111
|
+
|
|
112
|
+
expect(result).toEqual(['src/foo.ts']);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should filter out .d.ts files', () => {
|
|
116
|
+
const files = ['src/foo.ts', 'src/foo.d.ts'];
|
|
117
|
+
const result = filterSourceFiles(files);
|
|
118
|
+
|
|
119
|
+
expect(result).toEqual(['src/foo.ts']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should filter out files in /adapters/ directory', () => {
|
|
123
|
+
const files = ['src/foo.ts', 'src/adapters/foo.ts'];
|
|
124
|
+
const result = filterSourceFiles(files);
|
|
125
|
+
|
|
126
|
+
expect(result).toEqual(['src/foo.ts']);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should filter out non-TypeScript files', () => {
|
|
130
|
+
const files = ['src/foo.ts', 'src/bar.js', 'src/baz.py'];
|
|
131
|
+
const result = filterSourceFiles(files);
|
|
132
|
+
|
|
133
|
+
expect(result).toEqual(['src/foo.ts']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should return empty array when no source files', () => {
|
|
137
|
+
const files = ['src/foo.test.ts', 'src/bar.d.ts'];
|
|
138
|
+
const result = filterSourceFiles(files);
|
|
139
|
+
|
|
140
|
+
expect(result).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('threshold evaluation', () => {
|
|
145
|
+
it('should pass when score >= threshold', async () => {
|
|
146
|
+
const mockContent = `
|
|
147
|
+
import { describe, it, expect } from 'vitest';
|
|
148
|
+
|
|
149
|
+
describe('Calculator', () => {
|
|
150
|
+
it('should add two numbers', () => {
|
|
151
|
+
expect(1 + 1).toBe(2);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
`;
|
|
155
|
+
|
|
156
|
+
vi.mocked(fs.readFile).mockResolvedValue(mockContent);
|
|
157
|
+
|
|
158
|
+
const result = await detectAITestCharacteristics('test.ts');
|
|
159
|
+
|
|
160
|
+
expect(result.mockDensity).toBeLessThanOrEqual(30);
|
|
161
|
+
expect(result.isAiGenerated).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should fail when score < threshold', async () => {
|
|
165
|
+
const mockContent = `
|
|
166
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
167
|
+
|
|
168
|
+
describe('UserService', () => {
|
|
169
|
+
it('should fetch user', async () => {
|
|
170
|
+
const mockFn = vi.fn();
|
|
171
|
+
const mockResolve = vi.mockResolvedValue({ id: '1' });
|
|
172
|
+
const mockReturn = vi.mockReturnValue({ id: '1' });
|
|
173
|
+
const mockRej = vi.mockRejectedValue(new Error('fail'));
|
|
174
|
+
const mockImpl = vi.mockImplementation(() => ({ id: '1' }));
|
|
175
|
+
const spy = vi.spyOn(obj, 'method');
|
|
176
|
+
const mock = vi.mock('module', () => ({ fn: vi.fn() }));
|
|
177
|
+
const mReset = vi.mockReset();
|
|
178
|
+
const mClear = vi.mockClear();
|
|
179
|
+
const mRestore = vi.mockRestore();
|
|
180
|
+
|
|
181
|
+
await expect(service.getUser('1')).resolves.toEqual({ id: '1' });
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
`;
|
|
185
|
+
|
|
186
|
+
vi.mocked(fs.readFile).mockResolvedValue(mockContent);
|
|
187
|
+
|
|
188
|
+
const result = await detectAITestCharacteristics('test.ts');
|
|
189
|
+
|
|
190
|
+
expect(result.mockDensity).toBeGreaterThan(30);
|
|
191
|
+
expect(result.isAiGenerated).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { AITestDetectionResult, TestLayer } from './types';
|
|
3
|
+
|
|
4
|
+
const MOCK_KEYWORDS = [
|
|
5
|
+
'mock',
|
|
6
|
+
'jest.mock',
|
|
7
|
+
'vi.mock',
|
|
8
|
+
'spyOn',
|
|
9
|
+
'jest.spyOn',
|
|
10
|
+
'vi.spyOn',
|
|
11
|
+
'fn()',
|
|
12
|
+
'jest.fn',
|
|
13
|
+
'vi.fn',
|
|
14
|
+
'mockResolvedValue',
|
|
15
|
+
'mockRejectedValue',
|
|
16
|
+
'mockReturnValue',
|
|
17
|
+
'mockImplementation',
|
|
18
|
+
'createMock',
|
|
19
|
+
'mockReset',
|
|
20
|
+
'mockClear',
|
|
21
|
+
'mockRestore',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const AI_GENERATED_DENSITY_THRESHOLD = 30;
|
|
25
|
+
|
|
26
|
+
function countMockReferences(content: string): number {
|
|
27
|
+
let count = 0;
|
|
28
|
+
for (const keyword of MOCK_KEYWORDS) {
|
|
29
|
+
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
30
|
+
const regex = new RegExp(`\\b${escaped}\\b`, 'g');
|
|
31
|
+
const matches = content.match(regex);
|
|
32
|
+
if (matches) {
|
|
33
|
+
count += matches.length;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return count;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function countTestLines(content: string): number {
|
|
40
|
+
const lines = content.split('\n');
|
|
41
|
+
return lines.filter(line => {
|
|
42
|
+
const trimmed = line.trim();
|
|
43
|
+
return (
|
|
44
|
+
trimmed.startsWith('it(') ||
|
|
45
|
+
trimmed.startsWith('test(') ||
|
|
46
|
+
trimmed.startsWith('describe(') ||
|
|
47
|
+
trimmed.startsWith('it.each(') ||
|
|
48
|
+
trimmed.startsWith('test.each(') ||
|
|
49
|
+
trimmed.startsWith('describe.each(')
|
|
50
|
+
);
|
|
51
|
+
}).length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const LAYER_PATTERNS: Array<[TestLayer, string[]]> = [
|
|
55
|
+
['e2e', ['.e2e.', '/e2e/', 'e2e/']],
|
|
56
|
+
['integration', ['.integration.', '/integration/', 'integration/']],
|
|
57
|
+
['unit', ['/__tests__/', '.test.', '.spec.']],
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
function matchesPattern(normalized: string, pattern: string): boolean {
|
|
61
|
+
if (!pattern.startsWith('/') && pattern.endsWith('/')) {
|
|
62
|
+
return normalized.startsWith(pattern);
|
|
63
|
+
}
|
|
64
|
+
return normalized.includes(pattern);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function detectTestLayer(testFilePath: string): TestLayer {
|
|
68
|
+
const normalized = testFilePath.replace(/\\/g, '/');
|
|
69
|
+
for (const [layer, patterns] of LAYER_PATTERNS) {
|
|
70
|
+
if (patterns.some(p => matchesPattern(normalized, p))) return layer;
|
|
71
|
+
}
|
|
72
|
+
return 'unknown';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function detectLayerAwareDensity(
|
|
76
|
+
content: string,
|
|
77
|
+
testFilePath: string
|
|
78
|
+
): { mockDensity: number; mockCount: number; testLines: number; layer: TestLayer; pendingMocks: number } {
|
|
79
|
+
const mockCount = countMockReferences(content);
|
|
80
|
+
const testLines = countTestLines(content);
|
|
81
|
+
const mockDensity = testLines > 0 ? (mockCount / testLines) * 100 : 0;
|
|
82
|
+
const layer = detectTestLayer(testFilePath);
|
|
83
|
+
const pendingMocks = (content.match(/mockPending|pendingMock/g) || []).length;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
mockDensity: Math.round(mockDensity * 100) / 100,
|
|
87
|
+
mockCount,
|
|
88
|
+
testLines,
|
|
89
|
+
layer,
|
|
90
|
+
pendingMocks,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function detectAITestCharacteristics(
|
|
95
|
+
testFilePath: string
|
|
96
|
+
): Promise<AITestDetectionResult> {
|
|
97
|
+
let content: string;
|
|
98
|
+
try {
|
|
99
|
+
content = await fs.readFile(testFilePath, 'utf-8');
|
|
100
|
+
} catch {
|
|
101
|
+
return {
|
|
102
|
+
isAiGenerated: false,
|
|
103
|
+
mockDensity: 0,
|
|
104
|
+
layer: detectTestLayer(testFilePath),
|
|
105
|
+
annotations: { hasTest: false, hasIntent: false, hasCovers: false }
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const thresholdMatch = content.match(/@mutation-threshold:\s*(\d+)/);
|
|
110
|
+
const explicitThreshold = thresholdMatch
|
|
111
|
+
? parseInt(thresholdMatch[1], 10)
|
|
112
|
+
: undefined;
|
|
113
|
+
|
|
114
|
+
const hasTest = /@test\s+/i.test(content);
|
|
115
|
+
const hasIntent = /@intent\s+/i.test(content);
|
|
116
|
+
const hasCovers = /@covers\s+/i.test(content);
|
|
117
|
+
|
|
118
|
+
const densityInfo = detectLayerAwareDensity(content, testFilePath);
|
|
119
|
+
const isAiGenerated = densityInfo.mockDensity > AI_GENERATED_DENSITY_THRESHOLD;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
isAiGenerated,
|
|
123
|
+
mockDensity: densityInfo.mockDensity,
|
|
124
|
+
layer: densityInfo.layer,
|
|
125
|
+
explicitThreshold,
|
|
126
|
+
annotations: { hasTest, hasIntent, hasCovers }
|
|
127
|
+
};
|
|
128
|
+
}
|