phasegate 0.149.0 → 0.150.1
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 +13 -0
- package/README.ja.md +7 -6
- package/README.md +7 -6
- package/docs/guide/cli-reference.md +15 -2
- package/docs/guide/installation.md +1 -1
- package/docs/guide/layer-model.md +11 -2
- package/docs/guide/quick-vs-full-mode.md +1 -1
- package/docs/guide/retrofit-adoption.md +1 -1
- package/docs/guide/skills-overview.md +8 -1
- package/docs/templates/ci/consistency-check.yml +6 -6
- package/package.json +1 -1
- package/scripts/harness/agent-integration/presentation/hook-skip-event-recorder.ts +31 -0
- package/scripts/harness/agent-integration/presentation/post-tool-use-hook.ts +7 -0
- package/scripts/harness/agent-integration/presentation/stop-hook.ts +7 -0
- package/scripts/harness/ci-governance/domain/ports/validator-id-registry-port.ts +1 -0
- package/scripts/harness/ci-governance/domain/services/template-generator.ts +2 -1
- package/scripts/harness/ci-governance/infrastructure/adapters/validator-id-registry-adapter.ts +31 -3
- package/scripts/harness/harness-api/domain/services/command-dispatch-service.ts +48 -1
- package/scripts/harness/harness-api/domain/services/status-derivation-service.ts +7 -1
- package/scripts/harness/harness-api/domain/value-objects/harness-status-summary.ts +43 -0
- package/scripts/harness/harness-api/infrastructure/adapters/harness-config-query-adapter.ts +91 -1
- package/scripts/harness/main.ts +16 -0
- package/scripts/harness/nyquist-validation/application/dto/generate-matrix-output.ts +71 -0
- package/scripts/harness/nyquist-validation/application/usecases/generate-requirement-test-matrix-usecase.ts +161 -0
- package/scripts/harness/nyquist-validation/composition-root.ts +24 -1
- package/scripts/harness/nyquist-validation/domain/services/requirement-intent-coverage-service.ts +42 -0
- package/scripts/harness/nyquist-validation/index.ts +9 -1
- package/scripts/harness/nyquist-validation/infrastructure/adapters/file-system-generated-matrix-adapter.ts +24 -0
- package/scripts/harness/nyquist-validation/infrastructure/adapters/markdown-requirement-source-adapter.ts +37 -0
- package/scripts/harness/nyquist-validation/infrastructure/adapters/type-script-test-reference-source-adapter.ts +54 -0
- package/scripts/harness/nyquist-validation/presentation/handlers/generate-matrix-handler.ts +34 -0
- package/scripts/harness/validator-system/composition-root.ts +1 -1
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// @layer infrastructure
|
|
2
2
|
// @unit harness-api
|
|
3
|
+
// @work-item-id WI-123
|
|
3
4
|
// harness-config-query-adapter.ts — HarnessConfigQueryAdapter
|
|
4
5
|
|
|
5
6
|
import * as fs from 'node:fs/promises';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
9
|
import type { ConfigQueryPort } from '../../domain/ports/config-query-port.js';
|
|
7
|
-
import type { PresetInfo, ConfigSummary, PhaseGateSummary } from '../../domain/value-objects/harness-status-summary.js';
|
|
10
|
+
import type { PresetInfo, ConfigSummary, PhaseGateSummary, HookHealth, BaselineHealth } from '../../domain/value-objects/harness-status-summary.js';
|
|
8
11
|
import type { LayerId } from '../../domain/value-objects/layer-health.js';
|
|
9
12
|
|
|
10
13
|
interface HarnessConfigJson {
|
|
@@ -18,6 +21,10 @@ interface HarnessConfigJson {
|
|
|
18
21
|
designDocs?: string;
|
|
19
22
|
integrationTests?: string;
|
|
20
23
|
};
|
|
24
|
+
baseline?: {
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
path?: string;
|
|
27
|
+
};
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
const PRESET_LAYERS: Record<string, LayerId[]> = {
|
|
@@ -77,4 +84,87 @@ export class HarnessConfigQueryAdapter implements ConfigQueryPort {
|
|
|
77
84
|
// wave2-pending: integrate with phase-dependency-model
|
|
78
85
|
return { totalStories: 0, passedStories: 0, pendingStories: 0 };
|
|
79
86
|
}
|
|
87
|
+
|
|
88
|
+
async getHookHealth(): Promise<HookHealth> {
|
|
89
|
+
const rootDir = dirname(this.configPath);
|
|
90
|
+
const configuredHooks: string[] = [];
|
|
91
|
+
for (const hookPath of ['.claude/settings.json', '.codex/hooks.json', '.husky/pre-commit', '.husky/pre-push']) {
|
|
92
|
+
try {
|
|
93
|
+
await fs.access(join(rootDir, hookPath));
|
|
94
|
+
configuredHooks.push(hookPath);
|
|
95
|
+
} catch {
|
|
96
|
+
// Missing hook files are represented by absence from configuredHooks.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const skipEvents = await readHookSkipEvents(join(rootDir, '.phasegate/hook-skip-events.jsonl'));
|
|
101
|
+
const skipCountsByReason: Record<string, number> = {};
|
|
102
|
+
for (const event of skipEvents) {
|
|
103
|
+
skipCountsByReason[event.reason] = (skipCountsByReason[event.reason] ?? 0) + 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
enabled: configuredHooks.length > 0,
|
|
108
|
+
configuredHooks,
|
|
109
|
+
latestSkip: skipEvents.at(-1) ?? null,
|
|
110
|
+
skipCountsByReason,
|
|
111
|
+
applyPatchBypass: {
|
|
112
|
+
nativeApplyPatchIntercepted: false,
|
|
113
|
+
backstop: 'pre-commit',
|
|
114
|
+
documentationUrl: 'docs/guide/codex-integration.md',
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async getBaselineHealth(): Promise<BaselineHealth> {
|
|
120
|
+
const config = await this.readConfig();
|
|
121
|
+
const rootDir = dirname(this.configPath);
|
|
122
|
+
const enabled = config.baseline?.enabled ?? true;
|
|
123
|
+
const relativePath = config.baseline?.path ?? '.phasegate/baseline.json';
|
|
124
|
+
const baselinePath = resolve(rootDir, relativePath);
|
|
125
|
+
|
|
126
|
+
if (!enabled) {
|
|
127
|
+
return { enabled: false, path: relativePath, grandfatheredFileCount: 0, shaMismatchCount: 0, missingFileCount: 0, removalRate: 0 };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let raw: string;
|
|
131
|
+
try {
|
|
132
|
+
raw = await fs.readFile(baselinePath, 'utf-8');
|
|
133
|
+
} catch {
|
|
134
|
+
return { enabled: true, path: relativePath, grandfatheredFileCount: 0, shaMismatchCount: 0, missingFileCount: 0, removalRate: 0 };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const parsed = JSON.parse(raw) as { files?: Array<{ path?: string; sha1?: string }>; entries?: Array<{ path?: string; sha1?: string }> };
|
|
138
|
+
const entries = parsed.files ?? parsed.entries ?? [];
|
|
139
|
+
let shaMismatchCount = 0;
|
|
140
|
+
let missingFileCount = 0;
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
if (typeof entry.path !== 'string' || typeof entry.sha1 !== 'string') continue;
|
|
143
|
+
try {
|
|
144
|
+
const current = await fs.readFile(resolve(rootDir, entry.path));
|
|
145
|
+
const sha1 = createHash('sha1').update(current).digest('hex');
|
|
146
|
+
if (sha1 !== entry.sha1) shaMismatchCount += 1;
|
|
147
|
+
} catch {
|
|
148
|
+
missingFileCount += 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const grandfatheredFileCount = entries.length;
|
|
152
|
+
const removalRate = grandfatheredFileCount === 0
|
|
153
|
+
? 1
|
|
154
|
+
: (shaMismatchCount + missingFileCount) / grandfatheredFileCount;
|
|
155
|
+
return { enabled: true, path: relativePath, grandfatheredFileCount, shaMismatchCount, missingFileCount, removalRate };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function readHookSkipEvents(filePath: string): Promise<Array<NonNullable<HookHealth['latestSkip']>>> {
|
|
160
|
+
try {
|
|
161
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
162
|
+
return raw
|
|
163
|
+
.split('\n')
|
|
164
|
+
.filter((line) => line.trim().length > 0)
|
|
165
|
+
.map((line) => JSON.parse(line) as HookHealth['latestSkip'])
|
|
166
|
+
.filter((event): event is NonNullable<HookHealth['latestSkip']> => event !== null);
|
|
167
|
+
} catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
80
170
|
}
|
package/scripts/harness/main.ts
CHANGED
|
@@ -185,6 +185,7 @@ Commands:
|
|
|
185
185
|
phasegate:lint Lint via harness-api (--target <path>, --json)
|
|
186
186
|
phasegate:complete-check Complete L2-L4 check (--json)
|
|
187
187
|
phasegate:impact-analysis Impact analysis for story (<storyId>, --json)
|
|
188
|
+
phasegate:generate-matrix Generate requirement-test matrix (--requirements, --tests, --out, --json)
|
|
188
189
|
|
|
189
190
|
ci:generate-template Generate CI template (--preset <id>, default: standard; --type <aidlc-gate|consistency-check|pre-commit|agent-context-refresh>, --render, --json)
|
|
190
191
|
ci:migrate-agents-md Migrate AGENTS.md (--dry-run, --validate-only, --json)
|
|
@@ -1652,6 +1653,21 @@ async function main(): Promise<void> {
|
|
|
1652
1653
|
break;
|
|
1653
1654
|
}
|
|
1654
1655
|
|
|
1656
|
+
case "phasegate:generate-matrix": {
|
|
1657
|
+
const { createNyquistValidationModule } = await import("./nyquist-validation/composition-root.js");
|
|
1658
|
+
const mod = createNyquistValidationModule({
|
|
1659
|
+
getStoryIds: async () => [],
|
|
1660
|
+
});
|
|
1661
|
+
const flags: Record<string, boolean | string> = {};
|
|
1662
|
+
if (json) flags.json = true;
|
|
1663
|
+
await mod.handlers.generateMatrixHandler.handle({
|
|
1664
|
+
requirementsPath: parseFlag(args, "--requirements") ?? "docs/product/user_stories.md",
|
|
1665
|
+
testRoot: parseFlag(args, "--tests") ?? "scripts/harness/__tests__",
|
|
1666
|
+
matrixFilePath: parseFlag(args, "--out") ?? ".harness/requirement-test-matrix.json",
|
|
1667
|
+
}, flags);
|
|
1668
|
+
break;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1655
1671
|
// ── ci-governance ──
|
|
1656
1672
|
case "ci:generate-template": {
|
|
1657
1673
|
if (hasFlag(args, "--help")) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// @layer application
|
|
2
|
+
// @unit nyquist-validation
|
|
3
|
+
// @work-item-id WI-125
|
|
4
|
+
// @work-item-id WI-131
|
|
5
|
+
|
|
6
|
+
export interface RequirementSourceDto {
|
|
7
|
+
readonly storyId: string;
|
|
8
|
+
readonly acIds: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TestReferenceSourceDto {
|
|
12
|
+
readonly storyId: string;
|
|
13
|
+
readonly filePath: string;
|
|
14
|
+
readonly testType: 'unit' | 'it' | 'scenario';
|
|
15
|
+
readonly testName?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MatrixTestReferenceDto {
|
|
19
|
+
readonly filePath: string;
|
|
20
|
+
readonly testType: 'unit' | 'it' | 'scenario';
|
|
21
|
+
readonly testName?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MatrixAcMappingDto {
|
|
25
|
+
readonly acId: string;
|
|
26
|
+
readonly testReferences: readonly MatrixTestReferenceDto[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MatrixStoryDto {
|
|
30
|
+
readonly storyId: string;
|
|
31
|
+
readonly storyMappings: readonly MatrixAcMappingDto[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RequirementTestMatrixDto {
|
|
35
|
+
readonly version: string;
|
|
36
|
+
readonly generatedAt: string;
|
|
37
|
+
readonly stories: readonly MatrixStoryDto[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MissingTestDto {
|
|
41
|
+
readonly storyId: string;
|
|
42
|
+
readonly acId: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface OrphanTestDto {
|
|
46
|
+
readonly storyId: string;
|
|
47
|
+
readonly filePath: string;
|
|
48
|
+
readonly testName?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type IntentCoverageStatus = 'observed' | 'weakly-observed' | 'unobserved';
|
|
52
|
+
|
|
53
|
+
export interface IntentCoverageItemDto {
|
|
54
|
+
readonly storyId: string;
|
|
55
|
+
readonly acId: string;
|
|
56
|
+
readonly status: IntentCoverageStatus;
|
|
57
|
+
readonly warnings: readonly string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface MatrixGenerationReportDto {
|
|
61
|
+
readonly missingTests: readonly MissingTestDto[];
|
|
62
|
+
readonly orphanTests: readonly OrphanTestDto[];
|
|
63
|
+
readonly unknownStories: readonly string[];
|
|
64
|
+
readonly preservedReferences: number;
|
|
65
|
+
readonly intentCoverage: readonly IntentCoverageItemDto[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface GenerateMatrixOutput {
|
|
69
|
+
readonly matrix: RequirementTestMatrixDto;
|
|
70
|
+
readonly report: MatrixGenerationReportDto;
|
|
71
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @layer application
|
|
2
|
+
// @unit nyquist-validation
|
|
3
|
+
// @work-item-id WI-125
|
|
4
|
+
// @work-item-id WI-131
|
|
5
|
+
|
|
6
|
+
import type { RequirementIntentCoverageService } from '../../domain/services/requirement-intent-coverage-service.js';
|
|
7
|
+
import type {
|
|
8
|
+
GenerateMatrixOutput,
|
|
9
|
+
MatrixAcMappingDto,
|
|
10
|
+
MatrixStoryDto,
|
|
11
|
+
MatrixTestReferenceDto,
|
|
12
|
+
RequirementSourceDto,
|
|
13
|
+
RequirementTestMatrixDto,
|
|
14
|
+
TestReferenceSourceDto,
|
|
15
|
+
} from '../dto/generate-matrix-output.js';
|
|
16
|
+
|
|
17
|
+
export interface RequirementSourcePort {
|
|
18
|
+
readRequirements(sourcePath: string): Promise<readonly RequirementSourceDto[]>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TestReferenceSourcePort {
|
|
22
|
+
readTestReferences(testRoot: string): Promise<readonly TestReferenceSourceDto[]>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ExistingMatrixPort {
|
|
26
|
+
readExistingMatrix(matrixFilePath: string): Promise<RequirementTestMatrixDto | null>;
|
|
27
|
+
writeMatrix(matrixFilePath: string, matrix: RequirementTestMatrixDto): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GenerateRequirementTestMatrixInput {
|
|
31
|
+
readonly requirementsPath: string;
|
|
32
|
+
readonly testRoot: string;
|
|
33
|
+
readonly matrixFilePath: string;
|
|
34
|
+
readonly write: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GenerateRequirementTestMatrixUseCaseDeps {
|
|
38
|
+
readonly requirementSourcePort: RequirementSourcePort;
|
|
39
|
+
readonly testReferenceSourcePort: TestReferenceSourcePort;
|
|
40
|
+
readonly matrixPort: ExistingMatrixPort;
|
|
41
|
+
readonly intentCoverageService: RequirementIntentCoverageService;
|
|
42
|
+
readonly now?: () => Date;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function referenceKey(reference: MatrixTestReferenceDto): string {
|
|
46
|
+
return `${reference.filePath}\0${reference.testType}\0${reference.testName ?? ''}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mergeReferences(
|
|
50
|
+
generated: readonly MatrixTestReferenceDto[],
|
|
51
|
+
existing: readonly MatrixTestReferenceDto[],
|
|
52
|
+
): { references: readonly MatrixTestReferenceDto[]; preserved: number } {
|
|
53
|
+
const result = [...generated];
|
|
54
|
+
const keys = new Set(result.map(referenceKey));
|
|
55
|
+
let preserved = 0;
|
|
56
|
+
for (const reference of existing) {
|
|
57
|
+
if (!keys.has(referenceKey(reference))) {
|
|
58
|
+
result.push(reference);
|
|
59
|
+
keys.add(referenceKey(reference));
|
|
60
|
+
preserved += 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { references: Object.freeze(result), preserved };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeExistingReferences(
|
|
67
|
+
existingMatrix: RequirementTestMatrixDto | null,
|
|
68
|
+
storyId: string,
|
|
69
|
+
acId: string,
|
|
70
|
+
): readonly MatrixTestReferenceDto[] {
|
|
71
|
+
const story = existingMatrix?.stories.find((item) => item.storyId === storyId);
|
|
72
|
+
const mapping = story?.storyMappings.find((item) => item.acId === acId);
|
|
73
|
+
return mapping?.testReferences ?? [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class GenerateRequirementTestMatrixUseCase {
|
|
77
|
+
private readonly requirementSourcePort: RequirementSourcePort;
|
|
78
|
+
private readonly testReferenceSourcePort: TestReferenceSourcePort;
|
|
79
|
+
private readonly matrixPort: ExistingMatrixPort;
|
|
80
|
+
private readonly intentCoverageService: RequirementIntentCoverageService;
|
|
81
|
+
private readonly now: () => Date;
|
|
82
|
+
|
|
83
|
+
constructor(deps: GenerateRequirementTestMatrixUseCaseDeps) {
|
|
84
|
+
this.requirementSourcePort = deps.requirementSourcePort;
|
|
85
|
+
this.testReferenceSourcePort = deps.testReferenceSourcePort;
|
|
86
|
+
this.matrixPort = deps.matrixPort;
|
|
87
|
+
this.intentCoverageService = deps.intentCoverageService;
|
|
88
|
+
this.now = deps.now ?? (() => new Date());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async execute(input: GenerateRequirementTestMatrixInput): Promise<GenerateMatrixOutput> {
|
|
92
|
+
const [requirements, testReferences, existingMatrix] = await Promise.all([
|
|
93
|
+
this.requirementSourcePort.readRequirements(input.requirementsPath),
|
|
94
|
+
this.testReferenceSourcePort.readTestReferences(input.testRoot),
|
|
95
|
+
this.matrixPort.readExistingMatrix(input.matrixFilePath),
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const knownStories = new Set(requirements.map((requirement) => requirement.storyId));
|
|
99
|
+
const orphanTests = testReferences
|
|
100
|
+
.filter((reference) => !knownStories.has(reference.storyId))
|
|
101
|
+
.map((reference) => ({
|
|
102
|
+
storyId: reference.storyId,
|
|
103
|
+
filePath: reference.filePath,
|
|
104
|
+
testName: reference.testName,
|
|
105
|
+
}));
|
|
106
|
+
const referencesByStory = new Map<string, TestReferenceSourceDto[]>();
|
|
107
|
+
for (const reference of testReferences) {
|
|
108
|
+
if (!knownStories.has(reference.storyId)) continue;
|
|
109
|
+
const current = referencesByStory.get(reference.storyId) ?? [];
|
|
110
|
+
current.push(reference);
|
|
111
|
+
referencesByStory.set(reference.storyId, current);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let preservedReferences = 0;
|
|
115
|
+
const stories: MatrixStoryDto[] = requirements.map((requirement) => {
|
|
116
|
+
const storyReferences = referencesByStory.get(requirement.storyId) ?? [];
|
|
117
|
+
const storyMappings: MatrixAcMappingDto[] = requirement.acIds.map((acId) => {
|
|
118
|
+
const generatedReferences = storyReferences.map((reference) => ({
|
|
119
|
+
filePath: reference.filePath,
|
|
120
|
+
testType: reference.testType,
|
|
121
|
+
testName: reference.testName,
|
|
122
|
+
}));
|
|
123
|
+
const existingReferences = normalizeExistingReferences(existingMatrix, requirement.storyId, acId);
|
|
124
|
+
const merged = mergeReferences(generatedReferences, existingReferences);
|
|
125
|
+
preservedReferences += merged.preserved;
|
|
126
|
+
return {
|
|
127
|
+
acId,
|
|
128
|
+
testReferences: merged.references,
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
storyId: requirement.storyId,
|
|
133
|
+
storyMappings: Object.freeze(storyMappings),
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const matrix: RequirementTestMatrixDto = {
|
|
138
|
+
version: '1.0',
|
|
139
|
+
generatedAt: this.now().toISOString(),
|
|
140
|
+
stories: Object.freeze(stories),
|
|
141
|
+
};
|
|
142
|
+
const intentCoverage = this.intentCoverageService.evaluate(matrix.stories);
|
|
143
|
+
const missingTests = matrix.stories.flatMap((story) => story.storyMappings
|
|
144
|
+
.filter((mapping) => mapping.testReferences.length === 0)
|
|
145
|
+
.map((mapping) => ({ storyId: story.storyId, acId: mapping.acId })));
|
|
146
|
+
|
|
147
|
+
const report = {
|
|
148
|
+
missingTests,
|
|
149
|
+
orphanTests,
|
|
150
|
+
unknownStories: [...new Set(orphanTests.map((test) => test.storyId))],
|
|
151
|
+
preservedReferences,
|
|
152
|
+
intentCoverage,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (input.write) {
|
|
156
|
+
await this.matrixPort.writeMatrix(input.matrixFilePath, matrix);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { matrix, report };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* 全コンポーネントを生成・配線し、UseCase群を外部に公開する。
|
|
7
7
|
*/
|
|
8
8
|
import { FileSystemMatrixFileAdapter } from './infrastructure/adapters/file-system-matrix-file-adapter.js';
|
|
9
|
+
import { FileSystemGeneratedMatrixAdapter } from './infrastructure/adapters/file-system-generated-matrix-adapter.js';
|
|
10
|
+
import { MarkdownRequirementSourceAdapter } from './infrastructure/adapters/markdown-requirement-source-adapter.js';
|
|
11
|
+
import { TypeScriptTestReferenceSourceAdapter } from './infrastructure/adapters/type-script-test-reference-source-adapter.js';
|
|
9
12
|
import { AjvJsonSchemaValidatorAdapter } from './infrastructure/adapters/ajv-json-schema-validator-adapter.js';
|
|
10
13
|
import { ConfigFoundationCoverageThresholdAdapter } from './infrastructure/adapters/config-foundation-coverage-threshold-adapter.js';
|
|
11
14
|
import { TraceabilityModelStoryRegistryAdapter } from './infrastructure/adapters/traceability-model-story-registry-adapter.js';
|
|
@@ -13,14 +16,17 @@ import { MatrixValidationService } from './domain/services/matrix-validation-ser
|
|
|
13
16
|
import { AcCoverageGatePolicy } from './domain/services/ac-coverage-gate-policy.js';
|
|
14
17
|
import { CoverageCalculationService } from './domain/services/coverage-calculation-service.js';
|
|
15
18
|
import { ImpactAnalysisService } from './domain/services/impact-analysis-service.js';
|
|
19
|
+
import { RequirementIntentCoverageService } from './domain/services/requirement-intent-coverage-service.js';
|
|
16
20
|
import { ValidateMatrixUseCase } from './application/usecases/validate-matrix-usecase.js';
|
|
17
21
|
import { CheckAcCoverageGateUseCase } from './application/usecases/check-ac-coverage-gate-usecase.js';
|
|
18
22
|
import { CalculateCoverageUseCase } from './application/usecases/calculate-coverage-usecase.js';
|
|
19
23
|
import { AnalyzeImpactUseCase } from './application/usecases/analyze-impact-usecase.js';
|
|
24
|
+
import { GenerateRequirementTestMatrixUseCase } from './application/usecases/generate-requirement-test-matrix-usecase.js';
|
|
20
25
|
import { ValidateMatrixHandler } from './presentation/handlers/validate-matrix-handler.js';
|
|
21
26
|
import { CheckAcCoverageGateHandler } from './presentation/handlers/check-ac-coverage-gate-handler.js';
|
|
22
27
|
import { CalculateCoverageHandler } from './presentation/handlers/calculate-coverage-handler.js';
|
|
23
28
|
import { AnalyzeImpactHandler } from './presentation/handlers/analyze-impact-handler.js';
|
|
29
|
+
import { GenerateMatrixHandler } from './presentation/handlers/generate-matrix-handler.js';
|
|
24
30
|
|
|
25
31
|
export interface NyquistValidationModuleDeps {
|
|
26
32
|
/** traceability-model の storyCatalog.getAllStoryIds() を渡す */
|
|
@@ -32,6 +38,9 @@ export interface NyquistValidationModuleDeps {
|
|
|
32
38
|
export function createNyquistValidationModule(deps: NyquistValidationModuleDeps) {
|
|
33
39
|
// Infrastructure adapters
|
|
34
40
|
const matrixFilePort = new FileSystemMatrixFileAdapter();
|
|
41
|
+
const generatedMatrixPort = new FileSystemGeneratedMatrixAdapter();
|
|
42
|
+
const requirementSourcePort = new MarkdownRequirementSourceAdapter();
|
|
43
|
+
const testReferenceSourcePort = new TypeScriptTestReferenceSourceAdapter();
|
|
35
44
|
const ajvValidator = new AjvJsonSchemaValidatorAdapter();
|
|
36
45
|
const coverageThresholdPort = new ConfigFoundationCoverageThresholdAdapter({
|
|
37
46
|
getPreset: deps.getPreset,
|
|
@@ -45,6 +54,7 @@ export function createNyquistValidationModule(deps: NyquistValidationModuleDeps)
|
|
|
45
54
|
const acCoverageGatePolicy = new AcCoverageGatePolicy();
|
|
46
55
|
const coverageCalculationService = new CoverageCalculationService();
|
|
47
56
|
const impactAnalysisService = new ImpactAnalysisService();
|
|
57
|
+
const intentCoverageService = new RequirementIntentCoverageService();
|
|
48
58
|
|
|
49
59
|
// Application UseCases
|
|
50
60
|
const validateMatrixUseCase = new ValidateMatrixUseCase({
|
|
@@ -75,6 +85,13 @@ export function createNyquistValidationModule(deps: NyquistValidationModuleDeps)
|
|
|
75
85
|
impactAnalysisService,
|
|
76
86
|
});
|
|
77
87
|
|
|
88
|
+
const generateMatrixUseCase = new GenerateRequirementTestMatrixUseCase({
|
|
89
|
+
requirementSourcePort,
|
|
90
|
+
testReferenceSourcePort,
|
|
91
|
+
matrixPort: generatedMatrixPort,
|
|
92
|
+
intentCoverageService,
|
|
93
|
+
});
|
|
94
|
+
|
|
78
95
|
const validateMatrixHandler = new ValidateMatrixHandler({
|
|
79
96
|
validateMatrixUseCase,
|
|
80
97
|
});
|
|
@@ -91,18 +108,24 @@ export function createNyquistValidationModule(deps: NyquistValidationModuleDeps)
|
|
|
91
108
|
analyzeImpactUseCase,
|
|
92
109
|
});
|
|
93
110
|
|
|
111
|
+
const generateMatrixHandler = new GenerateMatrixHandler({
|
|
112
|
+
useCase: generateMatrixUseCase,
|
|
113
|
+
});
|
|
114
|
+
|
|
94
115
|
return {
|
|
95
116
|
validateMatrixUseCase,
|
|
96
117
|
checkAcCoverageGateUseCase,
|
|
97
118
|
calculateCoverageUseCase,
|
|
98
119
|
analyzeImpactUseCase,
|
|
120
|
+
generateMatrixUseCase,
|
|
99
121
|
handlers: {
|
|
100
122
|
validateMatrixHandler,
|
|
101
123
|
checkAcCoverageGateHandler,
|
|
102
124
|
calculateCoverageHandler,
|
|
103
125
|
analyzeImpactHandler,
|
|
126
|
+
generateMatrixHandler,
|
|
104
127
|
},
|
|
105
128
|
} as const;
|
|
106
129
|
}
|
|
107
130
|
|
|
108
|
-
// @story-id H08-07
|
|
131
|
+
// @story-id H08-07
|
package/scripts/harness/nyquist-validation/domain/services/requirement-intent-coverage-service.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @layer domain
|
|
2
|
+
// @unit nyquist-validation
|
|
3
|
+
// @work-item-id WI-131
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
IntentCoverageItemDto,
|
|
7
|
+
MatrixStoryDto,
|
|
8
|
+
} from '../../application/dto/generate-matrix-output.js';
|
|
9
|
+
|
|
10
|
+
export class RequirementIntentCoverageService {
|
|
11
|
+
evaluate(stories: readonly MatrixStoryDto[]): readonly IntentCoverageItemDto[] {
|
|
12
|
+
return stories.flatMap((story) => story.storyMappings.map((mapping) => {
|
|
13
|
+
if (mapping.testReferences.length === 0) {
|
|
14
|
+
return {
|
|
15
|
+
storyId: story.storyId,
|
|
16
|
+
acId: mapping.acId,
|
|
17
|
+
status: 'unobserved' as const,
|
|
18
|
+
warnings: ['No test reference observes this acceptance criterion.'],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const hasNamedReference = mapping.testReferences.some((reference) => (
|
|
23
|
+
typeof reference.testName === 'string' && reference.testName.trim().length > 0
|
|
24
|
+
));
|
|
25
|
+
if (!hasNamedReference) {
|
|
26
|
+
return {
|
|
27
|
+
storyId: story.storyId,
|
|
28
|
+
acId: mapping.acId,
|
|
29
|
+
status: 'weakly-observed' as const,
|
|
30
|
+
warnings: ['Test reference exists, but testName is missing; observation intent is weak.'],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
storyId: story.storyId,
|
|
36
|
+
acId: mapping.acId,
|
|
37
|
+
status: 'weakly-observed' as const,
|
|
38
|
+
warnings: ['Test reference and testName exist, but assertion target / expected outcome evidence is not yet attached.'],
|
|
39
|
+
};
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -22,5 +22,13 @@ export type {
|
|
|
22
22
|
} from './presentation/handlers/calculate-coverage-handler.js';
|
|
23
23
|
export { AnalyzeImpactHandler } from './presentation/handlers/analyze-impact-handler.js';
|
|
24
24
|
export type { AnalyzeImpactHandlerArgs, AnalyzeImpactHandlerDeps } from './presentation/handlers/analyze-impact-handler.js';
|
|
25
|
+
export { GenerateMatrixHandler } from './presentation/handlers/generate-matrix-handler.js';
|
|
26
|
+
export { GenerateRequirementTestMatrixUseCase } from './application/usecases/generate-requirement-test-matrix-usecase.js';
|
|
27
|
+
export { RequirementIntentCoverageService } from './domain/services/requirement-intent-coverage-service.js';
|
|
28
|
+
export type {
|
|
29
|
+
GenerateMatrixOutput,
|
|
30
|
+
RequirementTestMatrixDto,
|
|
31
|
+
MatrixGenerationReportDto,
|
|
32
|
+
} from './application/dto/generate-matrix-output.js';
|
|
25
33
|
|
|
26
|
-
// @story-id H08-07
|
|
34
|
+
// @story-id H08-07
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @layer infrastructure
|
|
2
|
+
// @unit nyquist-validation
|
|
3
|
+
// @work-item-id WI-125
|
|
4
|
+
|
|
5
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import type { RequirementTestMatrixDto } from '../../application/dto/generate-matrix-output.js';
|
|
8
|
+
import type { ExistingMatrixPort } from '../../application/usecases/generate-requirement-test-matrix-usecase.js';
|
|
9
|
+
|
|
10
|
+
export class FileSystemGeneratedMatrixAdapter implements ExistingMatrixPort {
|
|
11
|
+
async readExistingMatrix(matrixFilePath: string): Promise<RequirementTestMatrixDto | null> {
|
|
12
|
+
try {
|
|
13
|
+
const raw = await readFile(matrixFilePath, 'utf-8');
|
|
14
|
+
return JSON.parse(raw) as RequirementTestMatrixDto;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async writeMatrix(matrixFilePath: string, matrix: RequirementTestMatrixDto): Promise<void> {
|
|
21
|
+
await mkdir(path.dirname(matrixFilePath), { recursive: true });
|
|
22
|
+
await writeFile(matrixFilePath, `${JSON.stringify(matrix, null, 2)}\n`, 'utf-8');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @layer infrastructure
|
|
2
|
+
// @unit nyquist-validation
|
|
3
|
+
// @work-item-id WI-125
|
|
4
|
+
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import type { RequirementSourcePort } from '../../application/usecases/generate-requirement-test-matrix-usecase.js';
|
|
7
|
+
import type { RequirementSourceDto } from '../../application/dto/generate-matrix-output.js';
|
|
8
|
+
|
|
9
|
+
const STORY_HEADING = /^###\s+(H\d{2}-\d{2}):/;
|
|
10
|
+
const AC_ITEM = /^\s*-\s+\[[ xX]\]\s+(AC-[1-9][0-9]*):/;
|
|
11
|
+
|
|
12
|
+
export class MarkdownRequirementSourceAdapter implements RequirementSourcePort {
|
|
13
|
+
async readRequirements(sourcePath: string): Promise<readonly RequirementSourceDto[]> {
|
|
14
|
+
const content = await readFile(sourcePath, 'utf-8');
|
|
15
|
+
const results: RequirementSourceDto[] = [];
|
|
16
|
+
let current: { storyId: string; acIds: string[] } | null = null;
|
|
17
|
+
|
|
18
|
+
for (const line of content.split(/\r?\n/)) {
|
|
19
|
+
const storyMatch = line.match(STORY_HEADING);
|
|
20
|
+
if (storyMatch) {
|
|
21
|
+
if (current) {
|
|
22
|
+
results.push({ storyId: current.storyId, acIds: Object.freeze([...new Set(current.acIds)]) });
|
|
23
|
+
}
|
|
24
|
+
current = { storyId: storyMatch[1], acIds: [] };
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const acMatch = line.match(AC_ITEM);
|
|
28
|
+
if (current && acMatch) {
|
|
29
|
+
current.acIds.push(acMatch[1]);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (current) {
|
|
33
|
+
results.push({ storyId: current.storyId, acIds: Object.freeze([...new Set(current.acIds)]) });
|
|
34
|
+
}
|
|
35
|
+
return Object.freeze(results.filter((result) => result.acIds.length > 0));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @layer infrastructure
|
|
2
|
+
// @unit nyquist-validation
|
|
3
|
+
// @work-item-id WI-125
|
|
4
|
+
|
|
5
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import type { TestReferenceSourcePort } from '../../application/usecases/generate-requirement-test-matrix-usecase.js';
|
|
8
|
+
import type { TestReferenceSourceDto } from '../../application/dto/generate-matrix-output.js';
|
|
9
|
+
|
|
10
|
+
const STORY_TAG = /@story(?:-id)?\s+(H\d{2}-\d{2})/;
|
|
11
|
+
const TEST_NAME = /\b(?:it|test)(?:\.each\([^)]*\))?\s*\(\s*['"`]([^'"`]+)['"`]/g;
|
|
12
|
+
|
|
13
|
+
async function collectTestFiles(root: string): Promise<readonly string[]> {
|
|
14
|
+
const rootStat = await stat(root).catch(() => null);
|
|
15
|
+
if (rootStat === null) return [];
|
|
16
|
+
if (rootStat.isFile()) return /\.(test|spec)\.[cm]?[tj]sx?$/.test(root) ? [root] : [];
|
|
17
|
+
|
|
18
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
19
|
+
const nested = await Promise.all(entries
|
|
20
|
+
.filter((entry) => entry.name !== 'node_modules' && entry.name !== 'dist' && entry.name !== '.git')
|
|
21
|
+
.map((entry) => collectTestFiles(path.join(root, entry.name))));
|
|
22
|
+
return Object.freeze(nested.flat());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function classifyTestType(filePath: string): 'unit' | 'it' | 'scenario' {
|
|
26
|
+
const normalized = filePath.toLowerCase();
|
|
27
|
+
if (normalized.includes('/scenario/') || normalized.includes('.scenario.')) return 'scenario';
|
|
28
|
+
if (normalized.includes('/integration/') || normalized.includes('.it.') || normalized.includes('.integration.')) return 'it';
|
|
29
|
+
return 'unit';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class TypeScriptTestReferenceSourceAdapter implements TestReferenceSourcePort {
|
|
33
|
+
async readTestReferences(testRoot: string): Promise<readonly TestReferenceSourceDto[]> {
|
|
34
|
+
const files = await collectTestFiles(testRoot);
|
|
35
|
+
const references: TestReferenceSourceDto[] = [];
|
|
36
|
+
for (const filePath of files) {
|
|
37
|
+
const content = await readFile(filePath, 'utf-8');
|
|
38
|
+
const storyMatch = content.match(STORY_TAG);
|
|
39
|
+
if (!storyMatch) continue;
|
|
40
|
+
const storyId = storyMatch[1];
|
|
41
|
+
const relativePath = path.relative(process.cwd(), filePath).replaceAll(path.sep, '/');
|
|
42
|
+
const testType = classifyTestType(relativePath);
|
|
43
|
+
const names = [...content.matchAll(TEST_NAME)].map((match) => match[1]);
|
|
44
|
+
if (names.length === 0) {
|
|
45
|
+
references.push({ storyId, filePath: relativePath, testType });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
for (const testName of names) {
|
|
49
|
+
references.push({ storyId, filePath: relativePath, testType, testName });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return Object.freeze(references);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @layer presentation
|
|
2
|
+
// @unit nyquist-validation
|
|
3
|
+
// @work-item-id WI-125
|
|
4
|
+
// @work-item-id WI-131
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
GenerateRequirementTestMatrixInput,
|
|
8
|
+
GenerateRequirementTestMatrixUseCase,
|
|
9
|
+
} from '../../application/usecases/generate-requirement-test-matrix-usecase.js';
|
|
10
|
+
|
|
11
|
+
export interface GenerateMatrixHandlerDeps {
|
|
12
|
+
readonly useCase: GenerateRequirementTestMatrixUseCase;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class GenerateMatrixHandler {
|
|
16
|
+
private readonly useCase: GenerateRequirementTestMatrixUseCase;
|
|
17
|
+
|
|
18
|
+
constructor(deps: GenerateMatrixHandlerDeps) {
|
|
19
|
+
this.useCase = deps.useCase;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async handle(input: Omit<GenerateRequirementTestMatrixInput, 'write'>, flags: Record<string, boolean | string>): Promise<void> {
|
|
23
|
+
const output = await this.useCase.execute({ ...input, write: true });
|
|
24
|
+
if (flags.json === true) {
|
|
25
|
+
console.log(JSON.stringify(output, null, 2));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
console.log(`Requirement test matrix generated: ${input.matrixFilePath}`);
|
|
29
|
+
console.log(`Stories: ${output.matrix.stories.length}`);
|
|
30
|
+
console.log(`Missing tests: ${output.report.missingTests.length}`);
|
|
31
|
+
console.log(`Orphan tests: ${output.report.orphanTests.length}`);
|
|
32
|
+
console.log(`Preserved references: ${output.report.preservedReferences}`);
|
|
33
|
+
}
|
|
34
|
+
}
|