From 39bf24d7370233b13c7471eaa6d81e45ad7aa24a Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:43:29 +0900 Subject: [PATCH] takt: github-issue-382-feat-purojeku --- src/__tests__/globalConfig.test.ts | 121 +++++++++++ src/__tests__/projectConfig.test.ts | 99 +++++++++ src/__tests__/qualityGateOverrides.test.ts | 194 ++++++++++++++++++ src/core/models/persisted-global-config.ts | 19 ++ src/core/models/schemas.ts | 19 ++ src/infra/config/global/globalConfig.ts | 52 ++++- src/infra/config/loaders/pieceParser.ts | 28 ++- .../config/loaders/qualityGateOverrides.ts | 84 ++++++++ src/infra/config/project/projectConfig.ts | 55 ++++- src/infra/config/resolveConfigValue.ts | 1 + src/infra/config/types.ts | 4 +- 11 files changed, 669 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/globalConfig.test.ts create mode 100644 src/__tests__/projectConfig.test.ts create mode 100644 src/__tests__/qualityGateOverrides.test.ts create mode 100644 src/infra/config/loaders/qualityGateOverrides.ts diff --git a/src/__tests__/globalConfig.test.ts b/src/__tests__/globalConfig.test.ts new file mode 100644 index 00000000..8fed9825 --- /dev/null +++ b/src/__tests__/globalConfig.test.ts @@ -0,0 +1,121 @@ +/** + * Global config tests. + * + * Tests global config loading and saving with piece_overrides, + * including empty array round-trip behavior. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { PersistedGlobalConfig } from '../core/models/persisted-global-config.js'; + +// Mock the getGlobalConfigPath to use a test directory +let testConfigPath: string; +vi.mock('../infra/config/paths.js', () => ({ + getGlobalConfigPath: () => testConfigPath, + getGlobalTaktDir: () => join(testConfigPath, '..'), + getProjectTaktDir: vi.fn(), + getProjectCwd: vi.fn(), +})); + +import { GlobalConfigManager } from '../infra/config/global/globalConfig.js'; + +describe('globalConfig', () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'takt-test-global-config-')); + mkdirSync(testDir, { recursive: true }); + testConfigPath = join(testDir, 'config.yaml'); + GlobalConfigManager.resetInstance(); + }); + + afterEach(() => { + GlobalConfigManager.resetInstance(); + if (testDir) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('piece_overrides empty array round-trip', () => { + it('should preserve empty quality_gates array in save/load cycle', () => { + // Write config with empty quality_gates array + const configContent = ` +piece_overrides: + quality_gates: [] +`; + writeFileSync(testConfigPath, configContent, 'utf-8'); + + // Load config + const manager = GlobalConfigManager.getInstance(); + const loaded = manager.load(); + expect(loaded.pieceOverrides?.qualityGates).toEqual([]); + + // Save config + manager.save(loaded); + + // Reset and reload to verify empty array is preserved + GlobalConfigManager.resetInstance(); + const reloadedManager = GlobalConfigManager.getInstance(); + const reloaded = reloadedManager.load(); + expect(reloaded.pieceOverrides?.qualityGates).toEqual([]); + }); + + it('should preserve empty quality_gates in movements', () => { + const configContent = ` +piece_overrides: + movements: + implement: + quality_gates: [] +`; + writeFileSync(testConfigPath, configContent, 'utf-8'); + + const manager = GlobalConfigManager.getInstance(); + const loaded = manager.load(); + expect(loaded.pieceOverrides?.movements?.implement?.qualityGates).toEqual([]); + + manager.save(loaded); + + GlobalConfigManager.resetInstance(); + const reloadedManager = GlobalConfigManager.getInstance(); + const reloaded = reloadedManager.load(); + expect(reloaded.pieceOverrides?.movements?.implement?.qualityGates).toEqual([]); + }); + + it('should distinguish undefined from empty array', () => { + // Test with undefined (not specified) + writeFileSync(testConfigPath, 'piece_overrides: {}\n', 'utf-8'); + + const manager1 = GlobalConfigManager.getInstance(); + const loaded1 = manager1.load(); + expect(loaded1.pieceOverrides?.qualityGates).toBeUndefined(); + + // Test with empty array (explicitly disabled) + GlobalConfigManager.resetInstance(); + writeFileSync(testConfigPath, 'piece_overrides:\n quality_gates: []\n', 'utf-8'); + + const manager2 = GlobalConfigManager.getInstance(); + const loaded2 = manager2.load(); + expect(loaded2.pieceOverrides?.qualityGates).toEqual([]); + }); + + it('should preserve non-empty quality_gates array', () => { + const config: PersistedGlobalConfig = { + pieceOverrides: { + qualityGates: ['Test 1', 'Test 2'], + }, + }; + + const manager = GlobalConfigManager.getInstance(); + manager.save(config); + + GlobalConfigManager.resetInstance(); + const reloadedManager = GlobalConfigManager.getInstance(); + const reloaded = reloadedManager.load(); + + expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']); + }); + }); +}); diff --git a/src/__tests__/projectConfig.test.ts b/src/__tests__/projectConfig.test.ts new file mode 100644 index 00000000..c60ccd86 --- /dev/null +++ b/src/__tests__/projectConfig.test.ts @@ -0,0 +1,99 @@ +/** + * Project config tests. + * + * Tests project config loading and saving with piece_overrides, + * including empty array round-trip behavior. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { loadProjectConfig, saveProjectConfig } from '../infra/config/project/projectConfig.js'; +import type { ProjectLocalConfig } from '../infra/config/types.js'; + +describe('projectConfig', () => { + let testDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'takt-test-project-config-')); + mkdirSync(join(testDir, '.takt'), { recursive: true }); + }); + + afterEach(() => { + if (testDir) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('piece_overrides empty array round-trip', () => { + it('should preserve empty quality_gates array in save/load cycle', () => { + // Write config with empty quality_gates array + const configPath = join(testDir, '.takt', 'config.yaml'); + const configContent = ` +piece_overrides: + quality_gates: [] +`; + writeFileSync(configPath, configContent, 'utf-8'); + + // Load config + const loaded = loadProjectConfig(testDir); + expect(loaded.pieceOverrides?.qualityGates).toEqual([]); + + // Save config + saveProjectConfig(testDir, loaded); + + // Reload and verify empty array is preserved + const reloaded = loadProjectConfig(testDir); + expect(reloaded.pieceOverrides?.qualityGates).toEqual([]); + }); + + it('should preserve empty quality_gates in movements', () => { + const configPath = join(testDir, '.takt', 'config.yaml'); + const configContent = ` +piece_overrides: + movements: + implement: + quality_gates: [] +`; + writeFileSync(configPath, configContent, 'utf-8'); + + const loaded = loadProjectConfig(testDir); + expect(loaded.pieceOverrides?.movements?.implement?.qualityGates).toEqual([]); + + saveProjectConfig(testDir, loaded); + + const reloaded = loadProjectConfig(testDir); + expect(reloaded.pieceOverrides?.movements?.implement?.qualityGates).toEqual([]); + }); + + it('should distinguish undefined from empty array', () => { + // Test with undefined (not specified) + const configPath1 = join(testDir, '.takt', 'config.yaml'); + writeFileSync(configPath1, 'piece_overrides: {}\n', 'utf-8'); + + const loaded1 = loadProjectConfig(testDir); + expect(loaded1.pieceOverrides?.qualityGates).toBeUndefined(); + + // Test with empty array (explicitly disabled) + const configPath2 = join(testDir, '.takt', 'config.yaml'); + writeFileSync(configPath2, 'piece_overrides:\n quality_gates: []\n', 'utf-8'); + + const loaded2 = loadProjectConfig(testDir); + expect(loaded2.pieceOverrides?.qualityGates).toEqual([]); + }); + + it('should preserve non-empty quality_gates array', () => { + const config: ProjectLocalConfig = { + pieceOverrides: { + qualityGates: ['Test 1', 'Test 2'], + }, + }; + + saveProjectConfig(testDir, config); + const reloaded = loadProjectConfig(testDir); + + expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']); + }); + }); +}); diff --git a/src/__tests__/qualityGateOverrides.test.ts b/src/__tests__/qualityGateOverrides.test.ts new file mode 100644 index 00000000..330b3043 --- /dev/null +++ b/src/__tests__/qualityGateOverrides.test.ts @@ -0,0 +1,194 @@ +/** + * Tests for quality gate override logic + */ + +import { describe, it, expect } from 'vitest'; +import { applyQualityGateOverrides } from '../infra/config/loaders/qualityGateOverrides.js'; +import type { PieceOverrides } from '../core/models/persisted-global-config.js'; + +describe('applyQualityGateOverrides', () => { + it('returns undefined when no gates are defined', () => { + const result = applyQualityGateOverrides('implement', undefined, true, undefined, undefined); + expect(result).toBeUndefined(); + }); + + it('returns YAML gates when no overrides are defined', () => { + const yamlGates = ['Test passes']; + const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, undefined); + expect(result).toEqual(['Test passes']); + }); + + it('returns empty array when yamlGates is empty array and no overrides', () => { + const yamlGates: string[] = []; + const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, undefined); + expect(result).toEqual([]); + }); + + it('merges global override gates with YAML gates (additive)', () => { + const yamlGates = ['Unit tests pass']; + const globalOverrides: PieceOverrides = { + qualityGates: ['E2E tests pass'], + }; + const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, globalOverrides); + expect(result).toEqual(['E2E tests pass', 'Unit tests pass']); + }); + + it('applies movement-specific override from global config', () => { + const yamlGates = ['Unit tests pass']; + const globalOverrides: PieceOverrides = { + qualityGates: ['Global gate'], + movements: { + implement: { + qualityGates: ['Movement-specific gate'], + }, + }, + }; + const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, globalOverrides); + expect(result).toEqual(['Global gate', 'Movement-specific gate', 'Unit tests pass']); + }); + + it('applies project overrides with higher priority than global', () => { + const yamlGates = ['YAML gate']; + const globalOverrides: PieceOverrides = { + qualityGates: ['Global gate'], + }; + const projectOverrides: PieceOverrides = { + qualityGates: ['Project gate'], + }; + const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, globalOverrides); + expect(result).toEqual(['Global gate', 'Project gate', 'YAML gate']); + }); + + it('applies movement-specific override from project config', () => { + const yamlGates = ['YAML gate']; + const projectOverrides: PieceOverrides = { + movements: { + implement: { + qualityGates: ['Project movement gate'], + }, + }, + }; + const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, undefined); + expect(result).toEqual(['Project movement gate', 'YAML gate']); + }); + + it('filters global gates when qualityGatesEditOnly=true and edit=false', () => { + const yamlGates = ['YAML gate']; + const globalOverrides: PieceOverrides = { + qualityGates: ['Global gate'], + qualityGatesEditOnly: true, + }; + const result = applyQualityGateOverrides('review', yamlGates, false, undefined, globalOverrides); + expect(result).toEqual(['YAML gate']); // Global gate excluded because edit=false + }); + + it('includes global gates when qualityGatesEditOnly=true and edit=true', () => { + const yamlGates = ['YAML gate']; + const globalOverrides: PieceOverrides = { + qualityGates: ['Global gate'], + qualityGatesEditOnly: true, + }; + const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, globalOverrides); + expect(result).toEqual(['Global gate', 'YAML gate']); + }); + + it('filters project global gates when qualityGatesEditOnly=true and edit=false', () => { + const yamlGates = ['YAML gate']; + const projectOverrides: PieceOverrides = { + qualityGates: ['Project gate'], + qualityGatesEditOnly: true, + }; + const result = applyQualityGateOverrides('review', yamlGates, false, projectOverrides, undefined); + expect(result).toEqual(['YAML gate']); // Project gate excluded because edit=false + }); + + it('applies movement-specific gates regardless of qualityGatesEditOnly flag', () => { + const yamlGates = ['YAML gate']; + const projectOverrides: PieceOverrides = { + qualityGates: ['Project global gate'], + qualityGatesEditOnly: true, + movements: { + review: { + qualityGates: ['Review-specific gate'], + }, + }, + }; + const result = applyQualityGateOverrides('review', yamlGates, false, projectOverrides, undefined); + // Project global gate excluded (edit=false), but movement-specific gate included + expect(result).toEqual(['Review-specific gate', 'YAML gate']); + }); + + it('handles complex priority scenario with all override types', () => { + const yamlGates = ['YAML gate']; + const globalOverrides: PieceOverrides = { + qualityGates: ['Global gate'], + movements: { + implement: { + qualityGates: ['Global movement gate'], + }, + }, + }; + const projectOverrides: PieceOverrides = { + qualityGates: ['Project gate'], + movements: { + implement: { + qualityGates: ['Project movement gate'], + }, + }, + }; + const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, globalOverrides); + expect(result).toEqual([ + 'Global gate', + 'Global movement gate', + 'Project gate', + 'Project movement gate', + 'YAML gate', + ]); + }); + + it('returns YAML gates only when other movements are specified in overrides', () => { + const yamlGates = ['YAML gate']; + const projectOverrides: PieceOverrides = { + movements: { + review: { + qualityGates: ['Review gate'], + }, + }, + }; + const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, undefined); + expect(result).toEqual(['YAML gate']); // No override for 'implement', only for 'review' + }); + + describe('deduplication', () => { + it('removes duplicate gates from multiple sources', () => { + const yamlGates = ['Test 1', 'Test 2']; + const globalOverrides: PieceOverrides = { + qualityGates: ['Test 2', 'Test 3'], + }; + const projectOverrides: PieceOverrides = { + qualityGates: ['Test 1', 'Test 4'], + }; + const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, globalOverrides); + // Duplicates removed: Test 1, Test 2 appear only once + expect(result).toEqual(['Test 2', 'Test 3', 'Test 1', 'Test 4']); + }); + + it('removes duplicate gates from single source', () => { + const projectOverrides: PieceOverrides = { + qualityGates: ['Test 1', 'Test 2', 'Test 1', 'Test 3', 'Test 2'], + }; + const result = applyQualityGateOverrides('implement', undefined, true, projectOverrides, undefined); + expect(result).toEqual(['Test 1', 'Test 2', 'Test 3']); + }); + + it('removes duplicate gates from YAML and overrides', () => { + const yamlGates = ['npm run test', 'npm run lint']; + const projectOverrides: PieceOverrides = { + qualityGates: ['npm run test', 'npm run build'], + }; + const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, undefined); + // 'npm run test' appears only once + expect(result).toEqual(['npm run test', 'npm run build', 'npm run lint']); + }); + }); +}); diff --git a/src/core/models/persisted-global-config.ts b/src/core/models/persisted-global-config.ts index 699f4985..1f671baa 100644 --- a/src/core/models/persisted-global-config.ts +++ b/src/core/models/persisted-global-config.ts @@ -10,6 +10,21 @@ export interface PersonaProviderEntry { model?: string; } +/** Movement-specific quality gates override */ +export interface MovementQualityGatesOverride { + qualityGates?: string[]; +} + +/** Piece-level overrides (quality_gates, etc.) */ +export interface PieceOverrides { + /** Global quality gates applied to all movements */ + qualityGates?: string[]; + /** Whether to apply quality_gates only to edit: true movements */ + qualityGatesEditOnly?: boolean; + /** Movement-specific quality gates overrides */ + movements?: Record; +} + /** Custom agent configuration */ export interface CustomAgentConfig { name: string; @@ -127,6 +142,8 @@ export interface PersistedGlobalConfig { autoFetch?: boolean; /** Base branch to clone from (default: current branch) */ baseBranch?: string; + /** Piece-level overrides (quality_gates, etc.) */ + pieceOverrides?: PieceOverrides; } /** Project-level configuration */ @@ -141,4 +158,6 @@ export interface ProjectConfig { concurrency?: number; /** Base branch to clone from (overrides global baseBranch) */ baseBranch?: string; + /** Piece-level overrides (quality_gates, etc.) */ + pieceOverrides?: PieceOverrides; } diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index e04f7822..a190f1c9 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -153,6 +153,21 @@ export const OutputContractsFieldSchema = z.object({ /** Quality gates schema - AI directives for movement completion (string array) */ export const QualityGatesSchema = z.array(z.string()).optional(); +/** Movement-specific quality gates override schema */ +export const MovementQualityGatesOverrideSchema = z.object({ + quality_gates: QualityGatesSchema, +}).optional(); + +/** Piece overrides schema for config-level overrides */ +export const PieceOverridesSchema = z.object({ + /** Global quality gates applied to all movements */ + quality_gates: QualityGatesSchema, + /** Whether to apply quality_gates only to edit: true movements */ + quality_gates_edit_only: z.boolean().optional(), + /** Movement-specific quality gates overrides */ + movements: z.record(z.string(), MovementQualityGatesOverrideSchema).optional(), +}).optional(); + /** Rule-based transition schema (new unified format) */ export const PieceRuleSchema = z.object({ /** Human-readable condition text */ @@ -485,6 +500,8 @@ export const GlobalConfigSchema = z.object({ auto_fetch: z.boolean().optional().default(false), /** Base branch to clone from (default: current branch) */ base_branch: z.string().optional(), + /** Piece-level overrides (quality_gates, etc.) */ + piece_overrides: PieceOverridesSchema, }); /** Project config schema */ @@ -498,4 +515,6 @@ export const ProjectConfigSchema = z.object({ concurrency: z.number().int().min(1).max(10).optional(), /** Base branch to clone from (overrides global base_branch) */ base_branch: z.string().optional(), + /** Piece-level overrides (quality_gates, etc.) */ + piece_overrides: PieceOverridesSchema, }); diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index bf10412a..91e3728a 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -10,7 +10,7 @@ import { isAbsolute } from 'node:path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { GlobalConfigSchema } from '../../../core/models/index.js'; import type { Language } from '../../../core/models/index.js'; -import type { PersistedGlobalConfig, PersonaProviderEntry } from '../../../core/models/persisted-global-config.js'; +import type { PersistedGlobalConfig, PersonaProviderEntry, PieceOverrides } from '../../../core/models/persisted-global-config.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; import { normalizeProviderOptions } from '../loaders/pieceParser.js'; import { getGlobalConfigPath } from '../paths.js'; @@ -122,6 +122,51 @@ function denormalizeProviderProfiles( }])) as Record }>; } +/** Normalize piece_overrides from snake_case (YAML) to camelCase (internal) */ +function normalizePieceOverrides( + raw: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record } | undefined, +): PieceOverrides | undefined { + if (!raw) return undefined; + return { + qualityGates: raw.quality_gates, + qualityGatesEditOnly: raw.quality_gates_edit_only, + movements: raw.movements + ? Object.fromEntries( + Object.entries(raw.movements).map(([name, override]) => [ + name, + { qualityGates: override.quality_gates }, + ]) + ) + : undefined, + }; +} + +/** Denormalize piece_overrides from camelCase (internal) to snake_case (YAML) */ +function denormalizePieceOverrides( + overrides: PieceOverrides | undefined, +): { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record } | undefined { + if (!overrides) return undefined; + const result: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record } = {}; + if (overrides.qualityGates !== undefined) { + result.quality_gates = overrides.qualityGates; + } + if (overrides.qualityGatesEditOnly !== undefined) { + result.quality_gates_edit_only = overrides.qualityGatesEditOnly; + } + if (overrides.movements) { + result.movements = Object.fromEntries( + Object.entries(overrides.movements).map(([name, override]) => { + const movementOverride: { quality_gates?: string[] } = {}; + if (override.qualityGates !== undefined) { + movementOverride.quality_gates = override.qualityGates; + } + return [name, movementOverride]; + }) + ); + } + return Object.keys(result).length > 0 ? result : undefined; +} + /** * Manages global configuration loading and caching. * Singleton — use GlobalConfigManager.getInstance(). @@ -222,6 +267,7 @@ export class GlobalConfigManager { taskPollIntervalMs: parsed.task_poll_interval_ms, autoFetch: parsed.auto_fetch, baseBranch: parsed.base_branch, + pieceOverrides: normalizePieceOverrides(parsed.piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record } | undefined), }; validateProviderModelCompatibility(config.provider, config.model); this.cachedConfig = config; @@ -358,6 +404,10 @@ export class GlobalConfigManager { if (config.baseBranch) { raw.base_branch = config.baseBranch; } + const denormalizedPieceOverrides = denormalizePieceOverrides(config.pieceOverrides); + if (denormalizedPieceOverrides) { + raw.piece_overrides = denormalizedPieceOverrides; + } writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); this.invalidateCache(); invalidateAllResolvedConfigCache(); diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index ba5b2e84..200d208b 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -28,6 +28,10 @@ type RawPiece = z.output; import type { MovementProviderOptions } from '../../../core/models/piece-types.js'; import type { PieceRuntimeConfig } from '../../../core/models/piece-types.js'; +import type { PieceOverrides } from '../../../core/models/persisted-global-config.js'; +import { applyQualityGateOverrides } from './qualityGateOverrides.js'; +import { loadProjectConfig } from '../project/projectConfig.js'; +import { loadGlobalConfig } from '../global/globalConfig.js'; /** Convert raw YAML provider_options (snake_case) to internal format (camelCase). */ export function normalizeProviderOptions( @@ -277,6 +281,8 @@ function normalizeStepFromRaw( sections: PieceSections, inheritedProviderOptions?: PieceMovement['providerOptions'], context?: FacetResolutionContext, + projectOverrides?: PieceOverrides, + globalOverrides?: PieceOverrides, ): PieceMovement { const rules: PieceRule[] | undefined = step.rules?.map(normalizeRule); @@ -315,7 +321,13 @@ function normalizeStepFromRaw( : undefined) || expandedInstruction || '{task}', rules, outputContracts: normalizeOutputContracts(step.output_contracts, pieceDir, sections.resolvedReportFormats, context), - qualityGates: step.quality_gates, + qualityGates: applyQualityGateOverrides( + step.name, + step.quality_gates, + step.edit, + projectOverrides, + globalOverrides, + ), passPreviousResponse: step.pass_previous_response ?? true, policyContents, knowledgeContents, @@ -323,7 +335,7 @@ function normalizeStepFromRaw( if (step.parallel && step.parallel.length > 0) { result.parallel = step.parallel.map((sub: RawStep) => - normalizeStepFromRaw(sub, pieceDir, sections, inheritedProviderOptions, context), + normalizeStepFromRaw(sub, pieceDir, sections, inheritedProviderOptions, context, projectOverrides, globalOverrides), ); } @@ -381,6 +393,8 @@ export function normalizePieceConfig( raw: unknown, pieceDir: string, context?: FacetResolutionContext, + projectOverrides?: PieceOverrides, + globalOverrides?: PieceOverrides, ): PieceConfig { const parsed = PieceConfigRawSchema.parse(raw); @@ -401,7 +415,7 @@ export function normalizePieceConfig( const pieceRuntime = normalizeRuntimeConfig(parsed.piece_config); const movements: PieceMovement[] = parsed.movements.map((step) => - normalizeStepFromRaw(step, pieceDir, sections, pieceProviderOptions, context), + normalizeStepFromRaw(step, pieceDir, sections, pieceProviderOptions, context, projectOverrides, globalOverrides), ); // Schema guarantees movements.min(1) @@ -446,5 +460,11 @@ export function loadPieceFromFile(filePath: string, projectDir: string): PieceCo repertoireDir: getRepertoireDir(), }; - return normalizePieceConfig(raw, pieceDir, context); + // Load config overrides from project and global configs + const projectConfig = loadProjectConfig(projectDir); + const globalConfig = loadGlobalConfig(); + const projectOverrides = projectConfig.pieceOverrides; + const globalOverrides = globalConfig.pieceOverrides; + + return normalizePieceConfig(raw, pieceDir, context, projectOverrides, globalOverrides); } diff --git a/src/infra/config/loaders/qualityGateOverrides.ts b/src/infra/config/loaders/qualityGateOverrides.ts new file mode 100644 index 00000000..e8040b2d --- /dev/null +++ b/src/infra/config/loaders/qualityGateOverrides.ts @@ -0,0 +1,84 @@ +/** + * Quality gate override application logic + * + * Resolves quality gates from config overrides with 3-layer priority: + * 1. Project .takt/config.yaml piece_overrides + * 2. Global ~/.takt/config.yaml piece_overrides + * 3. Piece YAML quality_gates + * + * Merge strategy: Additive (config gates + YAML gates) + */ + +import type { PieceOverrides } from '../../../core/models/persisted-global-config.js'; + +/** + * Apply quality gate overrides to a movement. + * + * Merge order (gates are added in this sequence): + * 1. Global override in global config (filtered by edit flag if qualityGatesEditOnly=true) + * 2. Movement-specific override in global config + * 3. Global override in project config (filtered by edit flag if qualityGatesEditOnly=true) + * 4. Movement-specific override in project config + * 5. Piece YAML quality_gates + * + * Merge strategy: Additive merge (all gates are combined, no overriding) + * + * @param movementName - Name of the movement + * @param yamlGates - Quality gates from piece YAML + * @param editFlag - Whether the movement has edit: true + * @param projectOverrides - Project-level piece_overrides (from .takt/config.yaml) + * @param globalOverrides - Global-level piece_overrides (from ~/.takt/config.yaml) + * @returns Merged quality gates array + */ +export function applyQualityGateOverrides( + movementName: string, + yamlGates: string[] | undefined, + editFlag: boolean | undefined, + projectOverrides: PieceOverrides | undefined, + globalOverrides: PieceOverrides | undefined, +): string[] | undefined { + // Track whether yamlGates was explicitly defined (even if empty) + const hasYamlGates = yamlGates !== undefined; + const gates: string[] = []; + + // Collect global gates from global config + const globalGlobalGates = globalOverrides?.qualityGates; + const globalEditOnly = globalOverrides?.qualityGatesEditOnly ?? false; + if (globalGlobalGates && (!globalEditOnly || editFlag === true)) { + gates.push(...globalGlobalGates); + } + + // Collect movement-specific gates from global config + const globalMovementGates = globalOverrides?.movements?.[movementName]?.qualityGates; + if (globalMovementGates) { + gates.push(...globalMovementGates); + } + + // Collect global gates from project config + const projectGlobalGates = projectOverrides?.qualityGates; + const projectEditOnly = projectOverrides?.qualityGatesEditOnly ?? false; + if (projectGlobalGates && (!projectEditOnly || editFlag === true)) { + gates.push(...projectGlobalGates); + } + + // Collect movement-specific gates from project config + const projectMovementGates = projectOverrides?.movements?.[movementName]?.qualityGates; + if (projectMovementGates) { + gates.push(...projectMovementGates); + } + + // Add YAML gates (lowest priority) + if (yamlGates) { + gates.push(...yamlGates); + } + + // Deduplicate gates (same text = same gate) + const uniqueGates = Array.from(new Set(gates)); + + // Return undefined only if no gates were defined anywhere + // If yamlGates was explicitly set (even if empty), return the merged array + if (uniqueGates.length > 0) { + return uniqueGates; + } + return hasYamlGates ? [] : undefined; +} diff --git a/src/infra/config/project/projectConfig.ts b/src/infra/config/project/projectConfig.ts index bbdd97d1..ac88ccc3 100644 --- a/src/infra/config/project/projectConfig.ts +++ b/src/infra/config/project/projectConfig.ts @@ -10,7 +10,7 @@ import { parse, stringify } from 'yaml'; import { copyProjectResourcesToDir } from '../../resources/index.js'; import type { ProjectLocalConfig } from '../types.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; -import type { AnalyticsConfig } from '../../../core/models/persisted-global-config.js'; +import type { AnalyticsConfig, PieceOverrides } from '../../../core/models/persisted-global-config.js'; import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js'; import { normalizeProviderOptions } from '../loaders/pieceParser.js'; import { invalidateResolvedConfigCache } from '../resolutionCache.js'; @@ -85,6 +85,51 @@ function denormalizeAnalytics(config: AnalyticsConfig | undefined): Record 0 ? raw : undefined; } +/** Normalize piece_overrides from snake_case (YAML) to camelCase (internal) */ +function normalizePieceOverrides( + raw: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record } | undefined, +): PieceOverrides | undefined { + if (!raw) return undefined; + return { + qualityGates: raw.quality_gates, + qualityGatesEditOnly: raw.quality_gates_edit_only, + movements: raw.movements + ? Object.fromEntries( + Object.entries(raw.movements).map(([name, override]) => [ + name, + { qualityGates: override.quality_gates }, + ]) + ) + : undefined, + }; +} + +/** Denormalize piece_overrides from camelCase (internal) to snake_case (YAML) */ +function denormalizePieceOverrides( + overrides: PieceOverrides | undefined, +): { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record } | undefined { + if (!overrides) return undefined; + const result: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record } = {}; + if (overrides.qualityGates !== undefined) { + result.quality_gates = overrides.qualityGates; + } + if (overrides.qualityGatesEditOnly !== undefined) { + result.quality_gates_edit_only = overrides.qualityGatesEditOnly; + } + if (overrides.movements) { + result.movements = Object.fromEntries( + Object.entries(overrides.movements).map(([name, override]) => { + const movementOverride: { quality_gates?: string[] } = {}; + if (override.qualityGates !== undefined) { + movementOverride.quality_gates = override.qualityGates; + } + return [name, movementOverride]; + }) + ); + } + return Object.keys(result).length > 0 ? result : undefined; +} + /** * Load project configuration from .takt/config.yaml */ @@ -111,6 +156,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig { provider_options, provider_profiles, analytics, + piece_overrides, ...rest } = parsedConfig; @@ -132,6 +178,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig { }; } | undefined), providerProfiles: normalizeProviderProfiles(provider_profiles as Record }> | undefined), + pieceOverrides: normalizePieceOverrides(piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record } | undefined), }; } @@ -173,6 +220,12 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig delete savePayload.draftPr; delete savePayload.baseBranch; + const rawPieceOverrides = denormalizePieceOverrides(config.pieceOverrides); + if (rawPieceOverrides) { + savePayload.piece_overrides = rawPieceOverrides; + } + delete savePayload.pieceOverrides; + const content = stringify(savePayload, { indent: 2 }); writeFileSync(configPath, content, 'utf-8'); invalidateResolvedConfigCache(projectDir); diff --git a/src/infra/config/resolveConfigValue.ts b/src/infra/config/resolveConfigValue.ts index 5125e5c1..52dbc7f0 100644 --- a/src/infra/config/resolveConfigValue.ts +++ b/src/infra/config/resolveConfigValue.ts @@ -81,6 +81,7 @@ const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule