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.
Files changed (52) hide show
  1. package/README.ja.md +32 -0
  2. package/README.md +33 -0
  3. package/docs/guide/codex-integration.md +162 -0
  4. package/docs/guide/quick-vs-full-mode.md +141 -0
  5. package/package.json +1 -1
  6. package/scripts/harness/agent-integration/domain/services/bash-write-target-extractor.ts +60 -0
  7. package/scripts/harness/agent-integration/presentation/phasegate-status-context.ts +299 -0
  8. package/scripts/harness/agent-integration/presentation/session-start-hook.ts +54 -0
  9. package/scripts/harness/agent-integration/presentation/user-prompt-submit-hook.ts +70 -0
  10. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v2.schema.json +15 -0
  11. package/scripts/harness/integrations/pre-commit.ts +128 -28
  12. package/scripts/harness/main.ts +87 -9
  13. package/scripts/harness/phase2-extensions/application/dto/check-initial-creation-expiration-input.ts +9 -0
  14. package/scripts/harness/phase2-extensions/application/dto/check-initial-creation-expiration-output.ts +17 -0
  15. package/scripts/harness/phase2-extensions/application/usecases/check-initial-creation-expiration-usecase.ts +103 -0
  16. package/scripts/harness/phase2-extensions/composition-root.ts +22 -0
  17. package/scripts/harness/phase2-extensions/domain/aggregates/initial-creation-expiration-rule.ts +103 -0
  18. package/scripts/harness/phase2-extensions/domain/ports/frontmatter-reader-port.ts +17 -0
  19. package/scripts/harness/phase2-extensions/domain/ports/initial-creation-age-port.ts +9 -0
  20. package/scripts/harness/phase2-extensions/domain/ports/initial-creation-expiration-config-port.ts +9 -0
  21. package/scripts/harness/phase2-extensions/domain/services/initial-creation-expiration-check-service.ts +54 -0
  22. package/scripts/harness/phase2-extensions/domain/value-objects/initial-creation-age.ts +54 -0
  23. package/scripts/harness/phase2-extensions/infrastructure/adapters/git-log-initial-creation-age-adapter.ts +77 -0
  24. package/scripts/harness/phase2-extensions/infrastructure/adapters/harness-config-initial-creation-expiration-adapter.ts +57 -0
  25. package/scripts/harness/phase2-extensions/infrastructure/adapters/markdown-frontmatter-reader-adapter.ts +56 -0
  26. package/scripts/harness/phase2-extensions/presentation/formatters/initial-creation-expiration-result-formatter.ts +23 -0
  27. package/scripts/harness/phase2-extensions/presentation/handlers/check-initial-creation-expiration-handler.ts +38 -0
  28. package/scripts/harness/quick-mode/application/dto/change-category-classification-contract.ts +20 -0
  29. package/scripts/harness/quick-mode/application/usecases/classify-change-category-usecase.ts +83 -0
  30. package/scripts/harness/quick-mode/composition-root.ts +12 -0
  31. package/scripts/harness/quick-mode/domain/services/quick-mode-judgment-engine.ts +38 -32
  32. package/scripts/harness/quick-mode/domain/value-objects/quick-mode-config.ts +36 -4
  33. package/scripts/harness/quick-mode/infrastructure/adapters/harness-config-quick-mode-config-adapter.ts +6 -0
  34. package/scripts/harness/quick-mode/presentation/formatters/change-category-formatter.ts +42 -0
  35. package/scripts/harness/quick-mode/presentation/handlers/check-change-category-handler.ts +50 -0
  36. package/scripts/harness/setup/skill-deployer.ts +30 -0
  37. package/scripts/harness/traceability-model/composition-root.ts +14 -0
  38. package/scripts/harness/traceability-model/domain/value-objects/project-relative-path.ts +3 -0
  39. package/scripts/harness/traceability-model/infrastructure/parsers/markdown-story-annotation-parser.ts +39 -4
  40. package/scripts/harness/traceability-model/presentation/cli/validate-metadata-command-handler.ts +103 -9
  41. package/skills/domain-designer/SKILL.md +34 -0
  42. package/skills/it-test-logic-designer/SKILL.md +16 -0
  43. package/skills/logical-designer/SKILL.md +34 -0
  44. package/skills/quick-implementor/SKILL.md +10 -1
  45. package/skills/scenario-test-logic-designer/SKILL.md +16 -0
  46. package/skills/story-implementor/SKILL.md +58 -0
  47. package/skills/unit-designer/SKILL.md +41 -0
  48. package/skills/unit-test-logic-designer/SKILL.md +18 -0
  49. package/templates/.codex/hooks.json +63 -0
  50. package/templates/logical_design.template.md +79 -0
  51. package/templates/source.template.ts +18 -0
  52. 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
- const notAllowedFiles: ChangedFile[] = [];
104
- classification.categorizedFiles.forEach((files, categoryKey) => {
105
- if (!config.isAllowed(categoryKey)) {
106
- notAllowedFiles.push(...files);
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
- const newDomainFiles = changedFiles.filter(
120
- (f) =>
121
- (f.filePath.includes('/domain/') || f.filePath.startsWith('domain/')) &&
122
- f.changeKind === 'CREATE'
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
- const apiContractFiles = changedFiles.filter(
135
- (f) => f.filePath.endsWith('port.ts') || f.filePath.endsWith('adapter.ts')
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
+ }