phasegate 0.143.0 → 0.144.0
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/CHANGELOG.md +8 -0
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/docs/guide/layer-model.md +1 -1
- package/docs/guide/quick-vs-full-mode.md +1 -1
- package/docs/principles/testing-rules.md +18 -0
- package/package.json +1 -1
- package/scripts/harness/validator-system/domain/value-objects/test-quality-semantics.ts +58 -0
- package/scripts/harness/validator-system/infrastructure/adapters/biome-ast-test-quality-analyzer-adapter.ts +439 -21
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.144.0] - 2026-05-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **WI-129 / WI-130 — semantic L2 test-quality validation** — `L2-003 test-quality` を test case 単位の semantic AAA / assertion strength モデルへ更新した。
|
|
15
|
+
- TypeScript adapter は AST から `TestCaseStructure` / `SemanticAssertion` を構築し、複数 Act、Act 観測不足、Act 以外への Assert、domain/internal mock、weak truthiness / snapshot only / length only / interaction only、error contract 不足を warning として検出する。
|
|
16
|
+
- lifecycle/E2E と parameterized test の例外を追加し、weak assertion policy は constructor option で差し替え可能にした。
|
|
17
|
+
|
|
10
18
|
## [0.142.0] - 2026-05-10
|
|
11
19
|
|
|
12
20
|
### Added
|
package/README.ja.md
CHANGED
|
@@ -142,7 +142,7 @@ npx phasegate update-skills # スキルを最新版に再デプロイ
|
|
|
142
142
|
| @unit / @layer メタデータ, レイヤー違反, AI アンチパターン |
|
|
143
143
|
+------------------------------------------------------------------+
|
|
144
144
|
| L2 pre-commit |
|
|
145
|
-
| phase-gate, story-reflection, テスト品質 (AAA
|
|
145
|
+
| phase-gate, story-reflection, テスト品質 (semantic AAA) |
|
|
146
146
|
+------------------------------------------------------------------+
|
|
147
147
|
| L3 CI/CD |
|
|
148
148
|
| security, performance, coverage 90%/95%, 要件カバレッジ |
|
package/README.md
CHANGED
|
@@ -139,7 +139,7 @@ npx phasegate update-skills
|
|
|
139
139
|
+------------------------------------------------------------------+
|
|
140
140
|
| L2 PRE-COMMIT Validators |
|
|
141
141
|
| phase-gate, metadata completeness, story-reflection, |
|
|
142
|
-
| test-quality (AAA
|
|
142
|
+
| test-quality (semantic AAA + assertion strength) |
|
|
143
143
|
+------------------------------------------------------------------+
|
|
144
144
|
| L3 CI/CD Validators |
|
|
145
145
|
| security, performance, coverage threshold, nyquist traceability |
|
|
@@ -108,7 +108,7 @@ L2 validators run before every commit. They enforce process discipline and test
|
|
|
108
108
|
|-----------|-------------|
|
|
109
109
|
| **phase-gate** | Enforces design-before-implementation order. Code changes to `scripts/harness/` are blocked unless the corresponding design documents exist in `docs/product/construction/`. |
|
|
110
110
|
| **metadata** | Verifies completeness of source file annotations: `@unit`, `@layer`, `@US-XXX`, and `@story` |
|
|
111
|
-
| **test-quality** | Enforces test authoring standards: AAA pattern (Arrange/Act/Assert),
|
|
111
|
+
| **test-quality** | Enforces test authoring standards through a runner-independent semantic model: AAA pattern (Arrange/Act/Assert), named Act observation, single-act-per-test, assertion strength, lifecycle/E2E exceptions, and no domain/internal mocking in domain layer tests |
|
|
112
112
|
|
|
113
113
|
**Command:**
|
|
114
114
|
|
|
@@ -46,7 +46,7 @@ flowchart TD
|
|
|
46
46
|
| L4 Scheduled | drift + consistency + dead-code | skipped |
|
|
47
47
|
| Commit prefix | conventional (`feat:`, `fix:`, ...) | `[quick] ...` |
|
|
48
48
|
|
|
49
|
-
Both modes keep **L1 in full strength** and **L2 metadata / test-quality** — `@unit` / `@layer` comments
|
|
49
|
+
Both modes keep **L1 in full strength** and **L2 metadata / test-quality** — `@unit` / `@layer` comments, semantic AAA structure, and assertion-strength checks are non-negotiable regardless of flow.
|
|
50
50
|
|
|
51
51
|
---
|
|
52
52
|
|
|
@@ -12,6 +12,24 @@
|
|
|
12
12
|
- 実行結果は `actual` に代入する。
|
|
13
13
|
- テストの関心外の共通準備はヘルパーやファクトリに寄せる。ただし、各テストの前提が読めなくなるほど隠蔽しない。
|
|
14
14
|
|
|
15
|
+
## Semantic AAA と Assertion Quality
|
|
16
|
+
|
|
17
|
+
<!-- @work-item-id WI-129, WI-130 -->
|
|
18
|
+
|
|
19
|
+
AAA は特定の runner やコメント表記ではなく、テストケースが以下の意味構造を持つこととして扱う。
|
|
20
|
+
|
|
21
|
+
| 構造 | 意味 |
|
|
22
|
+
|------|------|
|
|
23
|
+
| Arrange | 前提条件、入力、fixture、制御可能な依存を組み立てる |
|
|
24
|
+
| Act | 観測したいふるまいを実行する。Unit / Integration では原則 1 つ |
|
|
25
|
+
| Assert | Act の観測結果、状態変化、発行イベント、永続化結果、error contract、interaction を検証する |
|
|
26
|
+
|
|
27
|
+
Assertion Quality は matcher 名だけで判断しない。観測対象は `observed output` / `state` / `emitted event` / `persisted effect` / `error contract` / `interaction` に分類し、観測強度は `exact value` / `shape` / `invariant` / `range` / `weak truthiness` / `snapshot only` / `interaction only` / `length only` に分類する。
|
|
28
|
+
|
|
29
|
+
- `toBeTruthy()` 相当の truthiness のみ、snapshot のみ、length のみ、mock call count のみは弱い観測として扱う。
|
|
30
|
+
- error case は error type / code / message / recovery hint などの contract を検証する。
|
|
31
|
+
- E2E の一連のライフサイクルは複数 Act を許可してよいが、各 Act の直後に観測可能な Assert を置く。
|
|
32
|
+
|
|
15
33
|
## テスト構造
|
|
16
34
|
|
|
17
35
|
`target` / `describe` / `context` / `it` は以下の役割で使う。
|
package/package.json
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @unit validator-system
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-129
|
|
4
|
+
// @work-item-id WI-130
|
|
5
|
+
|
|
6
|
+
export type TestCaseKind = 'unit' | 'integration' | 'e2e' | 'lifecycle';
|
|
7
|
+
|
|
8
|
+
export type TestStepKind = 'arrange' | 'act' | 'assert';
|
|
9
|
+
|
|
10
|
+
export type AssertionTarget =
|
|
11
|
+
| 'observed-output'
|
|
12
|
+
| 'state'
|
|
13
|
+
| 'emitted-event'
|
|
14
|
+
| 'persisted-effect'
|
|
15
|
+
| 'error-contract'
|
|
16
|
+
| 'interaction';
|
|
17
|
+
|
|
18
|
+
export type AssertionStrength =
|
|
19
|
+
| 'exact-value'
|
|
20
|
+
| 'shape'
|
|
21
|
+
| 'invariant'
|
|
22
|
+
| 'range'
|
|
23
|
+
| 'weak-truthiness'
|
|
24
|
+
| 'snapshot-only'
|
|
25
|
+
| 'interaction-only'
|
|
26
|
+
| 'length-only';
|
|
27
|
+
|
|
28
|
+
export interface SemanticAssertion {
|
|
29
|
+
readonly target: AssertionTarget;
|
|
30
|
+
readonly strength: AssertionStrength;
|
|
31
|
+
readonly subject: string;
|
|
32
|
+
readonly line: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TestStep {
|
|
36
|
+
readonly kind: TestStepKind;
|
|
37
|
+
readonly expression: string;
|
|
38
|
+
readonly line: number;
|
|
39
|
+
readonly observedName?: string;
|
|
40
|
+
readonly assertion?: SemanticAssertion;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TestDoubleReplacement {
|
|
44
|
+
readonly target: string;
|
|
45
|
+
readonly line: number;
|
|
46
|
+
readonly dependencyKind: 'external' | 'domain-internal';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TestCaseStructure {
|
|
50
|
+
readonly filePath: string;
|
|
51
|
+
readonly name: string;
|
|
52
|
+
readonly line: number;
|
|
53
|
+
readonly kind: TestCaseKind;
|
|
54
|
+
readonly steps: readonly TestStep[];
|
|
55
|
+
readonly assertions: readonly SemanticAssertion[];
|
|
56
|
+
readonly mocks: readonly TestDoubleReplacement[];
|
|
57
|
+
readonly allowsMultipleActs: boolean;
|
|
58
|
+
}
|
|
@@ -1,57 +1,475 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @layer infrastructure
|
|
3
3
|
* @unit validator-system
|
|
4
|
+
* @work-item-id WI-129
|
|
5
|
+
* @work-item-id WI-130
|
|
4
6
|
*
|
|
5
|
-
* BiomeAstTestQualityAnalyzerAdapter — TestQualityAnalyzerPort
|
|
6
|
-
* testing-rules.md準拠チェック: 日本語テスト名・actual変数・AAA構造
|
|
7
|
+
* BiomeAstTestQualityAnalyzerAdapter — TestQualityAnalyzerPort implementation.
|
|
7
8
|
*/
|
|
8
9
|
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import * as ts from 'typescript';
|
|
9
11
|
import type { TestQualityAnalyzerPort } from '../../domain/ports/test-quality-analyzer-port.js';
|
|
12
|
+
import type {
|
|
13
|
+
AssertionStrength,
|
|
14
|
+
AssertionTarget,
|
|
15
|
+
SemanticAssertion,
|
|
16
|
+
TestCaseKind,
|
|
17
|
+
TestCaseStructure,
|
|
18
|
+
TestDoubleReplacement,
|
|
19
|
+
TestStep,
|
|
20
|
+
} from '../../domain/value-objects/test-quality-semantics.js';
|
|
10
21
|
import type { HarnessErrorLike } from '../../domain/value-objects/validation-result.js';
|
|
11
22
|
|
|
12
|
-
// Japanese character ranges: hiragana, katakana, kanji
|
|
13
23
|
const JAPANESE_CHAR = /[\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]/;
|
|
24
|
+
const TEST_FUNCTION_NAMES = new Set(['it', 'test']);
|
|
25
|
+
const WEAK_ASSERTION_STRENGTHS = new Set<AssertionStrength>([
|
|
26
|
+
'weak-truthiness',
|
|
27
|
+
'snapshot-only',
|
|
28
|
+
'interaction-only',
|
|
29
|
+
'length-only',
|
|
30
|
+
]);
|
|
14
31
|
|
|
15
|
-
|
|
16
|
-
|
|
32
|
+
export interface TestQualityAnalyzerOptions {
|
|
33
|
+
readonly weakAssertionStrengths?: readonly AssertionStrength[];
|
|
34
|
+
}
|
|
17
35
|
|
|
18
|
-
function createViolation(
|
|
36
|
+
function createViolation(message: string, suggestion: string): HarnessErrorLike {
|
|
19
37
|
return {
|
|
20
|
-
code: { value:
|
|
38
|
+
code: { value: 'L2-003', toString: () => 'L2-003' },
|
|
21
39
|
severity: { value: 'warning', toString: () => 'warning' },
|
|
22
40
|
message,
|
|
23
41
|
suggestion,
|
|
24
42
|
};
|
|
25
43
|
}
|
|
26
44
|
|
|
27
|
-
function
|
|
45
|
+
function lineOf(sourceFile: ts.SourceFile, node: ts.Node): number {
|
|
46
|
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function textOf(sourceFile: ts.SourceFile, node: ts.Node): string {
|
|
50
|
+
return node.getText(sourceFile).replace(/\s+/g, ' ').trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function unwrapAwait(expression: ts.Expression): ts.Expression {
|
|
54
|
+
return ts.isAwaitExpression(expression) ? expression.expression : expression;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isCallLikeAct(expression: ts.Expression): boolean {
|
|
58
|
+
const unwrapped = unwrapAwait(expression);
|
|
59
|
+
if (!ts.isCallExpression(unwrapped) && !ts.isNewExpression(unwrapped)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const text = unwrapped.getText();
|
|
63
|
+
return !/^expect\s*\(/.test(text);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isDerivedFromObservedName(sourceFile: ts.SourceFile, expression: ts.Expression, observedNames: ReadonlySet<string>): boolean {
|
|
67
|
+
const expressionText = textOf(sourceFile, unwrapAwait(expression));
|
|
68
|
+
return [...observedNames].some((name) => (
|
|
69
|
+
expressionText === name || expressionText.startsWith(`${name}.`) || expressionText.startsWith(`${name}[`)
|
|
70
|
+
));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isTestCallee(expression: ts.Expression): boolean {
|
|
74
|
+
if (ts.isIdentifier(expression)) {
|
|
75
|
+
return TEST_FUNCTION_NAMES.has(expression.text);
|
|
76
|
+
}
|
|
77
|
+
if (ts.isPropertyAccessExpression(expression)) {
|
|
78
|
+
if (expression.name.text === 'each') {
|
|
79
|
+
return isTestCallee(expression.expression);
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
if (ts.isCallExpression(expression)) {
|
|
84
|
+
return isTestCallee(expression.expression);
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function findCallback(args: ts.NodeArray<ts.Expression>): ts.ArrowFunction | ts.FunctionExpression | undefined {
|
|
90
|
+
return args.find((arg): arg is ts.ArrowFunction | ts.FunctionExpression => (
|
|
91
|
+
ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)
|
|
92
|
+
));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractTestName(args: ts.NodeArray<ts.Expression>): string | undefined {
|
|
96
|
+
const nameArg = args.find((arg) => ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg));
|
|
97
|
+
return nameArg && (ts.isStringLiteral(nameArg) || ts.isNoSubstitutionTemplateLiteral(nameArg))
|
|
98
|
+
? nameArg.text
|
|
99
|
+
: undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isExpectCall(expression: ts.Expression): expression is ts.CallExpression {
|
|
103
|
+
return ts.isCallExpression(expression)
|
|
104
|
+
&& ts.isIdentifier(expression.expression)
|
|
105
|
+
&& expression.expression.text === 'expect';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function extractExpectCall(expression: ts.Expression): {
|
|
109
|
+
expectCall: ts.CallExpression;
|
|
110
|
+
matcher: string;
|
|
111
|
+
} | undefined {
|
|
112
|
+
const unwrapped = unwrapAwait(expression);
|
|
113
|
+
if (!ts.isCallExpression(unwrapped)) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let cursor: ts.Expression = unwrapped.expression;
|
|
118
|
+
let matcher = '';
|
|
119
|
+
while (ts.isPropertyAccessExpression(cursor)) {
|
|
120
|
+
if (!matcher) {
|
|
121
|
+
matcher = cursor.name.text;
|
|
122
|
+
}
|
|
123
|
+
cursor = cursor.expression;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (ts.isCallExpression(cursor) && isExpectCall(cursor)) {
|
|
127
|
+
return { expectCall: cursor, matcher };
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function classifyAssertion(
|
|
133
|
+
sourceFile: ts.SourceFile,
|
|
134
|
+
statement: ts.Statement,
|
|
135
|
+
expression: ts.Expression,
|
|
136
|
+
): SemanticAssertion | undefined {
|
|
137
|
+
const expectInfo = extractExpectCall(expression);
|
|
138
|
+
if (!expectInfo) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const subject = expectInfo.expectCall.arguments[0]
|
|
143
|
+
? textOf(sourceFile, expectInfo.expectCall.arguments[0])
|
|
144
|
+
: '';
|
|
145
|
+
const matcher = expectInfo.matcher;
|
|
146
|
+
const target = classifyAssertionTarget(subject, matcher);
|
|
147
|
+
const strength = classifyAssertionStrength(matcher, subject, expression);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
target,
|
|
151
|
+
strength,
|
|
152
|
+
subject,
|
|
153
|
+
line: lineOf(sourceFile, statement),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function classifyAssertionTarget(subject: string, matcher: string): AssertionTarget {
|
|
158
|
+
if (/toHaveBeenCalled|toBeCalled|toHaveReturned/.test(matcher) || /\.mock\b/.test(subject)) {
|
|
159
|
+
return 'interaction';
|
|
160
|
+
}
|
|
161
|
+
if (/throw/i.test(matcher) || /^\(\s*\)\s*=>/.test(subject)) {
|
|
162
|
+
return 'error-contract';
|
|
163
|
+
}
|
|
164
|
+
if (/event|emit|dispatch/i.test(subject)) {
|
|
165
|
+
return 'emitted-event';
|
|
166
|
+
}
|
|
167
|
+
if (/repository|store|db|database|persist/i.test(subject)) {
|
|
168
|
+
return 'persisted-effect';
|
|
169
|
+
}
|
|
170
|
+
if (/state|status|enabled|visible/i.test(subject)) {
|
|
171
|
+
return 'state';
|
|
172
|
+
}
|
|
173
|
+
return 'observed-output';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function classifyAssertionStrength(matcher: string, subject: string, expression: ts.Expression): AssertionStrength {
|
|
177
|
+
if (/toMatchSnapshot|toMatchInlineSnapshot/.test(matcher)) {
|
|
178
|
+
return 'snapshot-only';
|
|
179
|
+
}
|
|
180
|
+
if (/toBeTruthy|toBeFalsy|toBeDefined|toBeUndefined|toBeNull/.test(matcher)) {
|
|
181
|
+
return 'weak-truthiness';
|
|
182
|
+
}
|
|
183
|
+
if (/toHaveLength/.test(matcher) || /\.length$/.test(subject)) {
|
|
184
|
+
return 'length-only';
|
|
185
|
+
}
|
|
186
|
+
if (/toHaveBeenCalled|toBeCalled/.test(matcher)) {
|
|
187
|
+
return 'interaction-only';
|
|
188
|
+
}
|
|
189
|
+
if (/toThrow|toThrowError/.test(matcher) && ts.isCallExpression(unwrapAwait(expression))) {
|
|
190
|
+
const matcherCall = unwrapAwait(expression) as ts.CallExpression;
|
|
191
|
+
if (matcherCall.arguments.length === 0) {
|
|
192
|
+
return 'weak-truthiness';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (/toMatchObject|toEqual|toContainEqual/.test(matcher)) {
|
|
196
|
+
return 'shape';
|
|
197
|
+
}
|
|
198
|
+
if (/toBeGreaterThan|toBeGreaterThanOrEqual|toBeLessThan|toBeLessThanOrEqual/.test(matcher)) {
|
|
199
|
+
return 'range';
|
|
200
|
+
}
|
|
201
|
+
if (/toContain|toSatisfy/.test(matcher)) {
|
|
202
|
+
return 'invariant';
|
|
203
|
+
}
|
|
204
|
+
return 'exact-value';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function classifyTestKind(filePath: string, testName: string): TestCaseKind {
|
|
208
|
+
const lower = `${filePath} ${testName}`.toLowerCase();
|
|
209
|
+
if (/\be2e\b|lifecycle|ライフサイクル|一連/.test(lower)) {
|
|
210
|
+
return 'e2e';
|
|
211
|
+
}
|
|
212
|
+
if (/\bintegration\b|\.it\.test|統合/.test(lower)) {
|
|
213
|
+
return 'integration';
|
|
214
|
+
}
|
|
215
|
+
return 'unit';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function allowsMultipleActs(kind: TestCaseKind, testName: string): boolean {
|
|
219
|
+
return kind === 'e2e' || kind === 'lifecycle' || /ライフサイクル|一連|flow|journey/i.test(testName);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function extractMockReplacements(sourceFile: ts.SourceFile): TestDoubleReplacement[] {
|
|
223
|
+
const replacements: TestDoubleReplacement[] = [];
|
|
224
|
+
|
|
225
|
+
function visit(node: ts.Node): void {
|
|
226
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
227
|
+
const api = `${node.expression.expression.getText(sourceFile)}.${node.expression.name.text}`;
|
|
228
|
+
if ((api === 'vi.mock' || api === 'jest.mock') && node.arguments[0]) {
|
|
229
|
+
const target = textOf(sourceFile, node.arguments[0]).replace(/^['"`]|['"`]$/g, '');
|
|
230
|
+
replacements.push({
|
|
231
|
+
target,
|
|
232
|
+
line: lineOf(sourceFile, node),
|
|
233
|
+
dependencyKind: isDomainInternalReplacement(target) ? 'domain-internal' : 'external',
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
ts.forEachChild(node, visit);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
visit(sourceFile);
|
|
241
|
+
return replacements;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function isDomainInternalReplacement(target: string): boolean {
|
|
245
|
+
return /(^|\/)(domain|domains|entities|entity|value-objects|aggregates|aggregate|services)(\/|$)/i.test(target);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function extractSteps(sourceFile: ts.SourceFile, body: ts.ConciseBody): TestStep[] {
|
|
249
|
+
if (!ts.isBlock(body)) {
|
|
250
|
+
const assertion = classifyAssertion(sourceFile, body as unknown as ts.Statement, body);
|
|
251
|
+
return [{
|
|
252
|
+
kind: assertion ? 'assert' : 'act',
|
|
253
|
+
expression: textOf(sourceFile, body),
|
|
254
|
+
line: lineOf(sourceFile, body),
|
|
255
|
+
assertion,
|
|
256
|
+
}];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const steps: TestStep[] = [];
|
|
260
|
+
const observedNames = new Set<string>();
|
|
261
|
+
for (const statement of body.statements) {
|
|
262
|
+
if (ts.isVariableStatement(statement)) {
|
|
263
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
264
|
+
const name = declaration.name.getText(sourceFile);
|
|
265
|
+
const initializer = declaration.initializer;
|
|
266
|
+
const expression = initializer ? textOf(sourceFile, initializer) : textOf(sourceFile, declaration);
|
|
267
|
+
const isAct = Boolean(initializer) && (
|
|
268
|
+
name === 'actual'
|
|
269
|
+
|| (
|
|
270
|
+
observedNames.size > 0
|
|
271
|
+
&& initializer !== undefined
|
|
272
|
+
&& isCallLikeAct(initializer)
|
|
273
|
+
&& !isDerivedFromObservedName(sourceFile, initializer, observedNames)
|
|
274
|
+
)
|
|
275
|
+
);
|
|
276
|
+
if (isAct) {
|
|
277
|
+
observedNames.add(name);
|
|
278
|
+
}
|
|
279
|
+
steps.push({
|
|
280
|
+
kind: isAct ? 'act' : 'arrange',
|
|
281
|
+
expression,
|
|
282
|
+
line: lineOf(sourceFile, declaration),
|
|
283
|
+
observedName: isAct ? name : undefined,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (ts.isExpressionStatement(statement)) {
|
|
290
|
+
const assertion = classifyAssertion(sourceFile, statement, statement.expression);
|
|
291
|
+
if (assertion) {
|
|
292
|
+
steps.push({
|
|
293
|
+
kind: 'assert',
|
|
294
|
+
expression: textOf(sourceFile, statement.expression),
|
|
295
|
+
line: lineOf(sourceFile, statement),
|
|
296
|
+
assertion,
|
|
297
|
+
});
|
|
298
|
+
} else if (isCallLikeAct(statement.expression)) {
|
|
299
|
+
steps.push({
|
|
300
|
+
kind: 'act',
|
|
301
|
+
expression: textOf(sourceFile, statement.expression),
|
|
302
|
+
line: lineOf(sourceFile, statement),
|
|
303
|
+
});
|
|
304
|
+
} else {
|
|
305
|
+
steps.push({
|
|
306
|
+
kind: 'arrange',
|
|
307
|
+
expression: textOf(sourceFile, statement),
|
|
308
|
+
line: lineOf(sourceFile, statement),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
steps.push({
|
|
315
|
+
kind: 'arrange',
|
|
316
|
+
expression: textOf(sourceFile, statement),
|
|
317
|
+
line: lineOf(sourceFile, statement),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return steps;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function extractTestCases(filePath: string, sourceFile: ts.SourceFile): TestCaseStructure[] {
|
|
325
|
+
const mocks = extractMockReplacements(sourceFile);
|
|
326
|
+
const testCases: TestCaseStructure[] = [];
|
|
327
|
+
|
|
328
|
+
function visit(node: ts.Node): void {
|
|
329
|
+
if (ts.isCallExpression(node) && isTestCallee(node.expression)) {
|
|
330
|
+
const name = extractTestName(node.arguments);
|
|
331
|
+
const callback = findCallback(node.arguments);
|
|
332
|
+
if (name && callback) {
|
|
333
|
+
const kind = classifyTestKind(filePath, name);
|
|
334
|
+
const steps = extractSteps(sourceFile, callback.body);
|
|
335
|
+
const assertions = steps
|
|
336
|
+
.map((step) => step.assertion)
|
|
337
|
+
.filter((assertion): assertion is SemanticAssertion => assertion !== undefined);
|
|
338
|
+
testCases.push({
|
|
339
|
+
filePath,
|
|
340
|
+
name,
|
|
341
|
+
line: lineOf(sourceFile, node),
|
|
342
|
+
kind,
|
|
343
|
+
steps,
|
|
344
|
+
assertions,
|
|
345
|
+
mocks,
|
|
346
|
+
allowsMultipleActs: allowsMultipleActs(kind, name),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
ts.forEachChild(node, visit);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
visit(sourceFile);
|
|
354
|
+
return testCases;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function analyzeTestCase(
|
|
358
|
+
testCase: TestCaseStructure,
|
|
359
|
+
weakAssertionStrengths: ReadonlySet<AssertionStrength>,
|
|
360
|
+
): HarnessErrorLike[] {
|
|
28
361
|
const violations: HarnessErrorLike[] = [];
|
|
29
|
-
const
|
|
362
|
+
const acts = testCase.steps.filter((step) => step.kind === 'act');
|
|
363
|
+
const asserts = testCase.steps.filter((step) => step.kind === 'assert');
|
|
364
|
+
const firstActIndex = testCase.steps.findIndex((step) => step.kind === 'act');
|
|
365
|
+
const firstAssertIndex = testCase.steps.findIndex((step) => step.kind === 'assert');
|
|
366
|
+
|
|
367
|
+
if (!JAPANESE_CHAR.test(testCase.name)) {
|
|
368
|
+
violations.push(createViolation(
|
|
369
|
+
`テスト名が日本語ではありません: "${testCase.name}" at ${testCase.filePath}:${testCase.line}`,
|
|
370
|
+
'テスト名は日本語で仕様を表してください。',
|
|
371
|
+
));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (acts.length === 0) {
|
|
375
|
+
violations.push(createViolation(
|
|
376
|
+
`Act が見つかりません: "${testCase.name}" at ${testCase.filePath}:${testCase.line}`,
|
|
377
|
+
'ふるまいの実行を Act として名前付きの観測値に保持してください。',
|
|
378
|
+
));
|
|
379
|
+
}
|
|
30
380
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
381
|
+
if (asserts.length === 0) {
|
|
382
|
+
violations.push(createViolation(
|
|
383
|
+
`Assert が見つかりません: "${testCase.name}" at ${testCase.filePath}:${testCase.line}`,
|
|
384
|
+
'Act の観測結果を Assert してください。',
|
|
385
|
+
));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!testCase.allowsMultipleActs && acts.length > 1) {
|
|
389
|
+
violations.push(createViolation(
|
|
390
|
+
`Act が複数あります: "${testCase.name}" at ${testCase.filePath}:${acts[1].line}`,
|
|
391
|
+
'Unit/Integration テストでは 1 テスト 1 Act に分割してください。',
|
|
392
|
+
));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (firstActIndex >= 0 && firstAssertIndex >= 0 && firstAssertIndex < firstActIndex) {
|
|
396
|
+
violations.push(createViolation(
|
|
397
|
+
`Assert が Act より前にあります: "${testCase.name}" at ${testCase.filePath}:${testCase.steps[firstAssertIndex].line}`,
|
|
398
|
+
'Arrange / Act / Assert の順序に整理してください。',
|
|
399
|
+
));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const observedNames = new Set(acts.map((step) => step.observedName).filter((name): name is string => Boolean(name)));
|
|
403
|
+
if (acts.length > 0 && observedNames.size === 0) {
|
|
404
|
+
violations.push(createViolation(
|
|
405
|
+
`Act の観測結果が名前付き値として保持されていません: "${testCase.name}" at ${testCase.filePath}:${acts[0].line}`,
|
|
406
|
+
'Act の戻り値を const actual = ... のような名前付き値に保持してください。',
|
|
407
|
+
));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
for (const name of observedNames) {
|
|
411
|
+
if (!testCase.allowsMultipleActs && name !== 'actual') {
|
|
34
412
|
violations.push(createViolation(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
'テスト名は日本語で記述してください(testing-rules.md準拠)',
|
|
413
|
+
`Act の観測値名が actual ではありません: "${name}" at ${testCase.filePath}:${acts.find((step) => step.observedName === name)?.line ?? testCase.line}`,
|
|
414
|
+
'TypeScript テストでは const actual = ... を使用してください。',
|
|
38
415
|
));
|
|
39
416
|
}
|
|
40
417
|
}
|
|
41
418
|
|
|
42
|
-
|
|
43
|
-
|
|
419
|
+
const observesAct = testCase.assertions.some((assertion) => (
|
|
420
|
+
[...observedNames].some((name) => assertion.subject === name || assertion.subject.startsWith(`${name}.`))
|
|
421
|
+
));
|
|
422
|
+
const observesExternalEffect = testCase.assertions.some((assertion) => assertion.target !== 'observed-output');
|
|
423
|
+
if (acts.length > 0 && testCase.assertions.length > 0 && !observesAct && !observesExternalEffect) {
|
|
44
424
|
violations.push(createViolation(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
'アサーション前に const actual = ... で結果を変数に格納してください(testing-rules.md準拠)',
|
|
425
|
+
`Assert が Act の観測結果を検証していません: "${testCase.name}" at ${testCase.filePath}:${testCase.assertions[0].line}`,
|
|
426
|
+
'Assert は Act の戻り値、状態変化、イベント、永続化結果、error contract、interaction のいずれかを検証してください。',
|
|
48
427
|
));
|
|
49
428
|
}
|
|
50
429
|
|
|
430
|
+
for (const assertion of testCase.assertions) {
|
|
431
|
+
if (weakAssertionStrengths.has(assertion.strength)) {
|
|
432
|
+
violations.push(createViolation(
|
|
433
|
+
`弱い assertion です (${assertion.strength}): "${testCase.name}" at ${testCase.filePath}:${assertion.line}`,
|
|
434
|
+
'exact value、shape、invariant、range、または error contract を観測してください。',
|
|
435
|
+
));
|
|
436
|
+
}
|
|
437
|
+
if (assertion.target === 'error-contract' && assertion.strength === 'weak-truthiness') {
|
|
438
|
+
violations.push(createViolation(
|
|
439
|
+
`error contract の詳細が検証されていません: "${testCase.name}" at ${testCase.filePath}:${assertion.line}`,
|
|
440
|
+
'error type、code、message、recovery hint などを検証してください。',
|
|
441
|
+
));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
for (const replacement of testCase.mocks) {
|
|
446
|
+
if (replacement.dependencyKind === 'domain-internal') {
|
|
447
|
+
violations.push(createViolation(
|
|
448
|
+
`domain/internal dependency を mock しています: ${replacement.target} at ${testCase.filePath}:${replacement.line}`,
|
|
449
|
+
'Domain object や内部 module は実体を使い、外部I/O・時刻・乱数・Port実装のみを置き換えてください。',
|
|
450
|
+
));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
51
454
|
return violations;
|
|
52
455
|
}
|
|
53
456
|
|
|
457
|
+
function analyzeContent(
|
|
458
|
+
filePath: string,
|
|
459
|
+
content: string,
|
|
460
|
+
weakAssertionStrengths: ReadonlySet<AssertionStrength>,
|
|
461
|
+
): HarnessErrorLike[] {
|
|
462
|
+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
463
|
+
return extractTestCases(filePath, sourceFile).flatMap((testCase) => analyzeTestCase(testCase, weakAssertionStrengths));
|
|
464
|
+
}
|
|
465
|
+
|
|
54
466
|
export class BiomeAstTestQualityAnalyzerAdapter implements TestQualityAnalyzerPort {
|
|
467
|
+
private readonly weakAssertionStrengths: ReadonlySet<AssertionStrength>;
|
|
468
|
+
|
|
469
|
+
constructor(options: TestQualityAnalyzerOptions = {}) {
|
|
470
|
+
this.weakAssertionStrengths = new Set(options.weakAssertionStrengths ?? WEAK_ASSERTION_STRENGTHS);
|
|
471
|
+
}
|
|
472
|
+
|
|
55
473
|
async analyzeTestFiles(targetPaths: readonly string[]): Promise<{
|
|
56
474
|
results: readonly { filePath: string; passed: boolean; violations: readonly HarnessErrorLike[] }[];
|
|
57
475
|
}> {
|
|
@@ -59,7 +477,7 @@ export class BiomeAstTestQualityAnalyzerAdapter implements TestQualityAnalyzerPo
|
|
|
59
477
|
targetPaths.map(async (filePath) => {
|
|
60
478
|
try {
|
|
61
479
|
const content = await readFile(filePath, 'utf-8');
|
|
62
|
-
const violations = analyzeContent(filePath, content);
|
|
480
|
+
const violations = analyzeContent(filePath, content, this.weakAssertionStrengths);
|
|
63
481
|
return { filePath, passed: violations.length === 0, violations };
|
|
64
482
|
} catch {
|
|
65
483
|
return { filePath, passed: true, violations: [] as HarnessErrorLike[] };
|