phasegate 0.63.0 → 0.65.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 (22) hide show
  1. package/package.json +1 -1
  2. package/scripts/harness/agent-integration/application/dto/handle-pre-tool-use-dto.ts +3 -1
  3. package/scripts/harness/agent-integration/application/usecases/handle-pre-tool-use-usecase.ts +51 -0
  4. package/scripts/harness/agent-integration/domain/ports/full-mode-requirement-query-port.ts +13 -0
  5. package/scripts/harness/agent-integration/domain/value-objects/hook-translation-result.ts +4 -1
  6. package/scripts/harness/agent-integration/infrastructure/adapters/quick-mode-full-mode-requirement-adapter.ts +45 -0
  7. package/scripts/harness/agent-integration/presentation/pre-tool-use-hook.ts +6 -0
  8. package/scripts/harness/ci-governance/application/dto/create-baseline-input.ts +9 -0
  9. package/scripts/harness/ci-governance/application/dto/create-baseline-output.ts +15 -0
  10. package/scripts/harness/ci-governance/application/usecases/create-baseline-usecase.ts +95 -0
  11. package/scripts/harness/ci-governance/composition-root.ts +21 -0
  12. package/scripts/harness/ci-governance/domain/ports/baseline-repository-port.ts +11 -0
  13. package/scripts/harness/ci-governance/domain/ports/file-hasher-port.ts +6 -0
  14. package/scripts/harness/ci-governance/domain/ports/file-scanner-port.ts +11 -0
  15. package/scripts/harness/ci-governance/domain/value-objects/baseline-entry.ts +34 -0
  16. package/scripts/harness/ci-governance/domain/value-objects/baseline-snapshot.ts +55 -0
  17. package/scripts/harness/ci-governance/infrastructure/adapters/baseline-json-repository-adapter.ts +91 -0
  18. package/scripts/harness/ci-governance/infrastructure/adapters/file-system-sha1-hasher-adapter.ts +19 -0
  19. package/scripts/harness/ci-governance/infrastructure/adapters/glob-file-scanner-adapter.ts +79 -0
  20. package/scripts/harness/ci-governance/presentation/handlers/create-baseline-handler.ts +57 -0
  21. package/scripts/harness/config-foundation/infrastructure/schemas/harness-config-v2.schema.json +13 -0
  22. package/scripts/harness/main.ts +21 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.63.0",
