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.
Files changed (31) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.ja.md +5 -4
  3. package/README.md +6 -5
  4. package/docs/guide/layer-model.md +3 -2
  5. package/docs/guide/quick-vs-full-mode.md +1 -1
  6. package/docs/principles/testing-rules.md +18 -0
  7. package/docs/templates/ci/aidlc-gate.yml +26 -3
  8. package/docs/templates/hooks/pre-push +6 -0
  9. package/package.json +1 -1
  10. package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +2 -2
  11. package/scripts/harness/integrations/pre-commit.ts +251 -3
  12. package/scripts/harness/main.ts +23 -0
  13. package/scripts/harness/quick-mode/domain/value-objects/validator-relaxation-profile.ts +4 -3
  14. package/scripts/harness/quick-mode/infrastructure/adapters/harness-config-quick-mode-config-adapter.ts +2 -1
  15. package/scripts/harness/quick-mode/infrastructure/adapters/validator-system-validator-id-registry-adapter.ts +2 -1
  16. package/scripts/harness/setup/skill-deployer.ts +19 -0
  17. package/scripts/harness/traceability-model/application/usecases/apply-work-item-status-usecase.ts +29 -4
  18. package/scripts/harness/traceability-model/domain/services/work-item-status-derivation-service.ts +20 -4
  19. package/scripts/harness/traceability-model/domain/value-objects/work-item-status-report.ts +10 -1
  20. package/scripts/harness/traceability-model/infrastructure/gateways/file-system-work-item-status-gateway.ts +2 -1
  21. package/scripts/harness/traceability-model/presentation/cli/work-item-status-command-handler.ts +13 -4
  22. package/scripts/harness/validator-system/application/mappers/validation-result-contract-mapper.ts +8 -4
  23. package/scripts/harness/validator-system/application/use-cases/run-l2-validators-usecase.ts +28 -0
  24. package/scripts/harness/validator-system/composition-root.ts +5 -1
  25. package/scripts/harness/validator-system/domain/ports/work-item-status-policy-port.ts +11 -0
  26. package/scripts/harness/validator-system/domain/value-objects/test-quality-semantics.ts +58 -0
  27. package/scripts/harness/validator-system/domain/value-objects/validator-id.ts +2 -0
  28. package/scripts/harness/validator-system/infrastructure/adapters/biome-ast-test-quality-analyzer-adapter.ts +439 -21
  29. package/scripts/harness/validator-system/infrastructure/adapters/harness-config-validator-config-adapter.ts +1 -1
  30. package/scripts/harness/validator-system/infrastructure/adapters/traceability-work-item-status-policy-adapter.ts +52 -0
  31. 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
- // Matches it('...') or test('...') lines
16
- const IT_OR_TEST_LINE = /^\s*(?:it|test)\s*\(\s*['"`](.*?)['"`]/;
32
+ export interface TestQualityAnalyzerOptions {
33
+ readonly weakAssertionStrengths?: readonly AssertionStrength[];
34
+ }
17
35
 
18
- function createViolation(code: string, message: string, suggestion: string): HarnessErrorLike {
36
+ function createViolation(message: string, suggestion: string): HarnessErrorLike {
19
37
  return {
20
- code: { value: code, toString: () => code },
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 analyzeContent(filePath: string, content: string): HarnessErrorLike[] {
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 lines = content.split('\n');
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
- for (let i = 0; i < lines.length; i++) {
32
- const itMatch = lines[i].match(IT_OR_TEST_LINE);
33
- if (itMatch && !JAPANESE_CHAR.test(itMatch[1])) {
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
- 'L2-003',
36
- `テスト名が日本語ではありません: "${itMatch[1]}" at ${filePath}:${i + 1}`,
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
- // If file uses expect() but lacks `const actual` assignment
43
- if (/\bexpect\s*\(/.test(content) && !/\bconst\s+actual\b/.test(content)) {
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
- 'L2-003',
46
- `\`actual\` 変数が未使用: ${filePath}`,
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