takt: github-issue-382-feat-purojeku (#384)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
4a92ba2012
commit
f838a0e656
121
src/__tests__/globalConfig.test.ts
Normal file
121
src/__tests__/globalConfig.test.ts
Normal file
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
99
src/__tests__/projectConfig.test.ts
Normal file
99
src/__tests__/projectConfig.test.ts
Normal file
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
194
src/__tests__/qualityGateOverrides.test.ts
Normal file
194
src/__tests__/qualityGateOverrides.test.ts
Normal file
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<string, MovementQualityGatesOverride>;
|
||||
}
|
||||
|
||||
/** Custom agent configuration */
|
||||
export interface CustomAgentConfig {
|
||||
name: string;
|
||||
@ -138,6 +153,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 */
|
||||
@ -152,6 +169,8 @@ export interface ProjectConfig {
|
||||
concurrency?: number;
|
||||
/** Base branch to clone from (overrides global baseBranch) */
|
||||
baseBranch?: string;
|
||||
/** Piece-level overrides (quality_gates, etc.) */
|
||||
pieceOverrides?: PieceOverrides;
|
||||
/** Compatibility flag for full submodule acquisition when submodules is unset */
|
||||
withSubmodules?: boolean;
|
||||
/** Submodule acquisition mode (all or explicit path list) */
|
||||
|
||||
@ -154,6 +154,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 */
|
||||
@ -504,6 +519,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 */
|
||||
@ -517,6 +534,8 @@ 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,
|
||||
/** Submodule acquisition mode (all or explicit path list) */
|
||||
submodules: z.union([
|
||||
z.string().refine((value) => value.trim().toLowerCase() === 'all', {
|
||||
|
||||
@ -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';
|
||||
@ -123,6 +123,51 @@ function denormalizeProviderProfiles(
|
||||
}])) as Record<string, { default_permission_mode: string; movement_permission_overrides?: Record<string, string> }>;
|
||||
}
|
||||
|
||||
/** Normalize piece_overrides from snake_case (YAML) to camelCase (internal) */
|
||||
function normalizePieceOverrides(
|
||||
raw: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | 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<string, { quality_gates?: string[] }> } | undefined {
|
||||
if (!overrides) return undefined;
|
||||
const result: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } = {};
|
||||
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().
|
||||
@ -226,6 +271,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<string, { quality_gates?: string[] }> } | undefined),
|
||||
};
|
||||
validateProviderModelCompatibility(config.provider, config.model);
|
||||
this.cachedConfig = config;
|
||||
@ -371,6 +417,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();
|
||||
|
||||
@ -28,6 +28,10 @@ type RawPiece = z.output<typeof PieceConfigRawSchema>;
|
||||
|
||||
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(
|
||||
@ -278,6 +282,8 @@ function normalizeStepFromRaw(
|
||||
sections: PieceSections,
|
||||
inheritedProviderOptions?: PieceMovement['providerOptions'],
|
||||
context?: FacetResolutionContext,
|
||||
projectOverrides?: PieceOverrides,
|
||||
globalOverrides?: PieceOverrides,
|
||||
): PieceMovement {
|
||||
const rules: PieceRule[] | undefined = step.rules?.map(normalizeRule);
|
||||
|
||||
@ -316,7 +322,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,
|
||||
@ -324,7 +336,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),
|
||||
);
|
||||
}
|
||||
|
||||
@ -382,6 +394,8 @@ export function normalizePieceConfig(
|
||||
raw: unknown,
|
||||
pieceDir: string,
|
||||
context?: FacetResolutionContext,
|
||||
projectOverrides?: PieceOverrides,
|
||||
globalOverrides?: PieceOverrides,
|
||||
): PieceConfig {
|
||||
const parsed = PieceConfigRawSchema.parse(raw);
|
||||
|
||||
@ -402,7 +416,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)
|
||||
@ -447,5 +461,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);
|
||||
}
|
||||
|
||||
84
src/infra/config/loaders/qualityGateOverrides.ts
Normal file
84
src/infra/config/loaders/qualityGateOverrides.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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, SubmoduleSelection } from '../../../core/models/persisted-global-config.js';
|
||||
import type { AnalyticsConfig, PieceOverrides, SubmoduleSelection } 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';
|
||||
@ -129,6 +129,51 @@ function denormalizeAnalytics(config: AnalyticsConfig | undefined): Record<strin
|
||||
return Object.keys(raw).length > 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<string, { quality_gates?: string[] }> } | 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<string, { quality_gates?: string[] }> } | undefined {
|
||||
if (!overrides) return undefined;
|
||||
const result: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } = {};
|
||||
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
|
||||
*/
|
||||
@ -157,6 +202,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
||||
provider_options,
|
||||
provider_profiles,
|
||||
analytics,
|
||||
piece_overrides,
|
||||
claude_cli_path,
|
||||
codex_cli_path,
|
||||
cursor_cli_path,
|
||||
@ -188,6 +234,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
||||
};
|
||||
} | undefined),
|
||||
providerProfiles: normalizeProviderProfiles(provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
|
||||
pieceOverrides: normalizePieceOverrides(piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined),
|
||||
claudeCliPath: claude_cli_path as string | undefined,
|
||||
codexCliPath: codex_cli_path as string | undefined,
|
||||
cursorCliPath: cursor_cli_path as string | undefined,
|
||||
@ -246,6 +293,12 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
|
||||
delete savePayload.baseBranch;
|
||||
delete savePayload.withSubmodules;
|
||||
|
||||
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);
|
||||
|
||||
@ -79,6 +79,7 @@ const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K
|
||||
verbose: { layers: ['local', 'global'] },
|
||||
autoFetch: { layers: ['global'] },
|
||||
baseBranch: { layers: ['local', 'global'] },
|
||||
pieceOverrides: { layers: ['local', 'global'] },
|
||||
};
|
||||
|
||||
function resolveAnalyticsMerged(
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
|
||||
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
|
||||
import type { AnalyticsConfig, SubmoduleSelection } from '../../core/models/persisted-global-config.js';
|
||||
import type { AnalyticsConfig, PieceOverrides, SubmoduleSelection } from '../../core/models/persisted-global-config.js';
|
||||
|
||||
/** Project configuration stored in .takt/config.yaml */
|
||||
export interface ProjectLocalConfig {
|
||||
@ -34,6 +34,8 @@ export interface ProjectLocalConfig {
|
||||
providerOptions?: MovementProviderOptions;
|
||||
/** Provider-specific permission profiles (project-level override) */
|
||||
providerProfiles?: ProviderPermissionProfiles;
|
||||
/** Piece-level overrides (quality_gates, etc.) */
|
||||
pieceOverrides?: PieceOverrides;
|
||||
/** Claude Code CLI path override (project-level) */
|
||||
claudeCliPath?: string;
|
||||
/** Codex CLI path override (project-level) */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user