phasegate 0.44.0 → 0.63.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/README.ja.md +32 -0
- package/README.md +33 -0
- package/docs/guide/codex-integration.md +162 -0
- package/docs/guide/quick-vs-full-mode.md +141 -0
- package/package.json +1 -1
- package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +60 -0
- package/scripts/harness/agent-integration/presentation/phasegate-status-context.ts +299 -0
- package/scripts/harness/agent-integration/presentation/session-start-hook.ts +54 -0
- package/scripts/harness/agent-integration/presentation/user-prompt-submit-hook.ts +70 -0
- package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v2.schema.json +15 -0
- package/scripts/harness/integrations/pre-commit.ts +128 -28
- package/scripts/harness/main.ts +87 -9
- package/scripts/harness/phase2-extensions/application/dto/check-initial-creation-expiration-input.ts +9 -0
- package/scripts/harness/phase2-extensions/application/dto/check-initial-creation-expiration-output.ts +17 -0
- package/scripts/harness/phase2-extensions/application/usecases/check-initial-creation-expiration-usecase.ts +103 -0
- package/scripts/harness/phase2-extensions/composition-root.ts +22 -0
- package/scripts/harness/phase2-extensions/domain/aggregates/initial-creation-expiration-rule.ts +103 -0
- package/scripts/harness/phase2-extensions/domain/ports/frontmatter-reader-port.ts +17 -0
- package/scripts/harness/phase2-extensions/domain/ports/initial-creation-age-port.ts +9 -0
- package/scripts/harness/phase2-extensions/domain/ports/initial-creation-expiration-config-port.ts +9 -0
- package/scripts/harness/phase2-extensions/domain/services/initial-creation-expiration-check-service.ts +54 -0
- package/scripts/harness/phase2-extensions/domain/value-objects/initial-creation-age.ts +54 -0
- package/scripts/harness/phase2-extensions/infrastructure/adapters/git-log-initial-creation-age-adapter.ts +77 -0
- package/scripts/harness/phase2-extensions/infrastructure/adapters/harness-config-initial-creation-expiration-adapter.ts +57 -0
- package/scripts/harness/phase2-extensions/infrastructure/adapters/markdown-frontmatter-reader-adapter.ts +56 -0
- package/scripts/harness/phase2-extensions/presentation/formatters/initial-creation-expiration-result-formatter.ts +23 -0
- package/scripts/harness/phase2-extensions/presentation/handlers/check-initial-creation-expiration-handler.ts +38 -0
- package/scripts/harness/quick-mode/application/dto/change-category-classification-contract.ts +20 -0
- package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +83 -0
- package/scripts/harness/quick-mode/composition-root.ts +12 -0
- package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +38 -32
- package/scripts/harness/quick-mode/domain/value-objects/quick-mode-config.ts +36 -4
- package/scripts/harness/quick-mode/infrastructure/adapters/harness-config-quick-mode-config-adapter.ts +6 -0
- package/scripts/harness/quick-mode/presentation/formatters/change-category-formatter.ts +42 -0
- package/scripts/harness/quick-mode/presentation/handlers/check-change-category-handler.ts +50 -0
- package/scripts/harness/setup/skill-deployer.ts +30 -0
- package/scripts/harness/traceability-model/composition-root.ts +14 -0
- package/scripts/harness/traceability-model/domain/value-objects/project-relative-path.ts +3 -0
- package/scripts/harness/traceability-model/infrastructure/parsers/markdown-story-annotation-parser.ts +39 -4
- package/scripts/harness/traceability-model/presentation/cli/validate-metadata-command-handler.ts +103 -9
- package/skills/domain-designer/SKILL.md +34 -0
- package/skills/it-test-logic-designer/SKILL.md +16 -0
- package/skills/logical-designer/SKILL.md +34 -0
- package/skills/quick-implementor/SKILL.md +10 -1
- package/skills/scenario-test-logic-designer/SKILL.md +16 -0
- package/skills/story-implementor/SKILL.md +58 -0
- package/skills/unit-designer/SKILL.md +41 -0
- package/skills/unit-test-logic-designer/SKILL.md +18 -0
- package/templates/.codex/hooks.json +63 -0
- package/templates/logical_design.template.md +79 -0
- package/templates/source.template.ts +18 -0
- package/templates/test.template.ts +37 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @layer infrastructure
|
|
3
|
+
* @unit phase2-extensions
|
|
4
|
+
*/
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import * as fs from 'node:fs/promises';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import type { InitialCreationAgePort } from '../../domain/ports/initial-creation-age-port.js';
|
|
9
|
+
import { InitialCreationAge } from '../../domain/value-objects/initial-creation-age.js';
|
|
10
|
+
|
|
11
|
+
type GitExecutor = (
|
|
12
|
+
command: string,
|
|
13
|
+
options: { cwd: string; stdio?: readonly ['pipe', 'pipe', 'pipe'] },
|
|
14
|
+
) => Buffer;
|
|
15
|
+
|
|
16
|
+
function diffInDays(now: Date, past: Date): number {
|
|
17
|
+
const millisecondsPerDay = 1000 * 60 * 60 * 24;
|
|
18
|
+
return Math.max(0, Math.floor((now.getTime() - past.getTime()) / millisecondsPerDay));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class GitLogInitialCreationAgeAdapter implements InitialCreationAgePort {
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly projectRoot: string,
|
|
24
|
+
private readonly nowProvider: () => Date = () => new Date(),
|
|
25
|
+
private readonly gitExecutor: GitExecutor = execSync,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
async getAge(filePath: string): Promise<InitialCreationAge> {
|
|
29
|
+
const dateOutput = this.runGit(`git log --diff-filter=A --format=%ai -- "${filePath}"`);
|
|
30
|
+
const countOutput = this.runGit(`git rev-list --count HEAD -- "${filePath}"`);
|
|
31
|
+
|
|
32
|
+
if (dateOutput !== null && dateOutput.length > 0) {
|
|
33
|
+
const commitCount = this.parseCount(countOutput);
|
|
34
|
+
|
|
35
|
+
return InitialCreationAge.create({
|
|
36
|
+
ageInDays: diffInDays(this.nowProvider(), new Date(dateOutput.split('\n')[0].trim())),
|
|
37
|
+
commitCount: commitCount > 0 ? commitCount : 1,
|
|
38
|
+
source: 'git-log',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return this.fileMtimeFallback(filePath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private runGit(command: string): string | null {
|
|
46
|
+
try {
|
|
47
|
+
return this.gitExecutor(command, {
|
|
48
|
+
cwd: this.projectRoot,
|
|
49
|
+
// ISSUE-005 P1-3 と同様に、fresh repo の fatal stderr を静音化する。
|
|
50
|
+
stdio: ['pipe', 'pipe', 'pipe'] as const,
|
|
51
|
+
})
|
|
52
|
+
.toString()
|
|
53
|
+
.trim();
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private parseCount(output: string | null): number {
|
|
60
|
+
if (output === null || output.length === 0) {
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const parsed = Number.parseInt(output.trim(), 10);
|
|
65
|
+
return Number.isFinite(parsed) && parsed >= 1 ? parsed : 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async fileMtimeFallback(filePath: string): Promise<InitialCreationAge> {
|
|
69
|
+
const stat = await fs.stat(path.resolve(this.projectRoot, filePath));
|
|
70
|
+
|
|
71
|
+
return InitialCreationAge.create({
|
|
72
|
+
ageInDays: diffInDays(this.nowProvider(), stat.mtime),
|
|
73
|
+
commitCount: 1,
|
|
74
|
+
source: 'file-mtime',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @layer infrastructure
|
|
3
|
+
* @unit phase2-extensions
|
|
4
|
+
*/
|
|
5
|
+
import type { HarnessConfigV2 } from '../../../config-foundation/domain/harness-config.js';
|
|
6
|
+
import { InitialCreationExpirationRule } from '../../domain/aggregates/initial-creation-expiration-rule.js';
|
|
7
|
+
import type { InitialCreationExpirationConfigPort } from '../../domain/ports/initial-creation-expiration-config-port.js';
|
|
8
|
+
|
|
9
|
+
type Phase2InitialCreationConfig = {
|
|
10
|
+
phase2Extensions?: {
|
|
11
|
+
initialCreationExpirationRules?: Array<{
|
|
12
|
+
ruleId: string;
|
|
13
|
+
documentPattern: string;
|
|
14
|
+
daysThreshold: number;
|
|
15
|
+
commitCountThreshold: number;
|
|
16
|
+
evaluationMode: 'or' | 'and';
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
}>;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const DEFAULT_RULE_CONFIG = {
|
|
23
|
+
ruleId: 'default-initial-creation-expiration',
|
|
24
|
+
documentPattern: 'docs/**/*.md',
|
|
25
|
+
daysThreshold: 90,
|
|
26
|
+
commitCountThreshold: 5,
|
|
27
|
+
evaluationMode: 'or' as const,
|
|
28
|
+
enabled: true,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class HarnessConfigInitialCreationExpirationAdapter
|
|
32
|
+
implements InitialCreationExpirationConfigPort
|
|
33
|
+
{
|
|
34
|
+
constructor(private readonly config?: HarnessConfigV2 | Phase2InitialCreationConfig) {}
|
|
35
|
+
|
|
36
|
+
async loadRules(): Promise<InitialCreationExpirationRule[]> {
|
|
37
|
+
const configRules =
|
|
38
|
+
this.config && 'phase2Extensions' in this.config
|
|
39
|
+
? this.config.phase2Extensions?.initialCreationExpirationRules
|
|
40
|
+
: undefined;
|
|
41
|
+
|
|
42
|
+
if (!configRules || configRules.length === 0) {
|
|
43
|
+
return [InitialCreationExpirationRule.create(DEFAULT_RULE_CONFIG)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return configRules.map((rule) =>
|
|
47
|
+
InitialCreationExpirationRule.create({
|
|
48
|
+
ruleId: rule.ruleId,
|
|
49
|
+
documentPattern: rule.documentPattern,
|
|
50
|
+
daysThreshold: rule.daysThreshold,
|
|
51
|
+
commitCountThreshold: rule.commitCountThreshold,
|
|
52
|
+
evaluationMode: rule.evaluationMode,
|
|
53
|
+
enabled: rule.enabled ?? true,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @layer infrastructure
|
|
3
|
+
* @unit phase2-extensions
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'node:fs/promises';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import { parseFrontmatterFlags } from '../../../traceability-model/infrastructure/parsers/frontmatter-flag-parser.js';
|
|
8
|
+
import type {
|
|
9
|
+
FrontmatterReadResult,
|
|
10
|
+
FrontmatterReaderPort,
|
|
11
|
+
} from '../../domain/ports/frontmatter-reader-port.js';
|
|
12
|
+
|
|
13
|
+
const FRONTMATTER_INITIAL_CREATION_PATTERN =
|
|
14
|
+
/^---\r?\n[\s\S]*?^\s*initial_creation\s*:\s*(.+)\s*$/m;
|
|
15
|
+
|
|
16
|
+
export class MarkdownFrontmatterReaderAdapter implements FrontmatterReaderPort {
|
|
17
|
+
constructor(private readonly projectRoot: string) {}
|
|
18
|
+
|
|
19
|
+
async read(filePath: string): Promise<FrontmatterReadResult> {
|
|
20
|
+
try {
|
|
21
|
+
const absolutePath = path.resolve(this.projectRoot, filePath);
|
|
22
|
+
const content = await fs.readFile(absolutePath, 'utf8');
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const initialCreationMatch = FRONTMATTER_INITIAL_CREATION_PATTERN.exec(content);
|
|
26
|
+
if (initialCreationMatch) {
|
|
27
|
+
const rawValue = initialCreationMatch[1].trim();
|
|
28
|
+
if (rawValue !== 'true' && rawValue !== 'false') {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`traceability.initial_creation の値が不正です(true/false のみ許容): ${rawValue}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const parsed = parseFrontmatterFlags(content);
|
|
36
|
+
return {
|
|
37
|
+
filePath,
|
|
38
|
+
flags: { initialCreation: parsed.initialCreation },
|
|
39
|
+
parseError: null,
|
|
40
|
+
};
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return {
|
|
43
|
+
filePath,
|
|
44
|
+
flags: null,
|
|
45
|
+
parseError: error instanceof Error ? error.message : 'unknown parse error',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return {
|
|
50
|
+
filePath,
|
|
51
|
+
flags: null,
|
|
52
|
+
parseError: error instanceof Error ? `read failed: ${error.message}` : 'read failed',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @layer presentation
|
|
3
|
+
* @unit phase2-extensions
|
|
4
|
+
*/
|
|
5
|
+
import type { CheckInitialCreationExpirationOutput } from '../../application/dto/check-initial-creation-expiration-output.js';
|
|
6
|
+
|
|
7
|
+
export class InitialCreationExpirationResultFormatter {
|
|
8
|
+
formatJson(result: CheckInitialCreationExpirationOutput): string {
|
|
9
|
+
return JSON.stringify(result, null, 2);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
formatText(result: CheckInitialCreationExpirationOutput): string {
|
|
13
|
+
const header = `total=${result.summary.total} ok=${result.summary.ok} warn=${result.summary.warn}`;
|
|
14
|
+
const entries = result.results.map(
|
|
15
|
+
(entry) =>
|
|
16
|
+
`${entry.level}: ${entry.documentPath} (ageInDays=${entry.ageInDays}, commitCount=${entry.commitCount}, source=${entry.ageSource})`,
|
|
17
|
+
);
|
|
18
|
+
const warnings = result.warnings.map((warning) => `warning ${warning.code}: ${warning.message}`);
|
|
19
|
+
const errors = result.errors.map((error) => `error ${error.code}: ${error.message}`);
|
|
20
|
+
|
|
21
|
+
return [header, ...entries, ...warnings, ...errors].join('\n');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @layer presentation
|
|
3
|
+
* @unit phase2-extensions
|
|
4
|
+
*/
|
|
5
|
+
import type { CheckInitialCreationExpirationUseCase } from '../../application/usecases/check-initial-creation-expiration-usecase.js';
|
|
6
|
+
import { InitialCreationExpirationResultFormatter } from '../formatters/initial-creation-expiration-result-formatter.js';
|
|
7
|
+
|
|
8
|
+
export class CheckInitialCreationExpirationHandler {
|
|
9
|
+
private readonly formatter = new InitialCreationExpirationResultFormatter();
|
|
10
|
+
|
|
11
|
+
constructor(private readonly useCase: CheckInitialCreationExpirationUseCase) {}
|
|
12
|
+
|
|
13
|
+
async handle(args: string[]): Promise<{ exitCode: number; stdout: string }> {
|
|
14
|
+
let targetPattern: string | undefined;
|
|
15
|
+
let format: 'text' | 'json' = 'text';
|
|
16
|
+
let dryRun = false;
|
|
17
|
+
|
|
18
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
19
|
+
const arg = args[index];
|
|
20
|
+
if (arg === '--pattern') {
|
|
21
|
+
targetPattern = args[index + 1];
|
|
22
|
+
index += 1;
|
|
23
|
+
} else if (arg === '--format') {
|
|
24
|
+
format = (args[index + 1] as 'text' | 'json') ?? 'text';
|
|
25
|
+
index += 1;
|
|
26
|
+
} else if (arg === '--dry-run') {
|
|
27
|
+
dryRun = true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const result = await this.useCase.execute({ targetPattern, format, dryRun });
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
exitCode: result.errors.length > 0 ? 1 : 0,
|
|
35
|
+
stdout: format === 'json' ? this.formatter.formatJson(result) : this.formatter.formatText(result),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @layer application
|
|
3
|
+
* @unit quick-mode
|
|
4
|
+
* @story H10-05
|
|
5
|
+
*
|
|
6
|
+
* ChangeCategoryClassification の公開 DTO
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ChangeCategoryPerFile {
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly category: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ChangeCategoryClassificationContract {
|
|
15
|
+
readonly dominantCategory: string | null;
|
|
16
|
+
readonly perFile: readonly ChangeCategoryPerFile[];
|
|
17
|
+
readonly fullModeRequired: boolean;
|
|
18
|
+
readonly rejectionRule?: 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
|
|
19
|
+
readonly rejectionReason?: string;
|
|
20
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @layer application
|
|
3
|
+
* @unit quick-mode
|
|
4
|
+
* @story H10-05
|
|
5
|
+
*
|
|
6
|
+
* paths から変更カテゴリを分類し fullModeRequired 判定と理由を返す UseCase
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ChangedFile } from '../../domain/value-objects/changed-file.js';
|
|
10
|
+
import { QuickModeJudgmentEngine } from '../../domain/services/quick-mode-judgment-engine.js';
|
|
11
|
+
import type { QuickModeConfigPort } from '../ports/quick-mode-config-port.js';
|
|
12
|
+
import type { ChangeCategoryClassificationContract, ChangeCategoryPerFile } from '../dto/change-category-classification-contract.js';
|
|
13
|
+
|
|
14
|
+
export interface ClassifyChangeCategoryUseCaseInput {
|
|
15
|
+
readonly paths: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ClassifyChangeCategoryUseCaseDeps {
|
|
19
|
+
quickModeConfigPort: QuickModeConfigPort;
|
|
20
|
+
judgmentEngine?: QuickModeJudgmentEngine;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ClassifyChangeCategoryUseCase {
|
|
24
|
+
private readonly quickModeConfigPort: QuickModeConfigPort;
|
|
25
|
+
private readonly judgmentEngine: QuickModeJudgmentEngine;
|
|
26
|
+
|
|
27
|
+
constructor(deps: ClassifyChangeCategoryUseCaseDeps) {
|
|
28
|
+
this.quickModeConfigPort = deps.quickModeConfigPort;
|
|
29
|
+
this.judgmentEngine = deps.judgmentEngine ?? new QuickModeJudgmentEngine();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async execute(
|
|
33
|
+
input: ClassifyChangeCategoryUseCaseInput
|
|
34
|
+
): Promise<Readonly<ChangeCategoryClassificationContract>> {
|
|
35
|
+
const config = await this.quickModeConfigPort.getConfig();
|
|
36
|
+
|
|
37
|
+
if (input.paths.length === 0) {
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
dominantCategory: null,
|
|
40
|
+
perFile: [],
|
|
41
|
+
fullModeRequired: false,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const changedFiles = input.paths.map((p) =>
|
|
46
|
+
ChangedFile.create({ filePath: p, changeKind: 'MODIFY' })
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const classification = this.judgmentEngine.classify(changedFiles, config);
|
|
50
|
+
const eligibility = this.judgmentEngine.judge(changedFiles, config);
|
|
51
|
+
|
|
52
|
+
const perFile: ChangeCategoryPerFile[] = [];
|
|
53
|
+
for (const path of input.paths) {
|
|
54
|
+
const match = changedFiles.find((f) => f.filePath === path);
|
|
55
|
+
if (!match) continue;
|
|
56
|
+
let categoryForFile: string | null = null;
|
|
57
|
+
classification.categorizedFiles.forEach((files, categoryKey) => {
|
|
58
|
+
if (files.some((f) => f.filePath === path)) {
|
|
59
|
+
categoryForFile = categoryKey;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
perFile.push({ path, category: categoryForFile ?? 'unknown' });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fullModeRequired = !eligibility.isEligible();
|
|
66
|
+
|
|
67
|
+
const contract: ChangeCategoryClassificationContract = fullModeRequired
|
|
68
|
+
? {
|
|
69
|
+
dominantCategory: classification.dominantCategory?.toString() ?? null,
|
|
70
|
+
perFile,
|
|
71
|
+
fullModeRequired: true,
|
|
72
|
+
rejectionRule: eligibility.rejectionRule,
|
|
73
|
+
rejectionReason: eligibility.reason,
|
|
74
|
+
}
|
|
75
|
+
: {
|
|
76
|
+
dominantCategory: classification.dominantCategory?.toString() ?? null,
|
|
77
|
+
perFile,
|
|
78
|
+
fullModeRequired: false,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return Object.freeze(contract);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -14,13 +14,17 @@ import { QuickModeDecisionContractMapper } from './application/mappers/quick-mod
|
|
|
14
14
|
import { JudgeQuickModeEligibilityUseCase } from './application/usecases/judge-quick-mode-eligibility-usecase.js';
|
|
15
15
|
import { BuildRelaxationProfileUseCase } from './application/usecases/build-relaxation-profile-usecase.js';
|
|
16
16
|
import { ExecuteQuickCiCheckUseCase } from './application/usecases/execute-quick-ci-check-usecase.js';
|
|
17
|
+
import { ClassifyChangeCategoryUseCase } from './application/usecases/classify-change-category-usecase.js';
|
|
17
18
|
import { CiCheckQuickModeHandler } from './presentation/handlers/ci-check-quick-mode-handler.js';
|
|
19
|
+
import { CheckChangeCategoryHandler } from './presentation/handlers/check-change-category-handler.js';
|
|
18
20
|
|
|
19
21
|
export interface QuickModeCompositionRoot {
|
|
20
22
|
handler: CiCheckQuickModeHandler;
|
|
23
|
+
checkChangeCategoryHandler: CheckChangeCategoryHandler;
|
|
21
24
|
executeUseCase: ExecuteQuickCiCheckUseCase;
|
|
22
25
|
judgeUseCase: JudgeQuickModeEligibilityUseCase;
|
|
23
26
|
buildUseCase: BuildRelaxationProfileUseCase;
|
|
27
|
+
classifyUseCase: ClassifyChangeCategoryUseCase;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export function createQuickModeCompositionRoot(): QuickModeCompositionRoot {
|
|
@@ -62,13 +66,21 @@ export function createQuickModeCompositionRoot(): QuickModeCompositionRoot {
|
|
|
62
66
|
buildUseCase,
|
|
63
67
|
});
|
|
64
68
|
|
|
69
|
+
const classifyUseCase = new ClassifyChangeCategoryUseCase({
|
|
70
|
+
quickModeConfigPort,
|
|
71
|
+
judgmentEngine,
|
|
72
|
+
});
|
|
73
|
+
|
|
65
74
|
// Presentation
|
|
66
75
|
const handler = new CiCheckQuickModeHandler({ useCase: executeUseCase });
|
|
76
|
+
const checkChangeCategoryHandler = new CheckChangeCategoryHandler({ useCase: classifyUseCase });
|
|
67
77
|
|
|
68
78
|
return {
|
|
69
79
|
handler,
|
|
80
|
+
checkChangeCategoryHandler,
|
|
70
81
|
executeUseCase,
|
|
71
82
|
judgeUseCase,
|
|
72
83
|
buildUseCase,
|
|
84
|
+
classifyUseCase,
|
|
73
85
|
};
|
|
74
86
|
}
|
|
@@ -100,47 +100,53 @@ export class QuickModeJudgmentEngine {
|
|
|
100
100
|
const classification = this.classify(changedFiles, config);
|
|
101
101
|
|
|
102
102
|
// 1. MIXED_CHANGES評価: allowedCategories 外のカテゴリが含まれる場合
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
if (config.isFullModeRequiredFor('mixedCategories')) {
|
|
104
|
+
const notAllowedFiles: ChangedFile[] = [];
|
|
105
|
+
classification.categorizedFiles.forEach((files, categoryKey) => {
|
|
106
|
+
if (!config.isAllowed(categoryKey)) {
|
|
107
|
+
notAllowedFiles.push(...files);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (notAllowedFiles.length > 0) {
|
|
112
|
+
return QuickModeEligibility.rejected(
|
|
113
|
+
'MIXED_CHANGES',
|
|
114
|
+
notAllowedFiles,
|
|
115
|
+
`allowedCategories外のファイルが含まれています: ${notAllowedFiles.map((f) => f.filePath).join(', ')}`
|
|
116
|
+
);
|
|
107
117
|
}
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
if (notAllowedFiles.length > 0) {
|
|
111
|
-
return QuickModeEligibility.rejected(
|
|
112
|
-
'MIXED_CHANGES',
|
|
113
|
-
notAllowedFiles,
|
|
114
|
-
`allowedCategories外のファイルが含まれています: ${notAllowedFiles.map((f) => f.filePath).join(', ')}`
|
|
115
|
-
);
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
// 2. NEW_DOMAIN評価: domain/ 配下かつ changeKind=CREATE
|
|
119
|
-
|
|
120
|
-
(
|
|
121
|
-
(f
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (newDomainFiles.length > 0) {
|
|
126
|
-
return QuickModeEligibility.rejected(
|
|
127
|
-
'NEW_DOMAIN',
|
|
128
|
-
newDomainFiles,
|
|
129
|
-
`domain/配下に新規ファイルが追加されています: ${newDomainFiles.map((f) => f.filePath).join(', ')}`
|
|
121
|
+
if (config.isFullModeRequiredFor('newDomainFile')) {
|
|
122
|
+
const newDomainFiles = changedFiles.filter(
|
|
123
|
+
(f) =>
|
|
124
|
+
(f.filePath.includes('/domain/') || f.filePath.startsWith('domain/')) &&
|
|
125
|
+
f.changeKind === 'CREATE'
|
|
130
126
|
);
|
|
127
|
+
|
|
128
|
+
if (newDomainFiles.length > 0) {
|
|
129
|
+
return QuickModeEligibility.rejected(
|
|
130
|
+
'NEW_DOMAIN',
|
|
131
|
+
newDomainFiles,
|
|
132
|
+
`domain/配下に新規ファイルが追加されています: ${newDomainFiles.map((f) => f.filePath).join(', ')}`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
// 3. API_CONTRACT評価: *port.ts / *adapter.ts の変更
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (apiContractFiles.length > 0) {
|
|
139
|
-
return QuickModeEligibility.rejected(
|
|
140
|
-
'API_CONTRACT',
|
|
141
|
-
apiContractFiles,
|
|
142
|
-
`Port/Adapterインターフェースファイルの変更が含まれています: ${apiContractFiles.map((f) => f.filePath).join(', ')}`
|
|
138
|
+
if (config.isFullModeRequiredFor('apiContractChange')) {
|
|
139
|
+
const apiContractFiles = changedFiles.filter(
|
|
140
|
+
(f) => f.filePath.endsWith('port.ts') || f.filePath.endsWith('adapter.ts')
|
|
143
141
|
);
|
|
142
|
+
|
|
143
|
+
if (apiContractFiles.length > 0) {
|
|
144
|
+
return QuickModeEligibility.rejected(
|
|
145
|
+
'API_CONTRACT',
|
|
146
|
+
apiContractFiles,
|
|
147
|
+
`Port/Adapterインターフェースファイルの変更が含まれています: ${apiContractFiles.map((f) => f.filePath).join(', ')}`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
return QuickModeEligibility.eligible('すべてのファイルが許可カテゴリ内です');
|
|
@@ -12,20 +12,37 @@ export class QuickModeConfigError extends Error {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export type FullModeRequiredRuleId = 'mixedCategories' | 'newDomainFile' | 'apiContractChange';
|
|
16
|
+
|
|
17
|
+
export interface FullModeRequiredRules {
|
|
18
|
+
readonly mixedCategories: boolean;
|
|
19
|
+
readonly newDomainFile: boolean;
|
|
20
|
+
readonly apiContractChange: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_FULL_MODE_REQUIRED_WHEN: FullModeRequiredRules = Object.freeze({
|
|
24
|
+
mixedCategories: true,
|
|
25
|
+
newDomainFile: true,
|
|
26
|
+
apiContractChange: true,
|
|
27
|
+
});
|
|
28
|
+
|
|
15
29
|
|
|
16
30
|
export class QuickModeConfig {
|
|
17
31
|
readonly allowedCategories: readonly string[];
|
|
18
32
|
readonly maintainedLayers: readonly string[];
|
|
19
33
|
readonly relaxedGates: readonly string[];
|
|
34
|
+
readonly fullModeRequiredWhen: FullModeRequiredRules;
|
|
20
35
|
|
|
21
36
|
private constructor(
|
|
22
37
|
allowedCategories: readonly string[],
|
|
23
38
|
maintainedLayers: readonly string[],
|
|
24
|
-
relaxedGates: readonly string[]
|
|
39
|
+
relaxedGates: readonly string[],
|
|
40
|
+
fullModeRequiredWhen: FullModeRequiredRules
|
|
25
41
|
) {
|
|
26
42
|
this.allowedCategories = allowedCategories;
|
|
27
43
|
this.maintainedLayers = maintainedLayers;
|
|
28
44
|
this.relaxedGates = relaxedGates;
|
|
45
|
+
this.fullModeRequiredWhen = fullModeRequiredWhen;
|
|
29
46
|
Object.freeze(this);
|
|
30
47
|
}
|
|
31
48
|
|
|
@@ -33,17 +50,25 @@ export class QuickModeConfig {
|
|
|
33
50
|
allowedCategories: string[];
|
|
34
51
|
maintainedLayers: string[];
|
|
35
52
|
relaxedGates: string[];
|
|
53
|
+
fullModeRequiredWhen?: Partial<FullModeRequiredRules>;
|
|
36
54
|
}): QuickModeConfig {
|
|
37
|
-
const { allowedCategories, maintainedLayers, relaxedGates } = raw;
|
|
55
|
+
const { allowedCategories, maintainedLayers, relaxedGates, fullModeRequiredWhen } = raw;
|
|
38
56
|
|
|
39
57
|
if (allowedCategories.length === 0) {
|
|
40
58
|
throw new QuickModeConfigError('allowedCategories must not be empty');
|
|
41
59
|
}
|
|
42
60
|
|
|
61
|
+
const mergedRules: FullModeRequiredRules = Object.freeze({
|
|
62
|
+
mixedCategories: fullModeRequiredWhen?.mixedCategories ?? DEFAULT_FULL_MODE_REQUIRED_WHEN.mixedCategories,
|
|
63
|
+
newDomainFile: fullModeRequiredWhen?.newDomainFile ?? DEFAULT_FULL_MODE_REQUIRED_WHEN.newDomainFile,
|
|
64
|
+
apiContractChange: fullModeRequiredWhen?.apiContractChange ?? DEFAULT_FULL_MODE_REQUIRED_WHEN.apiContractChange,
|
|
65
|
+
});
|
|
66
|
+
|
|
43
67
|
return new QuickModeConfig(
|
|
44
68
|
Object.freeze([...allowedCategories]),
|
|
45
69
|
Object.freeze([...maintainedLayers]),
|
|
46
|
-
Object.freeze([...relaxedGates])
|
|
70
|
+
Object.freeze([...relaxedGates]),
|
|
71
|
+
mergedRules
|
|
47
72
|
);
|
|
48
73
|
}
|
|
49
74
|
|
|
@@ -59,11 +84,18 @@ export class QuickModeConfig {
|
|
|
59
84
|
return this.relaxedGates.includes(validatorId);
|
|
60
85
|
}
|
|
61
86
|
|
|
87
|
+
isFullModeRequiredFor(rule: FullModeRequiredRuleId): boolean {
|
|
88
|
+
return this.fullModeRequiredWhen[rule];
|
|
89
|
+
}
|
|
90
|
+
|
|
62
91
|
equals(other: QuickModeConfig): boolean {
|
|
63
92
|
return (
|
|
64
93
|
JSON.stringify(this.allowedCategories) === JSON.stringify(other.allowedCategories) &&
|
|
65
94
|
JSON.stringify(this.maintainedLayers) === JSON.stringify(other.maintainedLayers) &&
|
|
66
|
-
JSON.stringify(this.relaxedGates) === JSON.stringify(other.relaxedGates)
|
|
95
|
+
JSON.stringify(this.relaxedGates) === JSON.stringify(other.relaxedGates) &&
|
|
96
|
+
this.fullModeRequiredWhen.mixedCategories === other.fullModeRequiredWhen.mixedCategories &&
|
|
97
|
+
this.fullModeRequiredWhen.newDomainFile === other.fullModeRequiredWhen.newDomainFile &&
|
|
98
|
+
this.fullModeRequiredWhen.apiContractChange === other.fullModeRequiredWhen.apiContractChange
|
|
67
99
|
);
|
|
68
100
|
}
|
|
69
101
|
}
|
|
@@ -60,6 +60,11 @@ export class HarnessConfigQuickModeConfigAdapter {
|
|
|
60
60
|
allowedCategories?: string[];
|
|
61
61
|
maintainedLayers?: string[];
|
|
62
62
|
relaxedGates?: string[];
|
|
63
|
+
fullModeRequiredWhen?: {
|
|
64
|
+
mixedCategories?: boolean;
|
|
65
|
+
newDomainFile?: boolean;
|
|
66
|
+
apiContractChange?: boolean;
|
|
67
|
+
};
|
|
63
68
|
} | undefined;
|
|
64
69
|
|
|
65
70
|
if (!quickMode) {
|
|
@@ -70,6 +75,7 @@ export class HarnessConfigQuickModeConfigAdapter {
|
|
|
70
75
|
allowedCategories: quickMode.allowedCategories ?? DEFAULT_QUICK_MODE_CONFIG.allowedCategories,
|
|
71
76
|
maintainedLayers: quickMode.maintainedLayers ?? DEFAULT_QUICK_MODE_CONFIG.maintainedLayers,
|
|
72
77
|
relaxedGates: quickMode.relaxedGates ?? DEFAULT_QUICK_MODE_CONFIG.relaxedGates,
|
|
78
|
+
fullModeRequiredWhen: quickMode.fullModeRequiredWhen,
|
|
73
79
|
});
|
|
74
80
|
}
|
|
75
81
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @layer presentation
|
|
3
|
+
* @unit quick-mode
|
|
4
|
+
* @story H10-05
|
|
5
|
+
*
|
|
6
|
+
* ClassifyChangeCategoryUseCase の出力を human / json フォーマットで整形する
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ChangeCategoryClassificationContract } from '../../application/dto/change-category-classification-contract.js';
|
|
10
|
+
|
|
11
|
+
export type ChangeCategoryOutputFormat = 'human' | 'json';
|
|
12
|
+
|
|
13
|
+
export class ChangeCategoryFormatter {
|
|
14
|
+
format(
|
|
15
|
+
contract: ChangeCategoryClassificationContract,
|
|
16
|
+
format: ChangeCategoryOutputFormat = 'human'
|
|
17
|
+
): string {
|
|
18
|
+
if (format === 'json') {
|
|
19
|
+
return JSON.stringify(contract, null, 2) + '\n';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const lines: string[] = [];
|
|
23
|
+
lines.push(`dominantCategory: ${contract.dominantCategory ?? '(none)'}`);
|
|
24
|
+
lines.push(`fullModeRequired: ${contract.fullModeRequired}`);
|
|
25
|
+
|
|
26
|
+
if (contract.fullModeRequired) {
|
|
27
|
+
lines.push(`rejectionRule: ${contract.rejectionRule ?? ''}`);
|
|
28
|
+
if (contract.rejectionReason) {
|
|
29
|
+
lines.push(`rejectionReason: ${contract.rejectionReason}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (contract.perFile.length > 0) {
|
|
34
|
+
lines.push('perFile:');
|
|
35
|
+
for (const entry of contract.perFile) {
|
|
36
|
+
lines.push(` ${entry.path} -> ${entry.category}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return lines.join('\n') + '\n';
|
|
41
|
+
}
|
|
42
|
+
}
|