3
+ "version": "0.65.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",
@@ -11,10 +11,12 @@ export interface HandlePreToolUseInput {
11
11
  export interface HandlePreToolUseOutput {
12
12
  shouldBlock: boolean;
13
13
  blockedFilePath?: string;
14
- blockReason?: 'PROTECTED_FILE' | 'PHASE_GATE' | 'STORY_REFLECTION';
14
+ blockReason?: 'PROTECTED_FILE' | 'PHASE_GATE' | 'STORY_REFLECTION' | 'FULL_MODE_REQUIRED';
15
15
  error?: { message: string };
16
16
  phaseGateBlockers?: string[];
17
17
  storyReflectionBlockers?: string[];
18
18
  storyReflectionWarnings?: string[];
19
+ fullModeRejectionRule?: 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
20
+ fullModeDominantCategory?: string;
19
21
  nextAction?: string;
20
22
  }
@@ -13,6 +13,7 @@ import type { BlockMetadata } from '../../domain/value-objects/hook-translation-
13
13
  import type { ConfigQueryPort } from '../../domain/ports/config-query-port.js';
14
14
  import type { PhaseGateQueryPort } from '../../domain/ports/phase-gate-query-port.js';
15
15
  import type { StoryReflectionQueryPort } from '../../domain/ports/story-reflection-query-port.js';
16
+ import type { FullModeRequirementQueryPort } from '../../domain/ports/full-mode-requirement-query-port.js';
16
17
  import { WriteTargetScope } from '../../domain/value-objects/write-target-scope.js';
17
18
  import type { HandlePreToolUseInput, HandlePreToolUseOutput } from '../dto/handle-pre-tool-use-dto.js';
18
19
 
@@ -20,6 +21,7 @@ export interface HandlePreToolUseUseCasePorts {
20
21
  configQueryPort: ConfigQueryPort;
21
22
  phaseGateQueryPort: PhaseGateQueryPort;
22
23
  storyReflectionQueryPort?: StoryReflectionQueryPort;
24
+ fullModeRequirementQueryPort?: FullModeRequirementQueryPort;
23
25
  }
24
26
 
25
27
  export class HandlePreToolUseInputValidationError extends Error {
@@ -38,10 +40,12 @@ export class HandlePreToolUseUseCase {
38
40
  private readonly translator: AsyncHookToCliTranslator;
39
41
  private readonly configQueryPort: ConfigQueryPort;
40
42
  private readonly storyReflectionQueryPort?: StoryReflectionQueryPort;
43
+ private readonly fullModeRequirementQueryPort?: FullModeRequirementQueryPort;
41
44
 
42
45
  constructor(ports: HandlePreToolUseUseCasePorts) {
43
46
  this.configQueryPort = ports.configQueryPort;
44
47
  this.storyReflectionQueryPort = ports.storyReflectionQueryPort;
48
+ this.fullModeRequirementQueryPort = ports.fullModeRequirementQueryPort;
45
49
  this.translator = new AsyncHookToCliTranslator({
46
50
  configQueryPort: ports.configQueryPort,
47
51
  reentryGuard: { isActive: () => false } as never,
@@ -79,6 +83,18 @@ export class HandlePreToolUseUseCase {
79
83
  };
80
84
  }
81
85
 
86
+ if (HandlePreToolUseUseCase.WRITE_TOOLS.has(input.toolName)
87
+ && this.fullModeRequirementQueryPort !== undefined
88
+ && input.targetFilePaths.length > 0) {
89
+ const fullModeResult = await this.fullModeRequirementQueryPort.check(input.targetFilePaths);
90
+ if (fullModeResult.requiresFullMode) {
91
+ return HandlePreToolUseUseCase.buildFullModeRequiredBlockOutput(
92
+ input.targetFilePaths[0],
93
+ fullModeResult,
94
+ );
95
+ }
96
+ }
97
+
82
98
  const scope = this.resolveStoryReflectionScope(input);
83
99
  if (scope === null || this.storyReflectionQueryPort === undefined) {
84
100
  return { shouldBlock: false };
@@ -97,6 +113,41 @@ export class HandlePreToolUseUseCase {
97
113
  );
98
114
  }
99
115
 
116
+ private static buildFullModeRequiredBlockOutput(
117
+ blockedFilePath: string | undefined,
118
+ result: {
119
+ requiresFullMode: boolean;
120
+ rejectionRule?: 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
121
+ rejectionReason?: string;
122
+ dominantCategory?: string;
123
+ },
124
+ ): HandlePreToolUseOutput {
125
+ const fp = blockedFilePath ?? '不明なファイル';
126
+ const lines: string[] = [
127
+ `Full mode 必須変更が検出されました: ${fp}`,
128
+ ];
129
+ if (result.dominantCategory) {
130
+ lines.push(`カテゴリ: ${result.dominantCategory}`);
131
+ }
132
+ if (result.rejectionRule) {
133
+ lines.push(`判定ルール: ${result.rejectionRule}`);
134
+ }
135
+ if (result.rejectionReason) {
136
+ lines.push(`理由: ${result.rejectionReason}`);
137
+ }
138
+ lines.push('次のアクション: /story-implementor スキルを使用して設計フェーズから開始してください。');
139
+
140
+ return {
141
+ shouldBlock: true,
142
+ blockedFilePath,
143
+ blockReason: 'FULL_MODE_REQUIRED',
144
+ error: { message: lines.join('\n') },
145
+ fullModeRejectionRule: result.rejectionRule,
146
+ fullModeDominantCategory: result.dominantCategory,
147
+ nextAction: '/story-implementor',
148
+ };
149
+ }
150
+
100
151
  private resolveStoryReflectionScope(input: HandlePreToolUseInput): WriteTargetScope | null {
101
152
  if (!HandlePreToolUseUseCase.WRITE_TOOLS.has(input.toolName)) {
102
153
  return null;
@@ -0,0 +1,13 @@
1
+ // @unit agent-integration
2
+ // @layer domain
3
+
4
+ export interface FullModeRequirementQueryResult {
5
+ readonly requiresFullMode: boolean;
6
+ readonly rejectionRule?: 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
7
+ readonly rejectionReason?: string;
8
+ readonly dominantCategory?: string;
9
+ }
10
+
11
+ export interface FullModeRequirementQueryPort {
12
+ check(targetFilePaths: readonly string[]): Promise<FullModeRequirementQueryResult>;
13
+ }
@@ -8,7 +8,7 @@
8
8
 
9
9
  export type SkipReason = 'REENTRY_DETECTED' | 'HOOK_DISABLED' | 'TIMEOUT_EXCEEDED';
10
10
 
11
- export type BlockReason = 'PROTECTED_FILE' | 'PHASE_GATE';
11
+ export type BlockReason = 'PROTECTED_FILE' | 'PHASE_GATE' | 'FULL_MODE_REQUIRED';
12
12
 
13
13
  export interface BlockMetadata {
14
14
  readonly reason: BlockReason;
@@ -18,6 +18,9 @@ export interface BlockMetadata {
18
18
  readonly scopeLevel?: 1 | 2 | 3;
19
19
  readonly unitId?: string;
20
20
  readonly storyId?: string;
21
+ readonly fullModeRejectionRule?: 'MIXED_CHANGES' | 'NEW_DOMAIN' | 'API_CONTRACT';
22
+ readonly fullModeRejectionReason?: string;
23
+ readonly fullModeDominantCategory?: string;
21
24
  }
22
25
 
23
26
  export class HookTranslationResultInvariantError extends Error {
@@ -0,0 +1,45 @@
1
+ // @unit agent-integration
2
+ // @layer infrastructure
3
+
4
+ import type {
5
+ FullModeRequirementQueryPort,
6
+ FullModeRequirementQueryResult,
7
+ } from '../../domain/ports/full-mode-requirement-query-port.js';
8
+ import type { ClassifyChangeCategoryUseCase } from '../../../quick-mode/application/usecases/classify-change-category-usecase.js';
9
+
10
+ export interface QuickModeFullModeRequirementAdapterDeps {
11
+ classifyUseCaseFactory: () => ClassifyChangeCategoryUseCase;
12
+ }
13
+
14
+ export class QuickModeFullModeRequirementAdapter implements FullModeRequirementQueryPort {
15
+ private readonly classifyUseCaseFactory: () => ClassifyChangeCategoryUseCase;
16
+
17
+ constructor(deps: QuickModeFullModeRequirementAdapterDeps) {
18
+ this.classifyUseCaseFactory = deps.classifyUseCaseFactory;
19
+ }
20
+
21
+ async check(targetFilePaths: readonly string[]): Promise<FullModeRequirementQueryResult> {
22
+ if (targetFilePaths.length === 0) {
23
+ return { requiresFullMode: false };
24
+ }
25
+
26
+ try {
27
+ const useCase = this.classifyUseCaseFactory();
28
+ const contract = await useCase.execute({ paths: [...targetFilePaths] });
29
+ if (!contract.fullModeRequired) {
30
+ return {
31
+ requiresFullMode: false,
32
+ dominantCategory: contract.dominantCategory ?? undefined,
33
+ };
34
+ }
35
+ return {
36
+ requiresFullMode: true,
37
+ rejectionRule: contract.rejectionRule,
38
+ rejectionReason: contract.rejectionReason,
39
+ dominantCategory: contract.dominantCategory ?? undefined,
40
+ };
41
+ } catch {
42
+ return { requiresFullMode: false };
43
+ }
44
+ }
45
+ }
@@ -12,6 +12,8 @@ import { BashWriteTargetExtractor } from '../domain/services/bash-write-target-e
12
12
  import { HarnessConfigConfigQueryAdapter } from '../infrastructure/adapters/harness-config-config-query-adapter.js';
13
13
  import { PhaseGateQueryAdapter } from '../infrastructure/adapters/phase-gate-query-adapter.js';
14
14
  import { FileSystemStoryReflectionQueryAdapter } from '../infrastructure/adapters/file-system-story-reflection-query-adapter.js';
15
+ import { QuickModeFullModeRequirementAdapter } from '../infrastructure/adapters/quick-mode-full-mode-requirement-adapter.js';
16
+ import { createQuickModeCompositionRoot } from '../../quick-mode/composition-root.js';
15
17
  import * as path from 'node:path';
16
18
  import * as fs from 'node:fs/promises';
17
19
 
@@ -118,10 +120,14 @@ async function main(): Promise<void> {
118
120
  rootDir: path.dirname(configPath),
119
121
  configPath,
120
122
  });
123
+ const fullModeRequirementQueryPort = new QuickModeFullModeRequirementAdapter({
124
+ classifyUseCaseFactory: () => createQuickModeCompositionRoot().classifyUseCase,
125
+ });
121
126
  const useCase = new HandlePreToolUseUseCase({
122
127
  configQueryPort,
123
128
  phaseGateQueryPort,
124
129
  storyReflectionQueryPort,
130
+ fullModeRequirementQueryPort,
125
131
  });
126
132
 
127
133
  const output = await useCase.execute({ toolName: effectiveToolName, targetFilePaths });
@@ -0,0 +1,9 @@
1
+ // @unit ci-governance
2
+ // @layer application
3
+
4
+ export interface CreateBaselineInput {
5
+ readonly include?: readonly string[];
6
+ readonly exclude?: readonly string[];
7
+ readonly dryRun?: boolean;
8
+ readonly force?: boolean;
9
+ }
@@ -0,0 +1,15 @@
1
+ // @unit ci-governance
2
+ // @layer application
3
+
4
+ export interface CreateBaselineOutputEntry {
5
+ readonly path: string;
6
+ readonly sha1: string;
7
+ }
8
+
9
+ export interface CreateBaselineOutput {
10
+ readonly savedPath: string;
11
+ readonly entryCount: number;
12
+ readonly dryRun: boolean;
13
+ readonly overwriteBlocked: boolean;
14
+ readonly entries: readonly CreateBaselineOutputEntry[];
15
+ }
@@ -0,0 +1,95 @@
1
+ // @unit ci-governance
2
+ // @layer application
3
+
4
+ import type { FileScannerPort } from '../../domain/ports/file-scanner-port.js';
5
+ import type { FileHasherPort } from '../../domain/ports/file-hasher-port.js';
6
+ import type { BaselineRepositoryPort } from '../../domain/ports/baseline-repository-port.js';
7
+ import { BaselineEntry } from '../../domain/value-objects/baseline-entry.js';
8
+ import { BaselineSnapshot } from '../../domain/value-objects/baseline-snapshot.js';
9
+ import type { CreateBaselineInput } from '../dto/create-baseline-input.js';
10
+ import type { CreateBaselineOutput } from '../dto/create-baseline-output.js';
11
+
12
+ export const DEFAULT_BASELINE_INCLUDE: readonly string[] = Object.freeze([
13
+ 'scripts/**/*.ts',
14
+ 'src/**/*.ts',
15
+ 'src/**/*.tsx',
16
+ 'src/**/*.js',
17
+ 'src/**/*.jsx',
18
+ 'docs/product/construction/**/*.md',
19
+ 'docs/inception/**/*.md',
20
+ ]);
21
+
22
+ export const DEFAULT_BASELINE_EXCLUDE: readonly string[] = Object.freeze([
23
+ 'node_modules/**',
24
+ 'dist/**',
25
+ 'build/**',
26
+ '.next/**',
27
+ 'coverage/**',
28
+ '**/__tests__/**',
29
+ '**/*.test.ts',
30
+ '**/*.spec.ts',
31
+ ]);
32
+
33
+ export class CreateBaselineUseCase {
34
+ constructor(
35
+ private readonly scanner: FileScannerPort,
36
+ private readonly hasher: FileHasherPort,
37
+ private readonly repository: BaselineRepositoryPort,
38
+ private readonly clock: () => Date = () => new Date(),
39
+ ) {}
40
+
41
+ async execute(input: CreateBaselineInput = {}): Promise<CreateBaselineOutput> {
42
+ const include = input.include ?? DEFAULT_BASELINE_INCLUDE;
43
+ const exclude = input.exclude ?? DEFAULT_BASELINE_EXCLUDE;
44
+ const dryRun = input.dryRun === true;
45
+ const force = input.force === true;
46
+
47
+ const savedPath = this.repository.getPath();
48
+
49
+ if (!dryRun && !force && (await this.repository.exists())) {
50
+ return {
51
+ savedPath,
52
+ entryCount: 0,
53
+ dryRun: false,
54
+ overwriteBlocked: true,
55
+ entries: [],
56
+ };
57
+ }
58
+
59
+ const scanned = await this.scanner.scan({ include, exclude });
60
+ const sortedPaths = [...scanned].sort();
61
+
62
+ const entries: BaselineEntry[] = [];
63
+ for (const p of sortedPaths) {
64
+ const sha1 = await this.hasher.hashFile(p);
65
+ entries.push(BaselineEntry.create({ path: p, sha1 }));
66
+ }
67
+
68
+ const snapshot = BaselineSnapshot.create({
69
+ createdAt: this.clock().toISOString(),
70
+ algorithm: 'sha1',
71
+ entries,
72
+ });
73
+
74
+ const outputEntries = entries.map((e) => ({ path: e.path, sha1: e.sha1 }));
75
+
76
+ if (dryRun) {
77
+ return {
78
+ savedPath,
79
+ entryCount: snapshot.entryCount,
80
+ dryRun: true,
81
+ overwriteBlocked: false,
82
+ entries: outputEntries,
83
+ };
84
+ }
85
+
86
+ const writtenPath = await this.repository.save(snapshot);
87
+ return {
88
+ savedPath: writtenPath,
89
+ entryCount: snapshot.entryCount,
90
+ dryRun: false,
91
+ overwriteBlocked: false,
92
+ entries: outputEntries,
93
+ };
94
+ }
95
+ }
@@ -33,17 +33,25 @@ import { AdrFoundationExistenceAdapter } from './infrastructure/adapters/adr-fou
33
33
  import { GenerateCiTemplateHandler } from './presentation/handlers/generate-ci-template-handler.js';
34
34
  import { MigrateAgentsMdHandler } from './presentation/handlers/migrate-agents-md-handler.js';
35
35
  import { CheckRepetitionHandler } from './presentation/handlers/check-repetition-handler.js';
36
+ import { CreateBaselineHandler } from './presentation/handlers/create-baseline-handler.js';
37
+
38
+ import { CreateBaselineUseCase } from './application/usecases/create-baseline-usecase.js';
39
+ import { GlobFileScannerAdapter } from './infrastructure/adapters/glob-file-scanner-adapter.js';
40
+ import { FileSystemSha1HasherAdapter } from './infrastructure/adapters/file-system-sha1-hasher-adapter.js';
41
+ import { BaselineJsonRepositoryAdapter } from './infrastructure/adapters/baseline-json-repository-adapter.js';
36
42
 
37
43
  export interface CiGovernanceCompositionRoot {
38
44
  generateCiTemplateHandler: GenerateCiTemplateHandler;
39
45
  migrateAgentsMdHandler: MigrateAgentsMdHandler;
40
46
  checkRepetitionHandler: CheckRepetitionHandler;
47
+ createBaselineHandler: CreateBaselineHandler;
41
48
  // Use cases exposed for direct access
42
49
  recordErrorOccurrenceUseCase: RecordErrorOccurrenceUseCase;
43
50
  checkEscalationUseCase: CheckEscalationUseCase;
44
51
  resetRepetitionUseCase: ResetRepetitionUseCase;
45
52
  aggregateLessonsUseCase: AggregateLessonsUseCase;
46
53
  validatePointersUseCase: ValidatePointersUseCase;
54
+ createBaselineUseCase: CreateBaselineUseCase;
47
55
  }
48
56
 
49
57
  export function buildCiGovernance(baseDir: string): CiGovernanceCompositionRoot {
@@ -80,6 +88,16 @@ export function buildCiGovernance(baseDir: string): CiGovernanceCompositionRoot
80
88
  const aggregateLessonsUseCase = new AggregateLessonsUseCase(lessonArtifactReaderPort, lessonAggregator);
81
89
  const validatePointersUseCase = new ValidatePointersUseCase(agentsMdPort, pointerValidator);
82
90
 
91
+ // Baseline adapters & use case (ISSUE-007 Wave 1)
92
+ const fileScanner = new GlobFileScannerAdapter(baseDir);
93
+ const fileHasher = new FileSystemSha1HasherAdapter(baseDir);
94
+ const baselineRepository = new BaselineJsonRepositoryAdapter(baseDir);
95
+ const createBaselineUseCase = new CreateBaselineUseCase(
96
+ fileScanner,
97
+ fileHasher,
98
+ baselineRepository,
99
+ );
100
+
83
101
  // Handlers
84
102
  const generateCiTemplateHandler = new GenerateCiTemplateHandler(
85
103
  generateCiTemplateUseCase,
@@ -87,15 +105,18 @@ export function buildCiGovernance(baseDir: string): CiGovernanceCompositionRoot
87
105
  );
88
106
  const migrateAgentsMdHandler = new MigrateAgentsMdHandler(migrateAgentsMdUseCase);
89
107
  const checkRepetitionHandler = new CheckRepetitionHandler(checkEscalationUseCase);
108
+ const createBaselineHandler = new CreateBaselineHandler(createBaselineUseCase);
90
109
 
91
110
  return {
92
111
  generateCiTemplateHandler,
93
112
  migrateAgentsMdHandler,
94
113
  checkRepetitionHandler,
114
+ createBaselineHandler,
95
115
  recordErrorOccurrenceUseCase,
96
116
  checkEscalationUseCase,
97
117
  resetRepetitionUseCase,
98
118
  aggregateLessonsUseCase,
99
119
  validatePointersUseCase,
120
+ createBaselineUseCase,
100
121
  };
101
122
  }
@@ -0,0 +1,11 @@
1
+ // @unit ci-governance
2
+ // @layer domain
3
+
4
+ import type { BaselineSnapshot } from '../value-objects/baseline-snapshot.js';
5
+
6
+ export interface BaselineRepositoryPort {
7
+ save(snapshot: BaselineSnapshot): Promise<string>;
8
+ load(): Promise<BaselineSnapshot | null>;
9
+ exists(): Promise<boolean>;
10
+ getPath(): string;
11
+ }
@@ -0,0 +1,6 @@
1
+ // @unit ci-governance
2
+ // @layer domain
3
+
4
+ export interface FileHasherPort {
5
+ hashFile(relativePath: string): Promise<string>;
6
+ }
@@ -0,0 +1,11 @@
1
+ // @unit ci-governance
2
+ // @layer domain
3
+
4
+ export interface FileScanOptions {
5
+ readonly include: readonly string[];
6
+ readonly exclude: readonly string[];
7
+ }
8
+
9
+ export interface FileScannerPort {
10
+ scan(options: FileScanOptions): Promise<readonly string[]>;
11
+ }
@@ -0,0 +1,34 @@
1
+ // @unit ci-governance
2
+ // @layer domain
3
+
4
+ export interface BaselineEntryProps {
5
+ readonly path: string;
6
+ readonly sha1: string;
7
+ }
8
+
9
+ export class BaselineEntry {
10
+ readonly path: string;
11
+ readonly sha1: string;
12
+
13
+ private constructor(props: BaselineEntryProps) {
14
+ this.path = props.path;
15
+ this.sha1 = props.sha1;
16
+ Object.freeze(this);
17
+ }
18
+
19
+ static create(props: BaselineEntryProps): BaselineEntry {
20
+ if (props.path.length === 0) {
21
+ throw new Error('BaselineEntry: path must not be empty');
22
+ }
23
+ if (!/^[0-9a-f]{40}$/.test(props.sha1)) {
24
+ throw new Error(
25
+ `BaselineEntry: sha1 must be 40 lowercase hex chars, got: ${props.sha1}`,
26
+ );
27
+ }
28
+ return new BaselineEntry(props);
29
+ }
30
+
31
+ equals(other: BaselineEntry): boolean {
32
+ return this.path === other.path && this.sha1 === other.sha1;
33
+ }
34
+ }
@@ -0,0 +1,55 @@
1
+ // @unit ci-governance
2
+ // @layer domain
3
+
4
+ import { BaselineEntry } from './baseline-entry.js';
5
+
6
+ export interface BaselineSnapshotProps {
7
+ readonly createdAt: string;
8
+ readonly algorithm: 'sha1';
9
+ readonly entries: readonly BaselineEntry[];
10
+ }
11
+
12
+ const ISO_8601_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
13
+
14
+ export class BaselineSnapshot {
15
+ readonly createdAt: string;
16
+ readonly algorithm: 'sha1';
17
+ readonly entries: readonly BaselineEntry[];
18
+ private readonly pathIndex: ReadonlySet<string>;
19
+
20
+ private constructor(props: BaselineSnapshotProps) {
21
+ this.createdAt = props.createdAt;
22
+ this.algorithm = props.algorithm;
23
+ this.entries = Object.freeze([...props.entries]);
24
+ this.pathIndex = new Set(props.entries.map((e) => e.path));
25
+ }
26
+
27
+ static create(props: BaselineSnapshotProps): BaselineSnapshot {
28
+ if (!ISO_8601_UTC_RE.test(props.createdAt)) {
29
+ throw new Error(
30
+ `BaselineSnapshot: createdAt must be ISO 8601 UTC, got: ${props.createdAt}`,
31
+ );
32
+ }
33
+ if (props.algorithm !== 'sha1') {
34
+ throw new Error(
35
+ `BaselineSnapshot: algorithm must be 'sha1', got: ${props.algorithm}`,
36
+ );
37
+ }
38
+ const seen = new Set<string>();
39
+ for (const e of props.entries) {
40
+ if (seen.has(e.path)) {
41
+ throw new Error(`BaselineSnapshot: duplicate path ${e.path}`);
42
+ }
43
+ seen.add(e.path);
44
+ }
45
+ return new BaselineSnapshot(props);
46
+ }
47
+
48
+ contains(path: string): boolean {
49
+ return this.pathIndex.has(path);
50
+ }
51
+
52
+ get entryCount(): number {
53
+ return this.entries.length;
54
+ }
55
+ }
@@ -0,0 +1,91 @@
1
+ // @unit ci-governance
2
+ // @layer infrastructure
3
+
4
+ import * as fs from 'node:fs/promises';
5
+ import * as path from 'node:path';
6
+ import type { BaselineRepositoryPort } from '../../domain/ports/baseline-repository-port.js';
7
+ import { BaselineSnapshot } from '../../domain/value-objects/baseline-snapshot.js';
8
+ import { BaselineEntry } from '../../domain/value-objects/baseline-entry.js';
9
+
10
+ interface BaselineJsonV1 {
11
+ readonly version: '1.0';
12
+ readonly createdAt: string;
13
+ readonly algorithm: 'sha1';
14
+ readonly files: ReadonlyArray<{ readonly path: string; readonly sha1: string }>;
15
+ }
16
+
17
+ export class BaselineJsonRepositoryAdapter implements BaselineRepositoryPort {
18
+ private readonly filePath: string;
19
+
20
+ constructor(baseDir: string, relativePath = '.phasegate/baseline.json') {
21
+ this.filePath = path.isAbsolute(relativePath)
22
+ ? relativePath
23
+ : path.join(baseDir, relativePath);
24
+ }
25
+
26
+ getPath(): string {
27
+ return this.filePath;
28
+ }
29
+
30
+ async exists(): Promise<boolean> {
31
+ try {
32
+ await fs.access(this.filePath);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ async save(snapshot: BaselineSnapshot): Promise<string> {
40
+ const data: BaselineJsonV1 = {
41
+ version: '1.0',
42
+ createdAt: snapshot.createdAt,
43
+ algorithm: snapshot.algorithm,
44
+ files: snapshot.entries.map((e) => ({ path: e.path, sha1: e.sha1 })),
45
+ };
46
+ await fs.mkdir(path.dirname(this.filePath), { recursive: true });
47
+ await fs.writeFile(
48
+ this.filePath,
49
+ `${JSON.stringify(data, null, 2)}\n`,
50
+ 'utf-8',
51
+ );
52
+ return this.filePath;
53
+ }
54
+
55
+ async load(): Promise<BaselineSnapshot | null> {
56
+ let content: string;
57
+ try {
58
+ content = await fs.readFile(this.filePath, 'utf-8');
59
+ } catch (err) {
60
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
61
+ throw err;
62
+ }
63
+
64
+ let parsed: BaselineJsonV1;
65
+ try {
66
+ parsed = JSON.parse(content) as BaselineJsonV1;
67
+ } catch (err) {
68
+ throw new Error(
69
+ `Invalid baseline JSON at ${this.filePath}: ${String(err)}`,
70
+ );
71
+ }
72
+
73
+ if (
74
+ !parsed ||
75
+ parsed.version !== '1.0' ||
76
+ parsed.algorithm !== 'sha1' ||
77
+ !Array.isArray(parsed.files)
78
+ ) {
79
+ throw new Error(`Invalid baseline JSON schema at ${this.filePath}`);
80
+ }
81
+
82
+ const entries = parsed.files.map((f) =>
83
+ BaselineEntry.create({ path: f.path, sha1: f.sha1 }),
84
+ );
85
+ return BaselineSnapshot.create({
86
+ createdAt: parsed.createdAt,
87
+ algorithm: parsed.algorithm,
88
+ entries,
89
+ });
90
+ }
91
+ }
@@ -0,0 +1,19 @@
1
+ // @unit ci-governance
2
+ // @layer infrastructure
3
+
4
+ import * as fs from 'node:fs/promises';
5
+ import * as path from 'node:path';
6
+ import * as crypto from 'node:crypto';
7
+ import type { FileHasherPort } from '../../domain/ports/file-hasher-port.js';
8
+
9
+ export class FileSystemSha1HasherAdapter implements FileHasherPort {
10
+ constructor(private readonly baseDir: string) {}
11
+
12
+ async hashFile(relativePath: string): Promise<string> {
13
+ const absPath = path.isAbsolute(relativePath)
14
+ ? relativePath
15
+ : path.join(this.baseDir, relativePath);
16
+ const content = await fs.readFile(absPath);
17
+ return crypto.createHash('sha1').update(content).digest('hex');
18
+ }
19
+ }
@@ -0,0 +1,79 @@
1
+ // @unit ci-governance
2
+ // @layer infrastructure
3
+
4
+ import * as fs from 'node:fs/promises';
5
+ import * as path from 'node:path';
6
+ import { createRequire } from 'node:module';
7
+ import type {
8
+ FileScannerPort,
9
+ FileScanOptions,
10
+ } from '../../domain/ports/file-scanner-port.js';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const picomatch = require('picomatch') as (
14
+ pattern: string,
15
+ options?: { dot?: boolean },
16
+ ) => (p: string) => boolean;
17
+
18
+ const HARD_SKIP_DIRS: ReadonlySet<string> = new Set([
19
+ 'node_modules',
20
+ '.git',
21
+ 'dist',
22
+ 'build',
23
+ '.next',
24
+ 'coverage',
25
+ '.phasegate',
26
+ ]);
27
+
28
+ export class GlobFileScannerAdapter implements FileScannerPort {
29
+ constructor(private readonly baseDir: string) {}
30
+
31
+ async scan(options: FileScanOptions): Promise<readonly string[]> {
32
+ const includeMatchers = options.include.map((p) =>
33
+ picomatch(p, { dot: true }),
34
+ );
35
+ const excludeMatchers = options.exclude.map((p) =>
36
+ picomatch(p, { dot: true }),
37
+ );
38
+ const collected: string[] = [];
39
+ await this.walk(this.baseDir, '', includeMatchers, excludeMatchers, collected);
40
+ return collected.sort();
41
+ }
42
+
43
+ private async walk(
44
+ absDir: string,
45
+ relDir: string,
46
+ includeMatchers: Array<(p: string) => boolean>,
47
+ excludeMatchers: Array<(p: string) => boolean>,
48
+ out: string[],
49
+ ): Promise<void> {
50
+ let entries: Array<{
51
+ name: string;
52
+ isDirectory: () => boolean;
53
+ isFile: () => boolean;
54
+ }>;
55
+ try {
56
+ entries = (await fs.readdir(absDir, {
57
+ withFileTypes: true,
58
+ })) as unknown as typeof entries;
59
+ } catch {
60
+ return;
61
+ }
62
+
63
+ for (const ent of entries) {
64
+ if (ent.isDirectory() && HARD_SKIP_DIRS.has(ent.name)) continue;
65
+ const relPath = relDir === '' ? ent.name : `${relDir}/${ent.name}`;
66
+ const absPath = path.join(absDir, ent.name);
67
+
68
+ if (ent.isDirectory()) {
69
+ await this.walk(absPath, relPath, includeMatchers, excludeMatchers, out);
70
+ continue;
71
+ }
72
+
73
+ if (!ent.isFile()) continue;
74
+ if (!includeMatchers.some((m) => m(relPath))) continue;
75
+ if (excludeMatchers.some((m) => m(relPath))) continue;
76
+ out.push(relPath);
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,57 @@
1
+ // @unit ci-governance
2
+ // @layer presentation
3
+
4
+ import type { CreateBaselineUseCase } from '../../application/usecases/create-baseline-usecase.js';
5
+
6
+ export interface CreateBaselineHandlerArgs {
7
+ readonly include?: readonly string[];
8
+ readonly exclude?: readonly string[];
9
+ readonly dryRun?: boolean;
10
+ readonly force?: boolean;
11
+ readonly format?: 'human' | 'json';
12
+ }
13
+
14
+ export interface CreateBaselineHandlerResult {
15
+ readonly exitCode: number;
16
+ readonly output: string;
17
+ }
18
+
19
+ export class CreateBaselineHandler {
20
+ constructor(private readonly useCase: CreateBaselineUseCase) {}
21
+
22
+ async handle(args: CreateBaselineHandlerArgs): Promise<CreateBaselineHandlerResult> {
23
+ const format = args.format ?? 'human';
24
+ const result = await this.useCase.execute({
25
+ include: args.include,
26
+ exclude: args.exclude,
27
+ dryRun: args.dryRun,
28
+ force: args.force,
29
+ });
30
+
31
+ if (format === 'json') {
32
+ return {
33
+ exitCode: result.overwriteBlocked ? 2 : 0,
34
+ output: JSON.stringify(result, null, 2),
35
+ };
36
+ }
37
+
38
+ if (result.overwriteBlocked) {
39
+ return {
40
+ exitCode: 2,
41
+ output: [
42
+ `baseline は既に存在します: ${result.savedPath}`,
43
+ '上書きするには --force を指定してください。',
44
+ '生成内容のみを確認したい場合は --dry-run を指定してください。',
45
+ ].join('\n'),
46
+ };
47
+ }
48
+
49
+ const header = result.dryRun
50
+ ? `[dry run] baseline を生成しました(保存先想定: ${result.savedPath})`
51
+ : `baseline を保存しました: ${result.savedPath}`;
52
+ return {
53
+ exitCode: 0,
54
+ output: [header, `エントリ数: ${result.entryCount}`].join('\n'),
55
+ };
56
+ }
57
+ }
@@ -439,6 +439,19 @@
439
439
  "uniqueItems": true
440
440
  }
441
441
  }
442
+ },
443
+ "baseline": {
444
+ "type": "object",
445
+ "additionalProperties": false,
446
+ "properties": {
447
+ "enabled": {
448
+ "type": "boolean"
449
+ },
450
+ "path": {
451
+ "type": "string",
452
+ "minLength": 1
453
+ }
454
+ }
442
455
  }
443
456
  }
444
457
  }
@@ -88,6 +88,7 @@ Commands:
88
88
  ci:generate-template Generate CI template (--preset <id>, --type <aidlc-gate|consistency-check|pre-commit>, --render, --json)
89
89
  ci:migrate-agents-md Migrate AGENTS.md (--dry-run, --validate-only, --json)
90
90
  ci:check-repetition Check error repetition (--code <errorCode>, --reset, --json)
91
+ baseline Create retrofit baseline snapshot (--dry-run, --force, --paths <glob,glob,...>, --json)
91
92
 
92
93
  skill:execute-tdd-cycle Execute TDD cycle (--unit, --story, --desc, --phase RED|GREEN|REFACTOR, --passed)
93
94
  skill:check-coverage Check coverage (--story <storyId>, --json)
@@ -896,6 +897,26 @@ Examples:
896
897
  break;
897
898
  }
898
899
 
900
+ case 'baseline': {
901
+ const mod = buildCiGovernance(rootDir);
902
+ const dryRun = hasFlag(args, '--dry-run');
903
+ const force = hasFlag(args, '--force');
904
+ const pathsFlag = parseFlag(args, '--paths');
905
+ const include = pathsFlag
906
+ ? pathsFlag.split(',').map((s) => s.trim()).filter(Boolean)
907
+ : undefined;
908
+ const format = json ? 'json' : 'human';
909
+ const result = await mod.createBaselineHandler.handle({
910
+ include,
911
+ dryRun,
912
+ force,
913
+ format,
914
+ });
915
+ console.log(result.output);
916
+ process.exit(result.exitCode);
917
+ break;
918
+ }
919
+
899
920
  // ── skill-quality ──
900
921
  case 'skill:execute-tdd-cycle': {
901
922
  const mod = createSkillQualityHandlers();