phasegate 0.142.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 +5 -4
- package/README.md +6 -5
- package/docs/guide/layer-model.md +3 -2
- package/docs/guide/quick-vs-full-mode.md +1 -1
- package/docs/principles/testing-rules.md +18 -0
- package/docs/templates/ci/aidlc-gate.yml +26 -3
- package/docs/templates/hooks/pre-push +6 -0
- package/package.json +1 -1
- package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +2 -2
- package/scripts/harness/integrations/pre-commit.ts +251 -3
- package/scripts/harness/main.ts +23 -0
- package/scripts/harness/quick-mode/domain/value-objects/validator-relaxation-profile.ts +4 -3
- package/scripts/harness/quick-mode/infrastructure/adapters/harness-config-quick-mode-config-adapter.ts +2 -1
- package/scripts/harness/quick-mode/infrastructure/adapters/validator-system-validator-id-registry-adapter.ts +2 -1
- package/scripts/harness/setup/skill-deployer.ts +19 -0
- package/scripts/harness/traceability-model/application/usecases/apply-work-item-status-usecase.ts +29 -4
- package/scripts/harness/traceability-model/domain/services/work-item-status-derivation-service.ts +20 -4
- package/scripts/harness/traceability-model/domain/value-objects/work-item-status-report.ts +10 -1
- package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-status-gateway.ts +2 -1
- package/scripts/harness/traceability-model/presentation/cli/work-item-status-command-handler.ts +13 -4
- package/scripts/harness/validator-system/application/mappers/validation-result-contract-mapper.ts +8 -4
- package/scripts/harness/validator-system/application/use-cases/run-l2-validators-usecase.ts +28 -0
- package/scripts/harness/validator-system/composition-root.ts +5 -1
- package/scripts/harness/validator-system/domain/ports/work-item-status-policy-port.ts +11 -0
- package/scripts/harness/validator-system/domain/value-objects/test-quality-semantics.ts +58 -0
- package/scripts/harness/validator-system/domain/value-objects/validator-id.ts +2 -0
- package/scripts/harness/validator-system/infrastructure/adapters/biome-ast-test-quality-analyzer-adapter.ts +439 -21
- package/scripts/harness/validator-system/infrastructure/adapters/harness-config-validator-config-adapter.ts +1 -1
- package/scripts/harness/validator-system/infrastructure/adapters/traceability-work-item-status-policy-adapter.ts +52 -0
- package/templates/.husky/pre-push +1 -0
|
@@ -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[] };
|
|
@@ -38,7 +38,7 @@ export class HarnessConfigValidatorConfigAdapter implements ValidatorConfigPort
|
|
|
38
38
|
const layerData = this.config.layers?.[layer] ?? {};
|
|
39
39
|
|
|
40
40
|
const defaultValidators: Record<string, string[]> = {
|
|
41
|
-
L2: ['L2-001', 'L2-002', 'L2-003', 'L2-013'],
|
|
41
|
+
L2: ['L2-001', 'L2-002', 'L2-003', 'L2-013', 'L2-014'],
|
|
42
42
|
L3: ['L3-001', 'L3-002', 'L3-003', 'L3-004'],
|
|
43
43
|
L4: ['L4-001', 'L4-002', 'L4-003', 'L4-004', 'L4-005'],
|
|
44
44
|
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @layer infrastructure
|
|
3
|
+
* @unit validator-system
|
|
4
|
+
* @work-item-id WI-140
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createTraceabilityModelModule } from "../../../traceability-model/composition-root.js";
|
|
8
|
+
import type { WorkItemStatusPolicyPort } from "../../domain/ports/work-item-status-policy-port.js";
|
|
9
|
+
import type { WorkItemStatusReport } from "../../../traceability-model/domain/value-objects/work-item-status-report.js";
|
|
10
|
+
|
|
11
|
+
export class TraceabilityWorkItemStatusPolicyAdapter implements WorkItemStatusPolicyPort {
|
|
12
|
+
private readonly rootDir: string;
|
|
13
|
+
|
|
14
|
+
constructor(rootDir: string) {
|
|
15
|
+
this.rootDir = rootDir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async findStaleReports(targetPaths: readonly string[] = []): Promise<readonly WorkItemStatusReport[]> {
|
|
19
|
+
if (targetPaths.length === 0) return Object.freeze([]);
|
|
20
|
+
|
|
21
|
+
const traceability = createTraceabilityModelModule(this.rootDir);
|
|
22
|
+
const output = await traceability.workItemStatusCommandHandler.execute({ dryRun: true });
|
|
23
|
+
const staleReports = output.reports.filter(
|
|
24
|
+
(report) => report.stale && report.evidence.hasRequiredInceptionArtifacts,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const normalizedTargets = targetPaths.map((targetPath) => targetPath.replace(/^\.\//, ""));
|
|
28
|
+
const explicitlyTargetedWorkItems = this.extractTargetedWorkItemIds(normalizedTargets);
|
|
29
|
+
if (explicitlyTargetedWorkItems.size > 0) {
|
|
30
|
+
return Object.freeze(staleReports.filter((report) => explicitlyTargetedWorkItems.has(report.id)));
|
|
31
|
+
}
|
|
32
|
+
return Object.freeze(staleReports.filter((report) => this.matchesAnyTarget(report, normalizedTargets)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private extractTargetedWorkItemIds(targetPaths: readonly string[]): ReadonlySet<string> {
|
|
36
|
+
const ids = new Set<string>();
|
|
37
|
+
for (const targetPath of targetPaths) {
|
|
38
|
+
const match = /^docs\/inception\/.+\/(WI-\d+)\/description\.md$/.exec(targetPath);
|
|
39
|
+
if (match) ids.add(match[1]);
|
|
40
|
+
}
|
|
41
|
+
return ids;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private matchesAnyTarget(report: WorkItemStatusReport, targetPaths: readonly string[]): boolean {
|
|
45
|
+
const evidencePaths = [
|
|
46
|
+
report.descriptionPath,
|
|
47
|
+
...report.evidence.implementationPaths,
|
|
48
|
+
...report.evidence.testPaths,
|
|
49
|
+
];
|
|
50
|
+
return targetPaths.some((targetPath) => evidencePaths.some((evidencePath) => evidencePath === targetPath));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
npx phasegate bypass:audit --base origin/main --head HEAD
|