* 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>
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
import { parse, stringify } from 'yaml';
|
|
import { ProjectConfigSchema } from '../../../core/models/index.js';
|
|
import { copyProjectResourcesToDir } from '../../resources/index.js';
|
|
import type { ProjectLocalConfig } from '../types.js';
|
|
import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js';
|
|
import {
|
|
normalizeConfigProviderReference,
|
|
type ConfigProviderReference,
|
|
} from '../providerReference.js';
|
|
import {
|
|
normalizePipelineConfig,
|
|
normalizeProviderProfiles,
|
|
denormalizeProviderProfiles,
|
|
denormalizeProviderOptions,
|
|
normalizePersonaProviders,
|
|
normalizePieceOverrides,
|
|
denormalizePieceOverrides,
|
|
normalizeRuntime,
|
|
} from '../configNormalizers.js';
|
|
import { invalidateResolvedConfigCache } from '../resolutionCache.js';
|
|
import { MIGRATED_PROJECT_LOCAL_DEFAULTS } from '../migratedProjectLocalDefaults.js';
|
|
import type { MigratedProjectLocalConfigKey } from '../migratedProjectLocalKeys.js';
|
|
import { getProjectConfigDir, getProjectConfigPath } from './projectConfigPaths.js';
|
|
import {
|
|
normalizeSubmodules,
|
|
normalizeWithSubmodules,
|
|
normalizeAnalytics,
|
|
denormalizeAnalytics,
|
|
formatIssuePath,
|
|
} from './projectConfigTransforms.js';
|
|
|
|
export type { ProjectLocalConfig } from '../types.js';
|
|
|
|
type Assert<T extends true> = T;
|
|
type IsNever<T> = [T] extends [never] ? true : false;
|
|
|
|
/**
|
|
* Compile-time guard:
|
|
* migrated fields must be owned by ProjectLocalConfig.
|
|
*/
|
|
const projectLocalConfigMigratedFieldGuard:
|
|
Assert<IsNever<Exclude<MigratedProjectLocalConfigKey, keyof ProjectLocalConfig>>> = true;
|
|
void projectLocalConfigMigratedFieldGuard;
|
|
|
|
type ProviderType = NonNullable<ProjectLocalConfig['provider']>;
|
|
type RawProviderReference = ConfigProviderReference<ProviderType>;
|
|
|
|
/**
|
|
* Load project configuration from .takt/config.yaml
|
|
*/
|
|
export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
|
const configPath = getProjectConfigPath(projectDir);
|
|
|
|
const rawConfig: Record<string, unknown> = {};
|
|
if (existsSync(configPath)) {
|
|
const content = readFileSync(configPath, 'utf-8');
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = parse(content);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`Configuration error: failed to parse ${configPath}: ${message}`);
|
|
}
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
Object.assign(rawConfig, parsed as Record<string, unknown>);
|
|
} else if (parsed != null) {
|
|
throw new Error(`Configuration error: ${configPath} must be a YAML object.`);
|
|
}
|
|
}
|
|
|
|
applyProjectConfigEnvOverrides(rawConfig);
|
|
const parsedResult = ProjectConfigSchema.safeParse(rawConfig);
|
|
if (!parsedResult.success) {
|
|
const firstIssue = parsedResult.error.issues[0];
|
|
const issuePath = firstIssue ? formatIssuePath(firstIssue.path) : '(root)';
|
|
const issueMessage = firstIssue?.message ?? 'Invalid configuration value';
|
|
throw new Error(
|
|
`Configuration error: invalid ${issuePath} in ${configPath}: ${issueMessage}`,
|
|
);
|
|
}
|
|
const parsedConfig = parsedResult.data;
|
|
|
|
const {
|
|
provider,
|
|
model,
|
|
auto_pr,
|
|
draft_pr,
|
|
base_branch,
|
|
submodules,
|
|
with_submodules,
|
|
provider_options,
|
|
provider_profiles,
|
|
analytics,
|
|
log_level,
|
|
pipeline,
|
|
persona_providers,
|
|
verbose,
|
|
branch_name_strategy,
|
|
minimal_output,
|
|
concurrency,
|
|
task_poll_interval_ms,
|
|
interactive_preview_movements,
|
|
piece_overrides,
|
|
runtime,
|
|
...rest
|
|
} = parsedConfig;
|
|
const normalizedProvider = normalizeConfigProviderReference(
|
|
provider as RawProviderReference,
|
|
model as string | undefined,
|
|
provider_options as Record<string, unknown> | undefined,
|
|
);
|
|
|
|
const normalizedSubmodules = normalizeSubmodules(submodules);
|
|
const normalizedWithSubmodules = normalizeWithSubmodules(with_submodules);
|
|
const effectiveWithSubmodules = normalizedSubmodules === undefined ? normalizedWithSubmodules : undefined;
|
|
const normalizedPipeline = normalizePipelineConfig(
|
|
pipeline as { default_branch_prefix?: string; commit_message_template?: string; pr_body_template?: string } | undefined,
|
|
);
|
|
const personaProviders = normalizePersonaProviders(
|
|
persona_providers as Record<string, string | { type?: string; provider?: string; model?: string }> | undefined,
|
|
);
|
|
|
|
return {
|
|
...(rest as ProjectLocalConfig),
|
|
logLevel: log_level as ProjectLocalConfig['logLevel'],
|
|
pipeline: normalizedPipeline,
|
|
personaProviders,
|
|
branchNameStrategy: branch_name_strategy as ProjectLocalConfig['branchNameStrategy'],
|
|
minimalOutput: minimal_output as boolean | undefined,
|
|
concurrency: concurrency as number | undefined,
|
|
taskPollIntervalMs: task_poll_interval_ms as number | undefined,
|
|
interactivePreviewMovements: interactive_preview_movements as number | undefined,
|
|
verbose: verbose as boolean | undefined,
|
|
autoPr: auto_pr as boolean | undefined,
|
|
draftPr: draft_pr as boolean | undefined,
|
|
baseBranch: base_branch as string | undefined,
|
|
submodules: normalizedSubmodules,
|
|
withSubmodules: effectiveWithSubmodules,
|
|
analytics: normalizeAnalytics(analytics as Record<string, unknown> | undefined),
|
|
provider: normalizedProvider.provider,
|
|
model: normalizedProvider.model,
|
|
providerOptions: normalizedProvider.providerOptions,
|
|
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),
|
|
runtime: normalizeRuntime(runtime),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Save project configuration to .takt/config.yaml
|
|
*/
|
|
export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig): void {
|
|
const configDir = getProjectConfigDir(projectDir);
|
|
const configPath = getProjectConfigPath(projectDir);
|
|
|
|
if (!existsSync(configDir)) {
|
|
mkdirSync(configDir, { recursive: true });
|
|
}
|
|
|
|
copyProjectResourcesToDir(configDir);
|
|
|
|
const savePayload: Record<string, unknown> = { ...config };
|
|
const normalizedSubmodules = normalizeSubmodules(config.submodules);
|
|
|
|
const rawAnalytics = denormalizeAnalytics(config.analytics);
|
|
if (rawAnalytics) {
|
|
savePayload.analytics = rawAnalytics;
|
|
} else {
|
|
delete savePayload.analytics;
|
|
}
|
|
|
|
const rawProfiles = denormalizeProviderProfiles(config.providerProfiles);
|
|
if (rawProfiles && Object.keys(rawProfiles).length > 0) {
|
|
savePayload.provider_profiles = rawProfiles;
|
|
} else {
|
|
delete savePayload.provider_profiles;
|
|
}
|
|
const rawProviderOptions = denormalizeProviderOptions(config.providerOptions);
|
|
if (rawProviderOptions) {
|
|
savePayload.provider_options = rawProviderOptions;
|
|
} else {
|
|
delete savePayload.provider_options;
|
|
}
|
|
delete savePayload.providerProfiles;
|
|
delete savePayload.providerOptions;
|
|
delete savePayload.concurrency;
|
|
delete savePayload.verbose;
|
|
|
|
if (config.autoPr !== undefined) savePayload.auto_pr = config.autoPr;
|
|
if (config.draftPr !== undefined) savePayload.draft_pr = config.draftPr;
|
|
if (config.baseBranch !== undefined) savePayload.base_branch = config.baseBranch;
|
|
if (
|
|
config.logLevel !== undefined
|
|
&& config.logLevel !== MIGRATED_PROJECT_LOCAL_DEFAULTS.logLevel
|
|
) {
|
|
savePayload.log_level = config.logLevel;
|
|
}
|
|
if (config.branchNameStrategy !== undefined) savePayload.branch_name_strategy = config.branchNameStrategy;
|
|
if (
|
|
config.minimalOutput !== undefined
|
|
&& config.minimalOutput !== MIGRATED_PROJECT_LOCAL_DEFAULTS.minimalOutput
|
|
) {
|
|
savePayload.minimal_output = config.minimalOutput;
|
|
}
|
|
if (
|
|
config.taskPollIntervalMs !== undefined
|
|
&& config.taskPollIntervalMs !== MIGRATED_PROJECT_LOCAL_DEFAULTS.taskPollIntervalMs
|
|
) {
|
|
savePayload.task_poll_interval_ms = config.taskPollIntervalMs;
|
|
}
|
|
if (
|
|
config.interactivePreviewMovements !== undefined
|
|
&& config.interactivePreviewMovements !== MIGRATED_PROJECT_LOCAL_DEFAULTS.interactivePreviewMovements
|
|
) {
|
|
savePayload.interactive_preview_movements = config.interactivePreviewMovements;
|
|
}
|
|
if (
|
|
config.concurrency !== undefined
|
|
&& config.concurrency !== MIGRATED_PROJECT_LOCAL_DEFAULTS.concurrency
|
|
) {
|
|
savePayload.concurrency = config.concurrency;
|
|
}
|
|
if (
|
|
config.verbose !== undefined
|
|
&& config.verbose !== MIGRATED_PROJECT_LOCAL_DEFAULTS.verbose
|
|
) {
|
|
savePayload.verbose = config.verbose;
|
|
}
|
|
delete savePayload.pipeline;
|
|
if (config.pipeline) {
|
|
const pipelineRaw: Record<string, unknown> = {};
|
|
if (config.pipeline.defaultBranchPrefix !== undefined) {
|
|
pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
|
|
}
|
|
if (config.pipeline.commitMessageTemplate !== undefined) {
|
|
pipelineRaw.commit_message_template = config.pipeline.commitMessageTemplate;
|
|
}
|
|
if (config.pipeline.prBodyTemplate !== undefined) {
|
|
pipelineRaw.pr_body_template = config.pipeline.prBodyTemplate;
|
|
}
|
|
if (Object.keys(pipelineRaw).length > 0) savePayload.pipeline = pipelineRaw;
|
|
}
|
|
if (config.personaProviders && Object.keys(config.personaProviders).length > 0) {
|
|
savePayload.persona_providers = config.personaProviders;
|
|
}
|
|
if (normalizedSubmodules !== undefined) {
|
|
savePayload.submodules = normalizedSubmodules;
|
|
delete savePayload.with_submodules;
|
|
} else {
|
|
delete savePayload.submodules;
|
|
if (config.withSubmodules !== undefined) {
|
|
savePayload.with_submodules = config.withSubmodules;
|
|
} else {
|
|
delete savePayload.with_submodules;
|
|
}
|
|
}
|
|
delete savePayload.autoPr;
|
|
delete savePayload.draftPr;
|
|
delete savePayload.baseBranch;
|
|
delete savePayload.withSubmodules;
|
|
delete savePayload.logLevel;
|
|
delete savePayload.branchNameStrategy;
|
|
delete savePayload.minimalOutput;
|
|
delete savePayload.taskPollIntervalMs;
|
|
delete savePayload.interactivePreviewMovements;
|
|
delete savePayload.personaProviders;
|
|
|
|
const rawPieceOverrides = denormalizePieceOverrides(config.pieceOverrides);
|
|
if (rawPieceOverrides) {
|
|
savePayload.piece_overrides = rawPieceOverrides;
|
|
}
|
|
delete savePayload.pieceOverrides;
|
|
|
|
const normalizedRuntime = normalizeRuntime(config.runtime);
|
|
if (normalizedRuntime) {
|
|
savePayload.runtime = normalizedRuntime;
|
|
} else {
|
|
delete savePayload.runtime;
|
|
}
|
|
|
|
const content = stringify(savePayload, { indent: 2 });
|
|
writeFileSync(configPath, content, 'utf-8');
|
|
invalidateResolvedConfigCache(projectDir);
|
|
}
|
|
|
|
export function updateProjectConfig<K extends keyof ProjectLocalConfig>(
|
|
projectDir: string,
|
|
key: K,
|
|
value: ProjectLocalConfig[K]
|
|
): void {
|
|
const config = loadProjectConfig(projectDir);
|
|
config[key] = value;
|
|
saveProjectConfig(projectDir, config);
|
|
}
|