takt/src/infra/config/configNormalizers.ts
jake 289a0fb912
.takt/config.yaml(プロジェクト設定)に runtime.prepare を記述するとエラーになる (#464)
* fix: add missing runtime field to ProjectConfigSchema and unify normalizeRuntime

ProjectConfigSchema.strict() rejected the runtime key in .takt/config.yaml
because the field was never added to the project-level Zod schema. The global
schema and piece-level schema already had it, so only project-level
runtime.prepare was broken ("Unrecognized key: runtime").

- Add runtime to ProjectConfigSchema and ProjectLocalConfig type
- Handle runtime in loadProjectConfig/saveProjectConfig
- Extract normalizeRuntime() into configNormalizers.ts as shared function
- Replace duplicated normalization in globalConfigCore.ts and pieceParser.ts
- Add 9 round-trip tests for project-level runtime.prepare

* fix: remove unnecessary comments from configNormalizers.ts

* fix: replace removed piece field with verbose in runtime test

The piece field was removed from ProjectConfigSchema in PR #465.
Update the test to use verbose instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:13:44 +09:00

160 lines
6.3 KiB
TypeScript

import type { MovementProviderOptions, PieceRuntimeConfig } from '../../core/models/piece-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
import type { PieceOverrides, PersonaProviderEntry, PipelineConfig } from '../../core/models/persisted-global-config.js';
import { validateProviderModelCompatibility } from './providerModelCompatibility.js';
export function normalizeRuntime(
runtime: { prepare?: string[] } | undefined,
): PieceRuntimeConfig | undefined {
if (!runtime?.prepare || runtime.prepare.length === 0) {
return undefined;
}
return { prepare: [...new Set(runtime.prepare)] };
}
export function normalizeProviderProfiles(
raw: Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined,
): ProviderPermissionProfiles | undefined {
if (!raw) return undefined;
const entries = Object.entries(raw).map(([provider, profile]) => [provider, {
defaultPermissionMode: profile.default_permission_mode,
movementPermissionOverrides: profile.movement_permission_overrides,
}]);
return Object.fromEntries(entries) as ProviderPermissionProfiles;
}
export function denormalizeProviderProfiles(
profiles: ProviderPermissionProfiles | undefined,
): Record<string, { default_permission_mode: string; movement_permission_overrides?: Record<string, string> }> | undefined {
if (!profiles) return undefined;
const entries = Object.entries(profiles);
if (entries.length === 0) return undefined;
return Object.fromEntries(entries.map(([provider, profile]) => [provider, {
default_permission_mode: profile.defaultPermissionMode,
...(profile.movementPermissionOverrides
? { movement_permission_overrides: profile.movementPermissionOverrides }
: {}),
}])) as Record<string, { default_permission_mode: string; movement_permission_overrides?: Record<string, string> }>;
}
export 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,
};
}
export 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;
}
export function normalizePersonaProviders(
raw: Record<string, string | { type?: string; provider?: string; model?: string }> | undefined,
): Record<string, PersonaProviderEntry> | undefined {
if (!raw) return undefined;
const entries = Object.entries(raw);
if (entries.length === 0) return undefined;
return Object.fromEntries(entries.map(([persona, entry]) => {
const normalizedEntry: PersonaProviderEntry = typeof entry === 'string'
? { provider: entry as PersonaProviderEntry['provider'] }
: {
...(entry.provider !== undefined || entry.type !== undefined
? { provider: (entry.provider ?? entry.type) as PersonaProviderEntry['provider'] }
: {}),
...(entry.model !== undefined ? { model: entry.model } : {}),
};
validateProviderModelCompatibility(
normalizedEntry.provider,
normalizedEntry.model,
{
modelFieldName: `Configuration error: persona_providers.${persona}.model`,
requireProviderQualifiedModelForOpencode: false,
},
);
return [persona, normalizedEntry];
}));
}
export function normalizePipelineConfig(raw: {
default_branch_prefix?: string;
commit_message_template?: string;
pr_body_template?: string;
} | undefined): PipelineConfig | undefined {
if (!raw) return undefined;
const { default_branch_prefix, commit_message_template, pr_body_template } = raw;
if (default_branch_prefix === undefined && commit_message_template === undefined && pr_body_template === undefined) {
return undefined;
}
return {
defaultBranchPrefix: default_branch_prefix,
commitMessageTemplate: commit_message_template,
prBodyTemplate: pr_body_template,
};
}
export function denormalizeProviderOptions(
providerOptions: MovementProviderOptions | undefined,
): Record<string, unknown> | undefined {
if (!providerOptions) {
return undefined;
}
const raw: Record<string, unknown> = {};
if (providerOptions.codex?.networkAccess !== undefined) {
raw.codex = { network_access: providerOptions.codex.networkAccess };
}
if (providerOptions.opencode?.networkAccess !== undefined) {
raw.opencode = { network_access: providerOptions.opencode.networkAccess };
}
if (providerOptions.claude?.sandbox) {
const sandbox: Record<string, unknown> = {};
if (providerOptions.claude.sandbox.allowUnsandboxedCommands !== undefined) {
sandbox.allow_unsandboxed_commands = providerOptions.claude.sandbox.allowUnsandboxedCommands;
}
if (providerOptions.claude.sandbox.excludedCommands !== undefined) {
sandbox.excluded_commands = providerOptions.claude.sandbox.excludedCommands;
}
if (Object.keys(sandbox).length > 0) {
raw.claude = { sandbox };
}
}
return Object.keys(raw).length > 0 ? raw : undefined;
}