@boyingliu01/xp-gate 0.18.2 → 0.18.4
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/adapter-common.sh +0 -0
- package/adapters/cpp.sh +0 -0
- package/adapters/dart.sh +0 -0
- package/adapters/flutter.sh +0 -0
- package/adapters/gate-9.sh +1 -0
- package/adapters/go.sh +0 -0
- package/adapters/iac.sh +0 -0
- package/adapters/java.sh +0 -0
- package/adapters/kotlin.sh +0 -0
- package/adapters/objectivec.sh +0 -0
- package/adapters/plugins/book299-20132-python/scripts/install-gradle.sh +0 -0
- package/adapters/plugins/book299-20132-python/scripts/install-maven.sh +0 -0
- package/adapters/plugins/book299-4081-c/scripts/install-gradle.sh +0 -0
- package/adapters/plugins/book299-4081-c/scripts/install-maven.sh +0 -0
- package/adapters/plugins/book299-4083-javascriptes5/scripts/install-gradle.sh +0 -0
- package/adapters/plugins/book299-4083-javascriptes5/scripts/install-maven.sh +0 -0
- package/adapters/powershell.sh +0 -0
- package/adapters/python.sh +0 -0
- package/adapters/shell.sh +0 -0
- package/adapters/swift.sh +0 -0
- package/adapters/typescript.sh +0 -0
- package/bin/xp-gate.js +45 -0
- package/gate-3.sh +0 -0
- package/gate-4.sh +0 -0
- package/gate-7.sh +0 -0
- package/gate-8.sh +0 -0
- package/gate-9.sh +0 -0
- package/hooks/adapter-common.sh +0 -0
- package/hooks/gate-3.sh +0 -0
- package/hooks/gate-4.sh +0 -0
- package/hooks/gate-7.sh +0 -0
- package/hooks/gate-8.sh +0 -0
- package/hooks/gate-9.sh +0 -0
- package/hooks/pre-commit +108 -9
- package/hooks/pre-push +63 -232
- package/hooks/sprint-gate.sh +0 -0
- package/lib/__tests__/install-skill.test.js +3 -3
- package/lib/__tests__/phase-transition.test.js +512 -512
- package/lib/__tests__/sprint-audit.test.js +207 -207
- package/lib/__tests__/test-alignment.test.ts +761 -0
- package/lib/doctor.js +1 -1
- package/lib/install-skill.js +11 -3
- package/lib/language-tools.js +833 -833
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/phase-transition.js +604 -499
- package/lib/sprint-audit.js +235 -235
- package/lib/sprint-init.js +248 -207
- package/lib/test-alignment.ts +651 -0
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/gate-m.ts +18 -4
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/bin/delphi-review-guard.sh +0 -0
- package/plugins/claude-code/bin/xp-gate-check +0 -0
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/grilling/SKILL.md +12 -12
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- 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 +3 -3
- package/plugins/opencode/skills/grilling/SKILL.md +12 -12
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/agents/delphi-architecture.md +48 -48
- package/plugins/qoder/agents/delphi-feasibility.md +48 -48
- package/plugins/qoder/agents/delphi-technical.md +48 -48
- package/plugins/qoder/bin/sprint-flow-guard.sh +0 -0
- package/plugins/qoder/bin/xp-gate-check +0 -0
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/scripts/delphi-external-review.cjs +432 -432
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/grilling/SKILL.md +12 -12
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test-alignment.ts — Deterministic Test-Specification Alignment Engine
|
|
3
|
+
*
|
|
4
|
+
* Extracted from skills/test-specification-alignment/references/alignment-verification-algorithm.md
|
|
5
|
+
* (493 lines of TypeScript pseudocode) into real, compiled TypeScript.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* 1. parseSpecification() — Parse YAML → requirement IDs
|
|
9
|
+
* 2. parseTestFiles() — Parse test files → annotation map
|
|
10
|
+
* 3. verifyAlignment() — Cross-reference requirements ↔ tests
|
|
11
|
+
* 4. calculateScore() — Weighted alignment score
|
|
12
|
+
* 5. writeReport() — Write test-alignment-report.json
|
|
13
|
+
*
|
|
14
|
+
* Design decisions:
|
|
15
|
+
* - Lightweight YAML parser (~150 LOC, no external dependencies) for zero-dep npm package
|
|
16
|
+
* - Regex-based annotation extraction for TS, Python, Go (with .each/.skip/.todo support)
|
|
17
|
+
* - Real avgAssertions from testMap (not hardcoded placeholder)
|
|
18
|
+
* - Canonical spec format: specification.requirements (with backward compat for bare spec.requirements)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import * as path from 'path';
|
|
23
|
+
import * as crypto from 'crypto';
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Types
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
export interface AcceptanceCriterion {
|
|
30
|
+
id: string;
|
|
31
|
+
given?: string;
|
|
32
|
+
when?: string;
|
|
33
|
+
then?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Requirement {
|
|
37
|
+
id: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
priority?: 'MUST' | 'SHOULD' | 'MAY';
|
|
40
|
+
acceptanceCriteria: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DesignDecision {
|
|
44
|
+
id: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
rationale?: string;
|
|
47
|
+
alternatives_considered?: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ApiContract {
|
|
51
|
+
endpoint: string;
|
|
52
|
+
method?: string;
|
|
53
|
+
[key: string]: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SpecificationMap {
|
|
57
|
+
requirements: Requirement[];
|
|
58
|
+
designDecisions: DesignDecision[];
|
|
59
|
+
apiContracts: ApiContract[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TestCase {
|
|
63
|
+
name: string;
|
|
64
|
+
file: string;
|
|
65
|
+
requirementId?: string;
|
|
66
|
+
intent?: string;
|
|
67
|
+
covers: string[];
|
|
68
|
+
edgeCases: string[];
|
|
69
|
+
assertions: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface TestMap {
|
|
73
|
+
tests: TestCase[];
|
|
74
|
+
totalTests: number;
|
|
75
|
+
totalAssertions: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type IssueType =
|
|
79
|
+
| 'MISSING_REQUIREMENT_TEST'
|
|
80
|
+
| 'UNCOVERED_ACCEPTANCE_CRITERIA'
|
|
81
|
+
| 'MISSING_INTENT'
|
|
82
|
+
| 'UNCOVERED_EDGE_CASE'
|
|
83
|
+
| 'INSUFFICIENT_ASSERTIONS';
|
|
84
|
+
|
|
85
|
+
export type IssueSeverity = 'critical' | 'major' | 'minor';
|
|
86
|
+
|
|
87
|
+
export interface AlignmentIssue {
|
|
88
|
+
type: IssueType;
|
|
89
|
+
severity: IssueSeverity;
|
|
90
|
+
requirementId?: string;
|
|
91
|
+
acId?: string;
|
|
92
|
+
testId?: string;
|
|
93
|
+
message: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CoverageReport {
|
|
97
|
+
requirementsCovered: number;
|
|
98
|
+
requirementsTotal: number;
|
|
99
|
+
acCovered: number;
|
|
100
|
+
acTotal: number;
|
|
101
|
+
edgeCasesCovered: number;
|
|
102
|
+
edgeCasesTotal: number;
|
|
103
|
+
testsWithIntent: number;
|
|
104
|
+
testsTotal: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface AlignmentReport {
|
|
108
|
+
score: number;
|
|
109
|
+
issues: AlignmentIssue[];
|
|
110
|
+
coverage: CoverageReport;
|
|
111
|
+
passed: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface TestAlignmentReportFile {
|
|
115
|
+
alignment_status: 'PASS' | 'FAIL' | 'BLOCKED';
|
|
116
|
+
phase: number;
|
|
117
|
+
score: number;
|
|
118
|
+
head_commit: string;
|
|
119
|
+
spec_hash: string;
|
|
120
|
+
timestamp: string;
|
|
121
|
+
misaligned_tests: Array<{
|
|
122
|
+
test_name: string;
|
|
123
|
+
spec_requirement: string;
|
|
124
|
+
gap: string;
|
|
125
|
+
}>;
|
|
126
|
+
anti_pattern_detected: boolean;
|
|
127
|
+
errors: string[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// parseSpecification() — regex-based extraction for specification.yaml
|
|
133
|
+
// ============================================================================
|
|
134
|
+
|
|
135
|
+
export function parseSpecification(specPath: string): SpecificationMap {
|
|
136
|
+
if (!fs.existsSync(specPath)) {
|
|
137
|
+
throw new Error(`Specification file not found: ${specPath}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const content = fs.readFileSync(specPath, 'utf8');
|
|
141
|
+
|
|
142
|
+
// Split on REQ boundaries: "\n - id: REQ-" (canonical indentation)
|
|
143
|
+
// Also handle legacy "\n - id: REQ-" (minimal indentation)
|
|
144
|
+
const hasCanonicalIndent = content.includes('\n - id: REQ-');
|
|
145
|
+
const splitDelimiter = hasCanonicalIndent ? '\n - id: REQ-' : '\n - id: REQ-';
|
|
146
|
+
const blockPrefix = hasCanonicalIndent ? ' - id: REQ-' : ' - id: REQ-';
|
|
147
|
+
|
|
148
|
+
const blocks = content.split(splitDelimiter);
|
|
149
|
+
|
|
150
|
+
const requirements: Requirement[] = [];
|
|
151
|
+
|
|
152
|
+
for (let i = 1; i < blocks.length; i++) {
|
|
153
|
+
const fullBlock = blockPrefix + blocks[i]!;
|
|
154
|
+
|
|
155
|
+
const reqMatch = fullBlock.match(/- id: (REQ-[\w-]+)/);
|
|
156
|
+
if (!reqMatch) continue;
|
|
157
|
+
|
|
158
|
+
const reqId = reqMatch[1]!;
|
|
159
|
+
|
|
160
|
+
// Extract AC IDs: deeply indented "- id: AC-XXX-XX"
|
|
161
|
+
const acRegex = /- id: (AC-[\w-]+)/g;
|
|
162
|
+
const acIds: string[] = [];
|
|
163
|
+
let acMatch: RegExpExecArray | null;
|
|
164
|
+
while ((acMatch = acRegex.exec(fullBlock)) !== null) {
|
|
165
|
+
acIds.push(acMatch[1]!);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
requirements.push({
|
|
169
|
+
id: reqId,
|
|
170
|
+
acceptanceCriteria: acIds,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const designDecisions: DesignDecision[] = [];
|
|
175
|
+
const ddRegex = /- id: ([\w-]+)/g;
|
|
176
|
+
const ddSection = content.match(/design_decisions:\n([\s\S]*?)(?=\n[^\s#-])/);
|
|
177
|
+
if (ddSection) {
|
|
178
|
+
let ddMatch: RegExpExecArray | null;
|
|
179
|
+
while ((ddMatch = ddRegex.exec(ddSection[1]!)) !== null) {
|
|
180
|
+
designDecisions.push({ id: ddMatch[1]! });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const apiContracts: ApiContract[] = [];
|
|
185
|
+
const apiRegex = /- endpoint: (.+)/g;
|
|
186
|
+
const apiSection = content.match(/api_contracts:\n([\s\S]*?)(?=\n[^\s#-])/);
|
|
187
|
+
if (apiSection) {
|
|
188
|
+
let apiMatch: RegExpExecArray | null;
|
|
189
|
+
while ((apiMatch = apiRegex.exec(apiSection[1]!)) !== null) {
|
|
190
|
+
apiContracts.push({ endpoint: apiMatch[1]!.trim() });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { requirements, designDecisions, apiContracts };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ============================================================================
|
|
198
|
+
// Step 2: parseTestFiles()
|
|
199
|
+
// ============================================================================
|
|
200
|
+
|
|
201
|
+
const REQ_ID_PATTERN = /REQ-[A-Z0-9]+-[A-Z0-9]+/;
|
|
202
|
+
|
|
203
|
+
function extractTag(text: string, tag: string): string | null {
|
|
204
|
+
// Match @tag value (single line extraction, stops at newline or */)
|
|
205
|
+
const regex = new RegExp('@' + tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s+(.+?)(?:\\n|\\*\\/)', 's');
|
|
206
|
+
const match = text.match(regex);
|
|
207
|
+
if (!match) return null;
|
|
208
|
+
return match[1]!.trim();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function extractAllTags(text: string, tag: string): string[] {
|
|
212
|
+
const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
213
|
+
const regex = new RegExp('@' + escapedTag + '\\s+(.+?)(?:\\n|\\*\\/)', 'gs');
|
|
214
|
+
const results: string[] = [];
|
|
215
|
+
let match: RegExpExecArray | null;
|
|
216
|
+
while ((match = regex.exec(text)) !== null) {
|
|
217
|
+
const items1 = match[1]!.split(',').map(s => s.trim()).filter(Boolean);
|
|
218
|
+
results.push(...items1);
|
|
219
|
+
}
|
|
220
|
+
return results;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function countAssertions(content: string): number {
|
|
224
|
+
// Count common assertion patterns
|
|
225
|
+
const patterns = [
|
|
226
|
+
/expect\(/g,
|
|
227
|
+
/assert\./g,
|
|
228
|
+
/assert\(/g,
|
|
229
|
+
/\.should\./g,
|
|
230
|
+
/expect\./g,
|
|
231
|
+
/toEqual\(/g,
|
|
232
|
+
/toBe\(/g,
|
|
233
|
+
];
|
|
234
|
+
let count = 0;
|
|
235
|
+
for (const pattern of patterns) {
|
|
236
|
+
const matches = content.match(pattern);
|
|
237
|
+
if (matches) count += matches.length;
|
|
238
|
+
}
|
|
239
|
+
return count;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Extract JSDoc block preceding a test/it/describe declaration.
|
|
244
|
+
* Handles .each(), .skip(), .todo() variants and backtick template literals.
|
|
245
|
+
*/
|
|
246
|
+
function extractJSDocBeforeTest(content: string): Array<{ jsdoc: string; testBody: string; testName: string }> {
|
|
247
|
+
const results: Array<{ jsdoc: string; testBody: string; testName: string }> = [];
|
|
248
|
+
|
|
249
|
+
// Match: /** JSDOC */ (test|it|describe)[.modifier]( [args], )'name', ...
|
|
250
|
+
// `.each` variants have an extra `()` before the test name string
|
|
251
|
+
const testPattern = /\/\*\*([\s\S]*?)\*\/(?:[\s\n]*)(?:test|it|describe)(?:\.(?:each|skip|todo|only))?\s*\([^)]*\)\s*,\s*\(\s*(?:['"]([^'"]+)['"]|`([^`]*)`)\s*[,\)]/g;
|
|
252
|
+
|
|
253
|
+
let match: RegExpExecArray | null;
|
|
254
|
+
while ((match = testPattern.exec(content)) !== null) {
|
|
255
|
+
const jsdoc = match[1]!;
|
|
256
|
+
const testName = match[2] || match[3] || '';
|
|
257
|
+
if (testName) {
|
|
258
|
+
const testBody = content.slice(match.index, match.index + match[0].length);
|
|
259
|
+
results.push({ jsdoc, testBody, testName });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Second pass: match standard test/it/describe without .each
|
|
264
|
+
const simplePattern = /\/\*\*([\s\S]*?)\*\/(?:[\s\n]*)(?:test|it|describe)(?:\.(?:skip|todo|only))?\s*\(\s*(?:['"]([^'"]+)['"]|`([^`]*)`)\s*[,\)]/g;
|
|
265
|
+
|
|
266
|
+
while ((match = simplePattern.exec(content)) !== null) {
|
|
267
|
+
const jsdoc = match[1]!;
|
|
268
|
+
const testName = match[2] || match[3] || '';
|
|
269
|
+
if (testName) {
|
|
270
|
+
// Don't duplicate if already found in first pass
|
|
271
|
+
const alreadyFound = results.some(r => r.jsdoc === jsdoc);
|
|
272
|
+
if (!alreadyFound) {
|
|
273
|
+
const testBody = content.slice(match.index, match.index + match[0].length);
|
|
274
|
+
results.push({ jsdoc, testBody, testName });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return results;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function pushTestWithCovers(
|
|
283
|
+
tests: TestCase[],
|
|
284
|
+
testPath: string,
|
|
285
|
+
name: string,
|
|
286
|
+
reqId: string,
|
|
287
|
+
intent: string | undefined,
|
|
288
|
+
fileContent: string,
|
|
289
|
+
totalAssertionsRef: { value: number },
|
|
290
|
+
) {
|
|
291
|
+
const coversRegex = /@covers\s+(.+)/g;
|
|
292
|
+
const covers: string[] = [];
|
|
293
|
+
let coversMatch: RegExpExecArray | null;
|
|
294
|
+
while ((coversMatch = coversRegex.exec(fileContent)) !== null) {
|
|
295
|
+
covers.push(...coversMatch[1]!.split(',').map(s => s.trim()).filter(c => /^AC-[A-Z0-9]+-\d+-\d+$/.test(c)));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const assertions = countAssertions(fileContent);
|
|
299
|
+
totalAssertionsRef.value += assertions;
|
|
300
|
+
|
|
301
|
+
tests.push({
|
|
302
|
+
name,
|
|
303
|
+
file: testPath,
|
|
304
|
+
requirementId: reqId,
|
|
305
|
+
intent,
|
|
306
|
+
covers,
|
|
307
|
+
edgeCases: [],
|
|
308
|
+
assertions,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function parseTestFiles(testPaths: string[]): TestMap {
|
|
313
|
+
const tests: TestCase[] = [];
|
|
314
|
+
const totalAssertionsObj = { value: 0 };
|
|
315
|
+
|
|
316
|
+
for (const testPath of testPaths) {
|
|
317
|
+
if (!fs.existsSync(testPath)) continue;
|
|
318
|
+
|
|
319
|
+
const content = fs.readFileSync(testPath, 'utf8');
|
|
320
|
+
const ext = path.extname(testPath);
|
|
321
|
+
|
|
322
|
+
if (ext === '.ts' || ext === '.tsx' || ext === '.js' || ext === '.jsx') {
|
|
323
|
+
const matches = extractJSDocBeforeTest(content);
|
|
324
|
+
|
|
325
|
+
for (const m of matches) {
|
|
326
|
+
const reqMatch = m.jsdoc.match(/@test\s+(REQ-[A-Z0-9]+-[A-Z0-9]+)/);
|
|
327
|
+
const intentMatch = m.jsdoc.match(/@intent\s+(.+?)(?:\n\s*\*)/s);
|
|
328
|
+
const coversRaw = extractAllTags(m.jsdoc, 'covers');
|
|
329
|
+
// Filter: only valid AC IDs (AC-XXX-NNN-NN pattern)
|
|
330
|
+
const covers = coversRaw.filter(c => /^AC-[A-Z0-9]+-\d+-\d+$/.test(c));
|
|
331
|
+
|
|
332
|
+
const assertions = countAssertions(content);
|
|
333
|
+
totalAssertionsObj.value += assertions;
|
|
334
|
+
|
|
335
|
+
tests.push({
|
|
336
|
+
name: m.testName,
|
|
337
|
+
file: testPath,
|
|
338
|
+
requirementId: reqMatch ? reqMatch[1] : undefined,
|
|
339
|
+
intent: intentMatch ? intentMatch[1]!.trim() : undefined,
|
|
340
|
+
covers,
|
|
341
|
+
edgeCases: [],
|
|
342
|
+
assertions,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
} else if (ext === '.py') {
|
|
346
|
+
const pyTestPattern = /(# @test\s+(REQ-[A-Z0-9]+-[A-Z0-9]+)[\s\S]*?)(?:# @intent\s+(.+?)\n)?def\s+(test_\w+)/g;
|
|
347
|
+
let m: RegExpExecArray | null;
|
|
348
|
+
while ((m = pyTestPattern.exec(content)) !== null) {
|
|
349
|
+
pushTestWithCovers(tests, testPath, m[4]!, m[2]!, m[3]?.trim(), content, totalAssertionsObj);
|
|
350
|
+
}
|
|
351
|
+
} else if (ext === '.go') {
|
|
352
|
+
const goTestPattern = /(\/\/ @test\s+(REQ-[A-Z0-9]+-[A-Z0-9]+))\s*[\s\S]*?func\s+(Test_\w+)/g;
|
|
353
|
+
let m: RegExpExecArray | null;
|
|
354
|
+
while ((m = goTestPattern.exec(content)) !== null) {
|
|
355
|
+
const block = content.slice(Math.max(0, m.index - 500), m.index);
|
|
356
|
+
const intentMatch = block.match(/\/\/ @intent\s+(.+)/);
|
|
357
|
+
pushTestWithCovers(tests, testPath, m[3]!, m[2]!, intentMatch?.[1]?.trim(), content, totalAssertionsObj);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
tests,
|
|
364
|
+
totalTests: tests.length,
|
|
365
|
+
totalAssertions: totalAssertionsObj.value,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ============================================================================
|
|
370
|
+
// Step 3: verifyAlignment()
|
|
371
|
+
// ============================================================================
|
|
372
|
+
|
|
373
|
+
export function verifyAlignment(
|
|
374
|
+
specMap: SpecificationMap,
|
|
375
|
+
testMap: TestMap,
|
|
376
|
+
): AlignmentReport {
|
|
377
|
+
const issues: AlignmentIssue[] = [];
|
|
378
|
+
|
|
379
|
+
// Build coverage data
|
|
380
|
+
const coveredReqIds = new Set<string>();
|
|
381
|
+
const coveredAcIds = new Set<string>();
|
|
382
|
+
let testsWithIntent = 0;
|
|
383
|
+
|
|
384
|
+
for (const test of testMap.tests) {
|
|
385
|
+
if (test.requirementId) {
|
|
386
|
+
coveredReqIds.add(test.requirementId);
|
|
387
|
+
}
|
|
388
|
+
for (const acId of test.covers) {
|
|
389
|
+
coveredAcIds.add(acId);
|
|
390
|
+
}
|
|
391
|
+
if (test.intent && test.intent.trim().length > 0) {
|
|
392
|
+
testsWithIntent++;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Collect all requirement IDs and acceptance criteria IDs from spec
|
|
397
|
+
const allReqIds = new Set<string>();
|
|
398
|
+
const allAcIds = new Set<string>();
|
|
399
|
+
let totalEdgeCases = 0;
|
|
400
|
+
|
|
401
|
+
for (const req of specMap.requirements) {
|
|
402
|
+
allReqIds.add(req.id);
|
|
403
|
+
for (const acId of req.acceptanceCriteria) {
|
|
404
|
+
allAcIds.add(acId);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Rule 1: Each REQ must have a test (Critical)
|
|
409
|
+
for (const reqId of allReqIds) {
|
|
410
|
+
if (!coveredReqIds.has(reqId)) {
|
|
411
|
+
issues.push({
|
|
412
|
+
type: 'MISSING_REQUIREMENT_TEST',
|
|
413
|
+
severity: 'critical',
|
|
414
|
+
requirementId: reqId,
|
|
415
|
+
message: `Requirement ${reqId} has no associated test`,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Rule 2: Each AC must have assertion coverage (Major)
|
|
421
|
+
for (const acId of allAcIds) {
|
|
422
|
+
if (!coveredAcIds.has(acId)) {
|
|
423
|
+
// Find which requirement this AC belongs to
|
|
424
|
+
const parentReq = specMap.requirements.find(r => r.acceptanceCriteria.includes(acId));
|
|
425
|
+
issues.push({
|
|
426
|
+
type: 'UNCOVERED_ACCEPTANCE_CRITERIA',
|
|
427
|
+
severity: 'major',
|
|
428
|
+
acId,
|
|
429
|
+
requirementId: parentReq?.id,
|
|
430
|
+
message: `Acceptance criterion ${acId} is not covered by any test`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Rule 3: Each test must have @intent (Major)
|
|
436
|
+
for (const test of testMap.tests) {
|
|
437
|
+
if (!test.intent || test.intent.trim().length === 0) {
|
|
438
|
+
issues.push({
|
|
439
|
+
type: 'MISSING_INTENT',
|
|
440
|
+
severity: 'major',
|
|
441
|
+
testId: test.name,
|
|
442
|
+
message: `Test "${test.name}" in ${test.file} is missing @intent annotation`,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Rule 4: Each edge case should have a test (Minor) — not blocking in score
|
|
448
|
+
|
|
449
|
+
// Rule 5: Each test needs >= 2 assertions (Minor)
|
|
450
|
+
for (const test of testMap.tests) {
|
|
451
|
+
if (test.assertions < 2) {
|
|
452
|
+
issues.push({
|
|
453
|
+
type: 'INSUFFICIENT_ASSERTIONS',
|
|
454
|
+
severity: 'minor',
|
|
455
|
+
testId: test.name,
|
|
456
|
+
message: `Test "${test.name}" has only ${test.assertions} assertion(s) — consider adding more`,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const coverage: CoverageReport = {
|
|
462
|
+
requirementsCovered: coveredReqIds.size,
|
|
463
|
+
requirementsTotal: allReqIds.size,
|
|
464
|
+
acCovered: coveredAcIds.size,
|
|
465
|
+
acTotal: allAcIds.size,
|
|
466
|
+
edgeCasesCovered: 0,
|
|
467
|
+
edgeCasesTotal: totalEdgeCases,
|
|
468
|
+
testsWithIntent,
|
|
469
|
+
testsTotal: testMap.totalTests,
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
score: 0, // computed in calculateScore
|
|
474
|
+
issues,
|
|
475
|
+
coverage,
|
|
476
|
+
passed: false,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ============================================================================
|
|
481
|
+
// Step 4: calculateScore()
|
|
482
|
+
// Uses real avgAssertions from testMap — no hardcoded placeholder
|
|
483
|
+
// ============================================================================
|
|
484
|
+
|
|
485
|
+
export function calculateScore(
|
|
486
|
+
issues: AlignmentIssue[],
|
|
487
|
+
coverage: CoverageReport,
|
|
488
|
+
testMap: { totalTests: number; totalAssertions: number },
|
|
489
|
+
): number {
|
|
490
|
+
// Weighted dimensions
|
|
491
|
+
const weights = {
|
|
492
|
+
requirementCoverage: 30,
|
|
493
|
+
acCoverage: 25,
|
|
494
|
+
testIntent: 20,
|
|
495
|
+
edgeCaseCoverage: 15,
|
|
496
|
+
dataValidity: 10,
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
// Requirement coverage score
|
|
500
|
+
let reqCoverageScore = 0;
|
|
501
|
+
if (coverage.requirementsTotal > 0) {
|
|
502
|
+
reqCoverageScore = (coverage.requirementsCovered / coverage.requirementsTotal) * weights.requirementCoverage;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// AC coverage score
|
|
506
|
+
let acCoverageScore = 0;
|
|
507
|
+
if (coverage.acTotal > 0) {
|
|
508
|
+
acCoverageScore = (coverage.acCovered / coverage.acTotal) * weights.acCoverage;
|
|
509
|
+
} else {
|
|
510
|
+
// No ACs → full score (nothing to cover)
|
|
511
|
+
acCoverageScore = weights.acCoverage;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Test intent score
|
|
515
|
+
let intentScore = 0;
|
|
516
|
+
if (coverage.testsTotal > 0) {
|
|
517
|
+
intentScore = (coverage.testsWithIntent / coverage.testsTotal) * weights.testIntent;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Edge case coverage (stub — no edge case tracking in v1)
|
|
521
|
+
const edgeCaseScore = (coverage.edgeCasesTotal > 0)
|
|
522
|
+
? (coverage.edgeCasesCovered / coverage.edgeCasesTotal) * weights.edgeCaseCoverage
|
|
523
|
+
: weights.edgeCaseCoverage; // full score if no edge cases tracked
|
|
524
|
+
|
|
525
|
+
// Data validity: use real avgAssertions from testMap
|
|
526
|
+
const avgAssertions = testMap.totalTests > 0
|
|
527
|
+
? testMap.totalAssertions / testMap.totalTests
|
|
528
|
+
: 0;
|
|
529
|
+
let dataValidityScore = 0;
|
|
530
|
+
if (avgAssertions >= 5) {
|
|
531
|
+
dataValidityScore = weights.dataValidity; // full score
|
|
532
|
+
} else if (avgAssertions >= 3) {
|
|
533
|
+
dataValidityScore = weights.dataValidity * 0.75;
|
|
534
|
+
} else if (avgAssertions >= 1) {
|
|
535
|
+
dataValidityScore = weights.dataValidity * 0.5;
|
|
536
|
+
} else {
|
|
537
|
+
dataValidityScore = 0;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
let score = reqCoverageScore + acCoverageScore + intentScore + edgeCaseScore + dataValidityScore;
|
|
541
|
+
|
|
542
|
+
// Penalize for issues
|
|
543
|
+
for (const issue of issues) {
|
|
544
|
+
if (issue.severity === 'critical') {
|
|
545
|
+
score -= 5;
|
|
546
|
+
} else if (issue.severity === 'major') {
|
|
547
|
+
score -= 2;
|
|
548
|
+
}
|
|
549
|
+
// minor issues don't deduct (they're informational)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return Math.max(0, Math.round(score * 10) / 10);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ============================================================================
|
|
556
|
+
// Step 5: writeReport()
|
|
557
|
+
// ============================================================================
|
|
558
|
+
|
|
559
|
+
export function writeReport(report: TestAlignmentReportFile, outputPath: string): void {
|
|
560
|
+
const dir = path.dirname(outputPath);
|
|
561
|
+
if (!fs.existsSync(dir)) {
|
|
562
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2), 'utf8');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ============================================================================
|
|
569
|
+
// Orchestrator: runFullAlignment()
|
|
570
|
+
// ============================================================================
|
|
571
|
+
|
|
572
|
+
export interface AlignmentOptions {
|
|
573
|
+
specPath: string;
|
|
574
|
+
testDirs?: string[];
|
|
575
|
+
headCommit?: string;
|
|
576
|
+
outputPath?: string;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function runFullAlignment(options: AlignmentOptions): TestAlignmentReportFile {
|
|
580
|
+
const { specPath, testDirs = ['tests', 'test', '__tests__', 'src/__tests__'], headCommit, outputPath } = options;
|
|
581
|
+
|
|
582
|
+
// Step 1: Parse spec
|
|
583
|
+
let spec: SpecificationMap;
|
|
584
|
+
try {
|
|
585
|
+
spec = parseSpecification(specPath);
|
|
586
|
+
} catch (e: any) {
|
|
587
|
+
return {
|
|
588
|
+
alignment_status: 'BLOCKED',
|
|
589
|
+
phase: 0,
|
|
590
|
+
score: 0,
|
|
591
|
+
head_commit: headCommit || 'unknown',
|
|
592
|
+
spec_hash: '',
|
|
593
|
+
timestamp: new Date().toISOString(),
|
|
594
|
+
misaligned_tests: [],
|
|
595
|
+
anti_pattern_detected: false,
|
|
596
|
+
errors: [`Failed to parse specification: ${e.message}`],
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const specContent = fs.readFileSync(specPath, 'utf8');
|
|
601
|
+
const specHash = crypto.createHash('sha256').update(specContent).digest('hex');
|
|
602
|
+
|
|
603
|
+
// Step 2: Collect test files
|
|
604
|
+
const testFiles: string[] = [];
|
|
605
|
+
for (const dir of testDirs) {
|
|
606
|
+
if (fs.existsSync(dir)) {
|
|
607
|
+
const walkDir = (d: string) => {
|
|
608
|
+
const entries = fs.readdirSync(d, { withFileTypes: true });
|
|
609
|
+
for (const entry of entries) {
|
|
610
|
+
const fullPath = path.join(d, entry.name);
|
|
611
|
+
if (entry.isDirectory() && entry.name !== 'node_modules') {
|
|
612
|
+
walkDir(fullPath);
|
|
613
|
+
} else if (entry.isFile() && /\.(test|spec)\.(ts|tsx|js|jsx|py|go)$/.test(entry.name)) {
|
|
614
|
+
testFiles.push(fullPath);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
walkDir(dir);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Step 3: Parse tests
|
|
623
|
+
const testMap = parseTestFiles(testFiles);
|
|
624
|
+
|
|
625
|
+
// Step 4: Verify alignment
|
|
626
|
+
const alignmentReport = verifyAlignment(spec, testMap);
|
|
627
|
+
const score = calculateScore(alignmentReport.issues, alignmentReport.coverage, testMap);
|
|
628
|
+
|
|
629
|
+
const report: TestAlignmentReportFile = {
|
|
630
|
+
alignment_status: score >= 80 ? 'PASS' : 'FAIL',
|
|
631
|
+
phase: score >= 80 ? 2 : 1,
|
|
632
|
+
score,
|
|
633
|
+
head_commit: headCommit || 'unknown',
|
|
634
|
+
spec_hash: specHash,
|
|
635
|
+
timestamp: new Date().toISOString(),
|
|
636
|
+
misaligned_tests: alignmentReport.issues.map(issue => ({
|
|
637
|
+
test_name: issue.testId || 'unknown',
|
|
638
|
+
spec_requirement: issue.requirementId || 'unknown',
|
|
639
|
+
gap: issue.message,
|
|
640
|
+
})),
|
|
641
|
+
anti_pattern_detected: false,
|
|
642
|
+
errors: [],
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
// Step 5: Write output
|
|
646
|
+
if (outputPath) {
|
|
647
|
+
writeReport(report, outputPath);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return report;
|
|
651
|
+
}
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-07-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-07-29
|
|
4
|
+
**Commit:** 527fca8
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.18.
|
|
6
|
+
**Version:** 0.18.3.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
|
package/mutation/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MUTATION KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-07-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-07-29
|
|
4
|
+
**Commit:** 527fca8
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.18.
|
|
6
|
+
**Version:** 0.18.3.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
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.
|