phasegate 0.62.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.62.0",
3
+ "version": "0.63.0",
4
4
  "packageManager": "pnpm@10.30.1",
5
5
  "description": "Phasegate — AI-agnostic quality defense toolkit. Enforces structural integrity between design intent and code.",
6
6
  "license": "Apache-2.0",
@@ -153,6 +153,21 @@
153
153
  "type": "string"
154
154
  },
155
155
  "uniqueItems": true
156
+ },
157
+ "fullModeRequiredWhen": {
158
+ "type": "object",
159
+ "additionalProperties": false,
160
+ "properties": {
161
+ "mixedCategories": {
162
+ "type": "boolean"
163
+ },
164
+ "newDomainFile": {
165
+ "type": "boolean"
166
+ },
167
+ "apiContractChange": {
168
+ "type": "boolean"
169
+ }
170
+ }
156
171
  }
157
172
  }
158
173
  },
@@ -716,6 +716,41 @@ async function main(): Promise<void> {
716
716
  break;
717
717
  }
718
718
 
719
+ // ── quick-mode / check-change-category (H10-05) ──
720
+ case 'check-change-category': {
721
+ if (hasFlag(args, '--help')) {
722
+ process.stdout.write([
723
+ 'Usage: phasegate check-change-category --paths <csv> [options]',
724
+ '',
725
+ 'Classify changed file paths into quick-mode categories and report',
726
+ 'whether Full Mode is required.',
727
+ '',
728
+ 'Options:',
729
+ ' --paths <csv> Comma-separated file paths to classify.',
730
+ ' --format <human|json> Output format. Default: human.',
731
+ ' --fail-on-full-required Exit with code 1 when Full Mode is required.',
732
+ ' --help Show this help.',
733
+ '',
734
+ 'Examples:',
735
+ ' phasegate check-change-category --paths src/foo.ts,src/bar.ts',
736
+ ' phasegate check-change-category --paths src/foo.ts --format json',
737
+ '',
738
+ ].join('\n'));
739
+ return;
740
+ }
741
+ const mod = createQuickModeCompositionRoot();
742
+ const paths = parseFlag(args, '--paths');
743
+ const format = parseFlag(args, '--format') as 'human' | 'json' | undefined;
744
+ const failOnFullRequired = hasFlag(args, '--fail-on-full-required');
745
+ const result = await mod.checkChangeCategoryHandler.handle({
746
+ paths,
747
+ format,
748
+ failOnFullRequired,
749
+ });
750
+ process.exit(result.exitCode);
751
+ break;
752
+ }
753
+
719
754
  // ── harness-api ──
720
755
  case 'phasegate:check-ready': {
721
756
  const mod = createHarnessApiModule();
@@ -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
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @layer presentation
3
+ * @unit quick-mode
4
+ * @story H10-05
5
+ *
6
+ * phasegate check-change-category CLI のハンドラ
7
+ */
8
+
9
+ import type { ClassifyChangeCategoryUseCase } from '../../application/usecases/classify-change-category-usecase.js';
10
+ import { ChangeCategoryFormatter, type ChangeCategoryOutputFormat } from '../formatters/change-category-formatter.js';
11
+
12
+ export interface CheckChangeCategoryHandlerDeps {
13
+ useCase: Pick<ClassifyChangeCategoryUseCase, 'execute'>;
14
+ writer?: (s: string) => void;
15
+ }
16
+
17
+ export interface CheckChangeCategoryHandlerOptions {
18
+ paths?: string;
19
+ format?: ChangeCategoryOutputFormat;
20
+ failOnFullRequired?: boolean;
21
+ }
22
+
23
+ export interface CheckChangeCategoryHandlerResult {
24
+ exitCode: number;
25
+ }
26
+
27
+ export class CheckChangeCategoryHandler {
28
+ private readonly useCase: Pick<ClassifyChangeCategoryUseCase, 'execute'>;
29
+ private readonly writer: (s: string) => void;
30
+ private readonly formatter = new ChangeCategoryFormatter();
31
+
32
+ constructor(deps: CheckChangeCategoryHandlerDeps) {
33
+ this.useCase = deps.useCase;
34
+ this.writer = deps.writer ?? ((s: string) => process.stdout.write(s));
35
+ }
36
+
37
+ async handle(options: CheckChangeCategoryHandlerOptions): Promise<CheckChangeCategoryHandlerResult> {
38
+ const { paths: pathsRaw, format = 'human', failOnFullRequired = false } = options;
39
+
40
+ const paths = pathsRaw
41
+ ? pathsRaw.split(',').map((p) => p.trim()).filter((p) => p.length > 0)
42
+ : [];
43
+
44
+ const contract = await this.useCase.execute({ paths });
45
+ this.writer(this.formatter.format(contract, format));
46
+
47
+ const exitCode = failOnFullRequired && contract.fullModeRequired ? 1 : 0;
48
+ return { exitCode };
49
+ }
50
+ }