import { parse } from "yaml"; import type { InterviewConfig, InterviewProfile, InterviewStep, } from "./types"; export interface ParseResult { config: InterviewConfig; errors: string[]; } /** * Parse YAML interview config file. */ export function parseInterviewConfig(yamlText: string): ParseResult { const errors: string[] = []; try { const raw = parse(yamlText) as unknown; if (!raw || typeof raw !== "object") { return { config: { version: "2.0", frontmatterWhitelist: [], profiles: [], }, errors: ["Invalid YAML: root must be an object"], }; } const obj = raw as Record; // Extract version const version = typeof obj.version === "string" ? obj.version : "2.0"; // Extract frontmatterWhitelist (accept both camelCase and snake_case) const frontmatterWhitelist: string[] = []; const whitelistSource = obj.frontmatterWhitelist || obj.frontmatter_whitelist; if (Array.isArray(whitelistSource)) { for (const item of whitelistSource) { if (typeof item === "string") { frontmatterWhitelist.push(item); } } } // Extract profiles const profiles: InterviewProfile[] = []; if (Array.isArray(obj.profiles)) { for (let i = 0; i < obj.profiles.length; i++) { const profileRaw = obj.profiles[i]; if (!profileRaw || typeof profileRaw !== "object") { errors.push(`Profile at index ${i} is not an object`); continue; } const profile = parseProfile(profileRaw as Record, i); if (profile) { profiles.push(profile); } else { errors.push(`Failed to parse profile at index ${i}`); } } } const config: InterviewConfig = { version, frontmatterWhitelist, profiles, }; // Validate const validationErrors = validateConfig(config); errors.push(...validationErrors); return { config, errors }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); return { config: { version: "2.0", frontmatterWhitelist: [], profiles: [], }, errors: [`YAML parse error: ${msg}`], }; } } function parseProfile( raw: Record, index: number ): InterviewProfile | null { if (typeof raw.key !== "string" || !raw.key.trim()) { return null; } if (typeof raw.label !== "string" || !raw.label.trim()) { return null; } if (typeof raw.note_type !== "string" || !raw.note_type.trim()) { return null; } const profile: InterviewProfile = { key: raw.key.trim(), label: raw.label.trim(), note_type: raw.note_type.trim(), steps: [], }; if (typeof raw.group === "string" && raw.group.trim()) { profile.group = raw.group.trim(); } if (raw.defaults && typeof raw.defaults === "object") { profile.defaults = raw.defaults as Record; } // Parse edging config if (raw.edging && typeof raw.edging === "object") { const edgingRaw = raw.edging as Record; profile.edging = {}; if (edgingRaw.mode === "none" || edgingRaw.mode === "post_run" || edgingRaw.mode === "inline_micro" || edgingRaw.mode === "both") { profile.edging.mode = edgingRaw.mode; } if (typeof edgingRaw.wrapperCalloutType === "string") { profile.edging.wrapperCalloutType = edgingRaw.wrapperCalloutType; } if (typeof edgingRaw.wrapperTitle === "string") { profile.edging.wrapperTitle = edgingRaw.wrapperTitle; } if (typeof edgingRaw.wrapperFolded === "boolean") { profile.edging.wrapperFolded = edgingRaw.wrapperFolded; } } // Parse steps if (Array.isArray(raw.steps)) { for (let i = 0; i < raw.steps.length; i++) { const stepRaw = raw.steps[i]; if (!stepRaw || typeof stepRaw !== "object") { continue; } const step = parseStep(stepRaw as Record); if (step) { profile.steps.push(step); } } } return profile; } function parseStep(raw: Record): InterviewStep | null { // Accept both "type" and "kind" (YAML uses "kind", but we normalize to "type" internally) const stepType = raw.type || raw.kind; if (typeof stepType !== "string") { return null; } const type = stepType; // Helper: get key from "key" or "id" field const getKey = (): string | null => { if (typeof raw.key === "string" && raw.key.trim()) { return raw.key.trim(); } if (typeof raw.id === "string" && raw.id.trim()) { return raw.id.trim(); } return null; }; if (type === "loop") { const key = getKey(); if (!key) { return null; } // Accept both "items" and "steps" for loop nested steps const nestedSteps = raw.items || raw.steps; if (!Array.isArray(nestedSteps)) { return null; } const step: InterviewStep = { type: "loop", key: key, items: [], }; if (typeof raw.label === "string" && raw.label.trim()) { step.label = raw.label.trim(); } // Parse output.join if (raw.output && typeof raw.output === "object") { const output = raw.output as Record; if (typeof output.join === "string") { step.output = { join: output.join }; } } // Parse ui.commit if (raw.ui && typeof raw.ui === "object") { const ui = raw.ui as Record; if (ui.commit === "explicit_add" || ui.commit === "on_next") { step.ui = { commit: ui.commit }; } } for (const itemRaw of nestedSteps) { if (!itemRaw || typeof itemRaw !== "object") { continue; } const item = parseStep(itemRaw as Record); if (item) { step.items.push(item); } } return step; } if (type === "llm_dialog") { const key = getKey(); if (!key) { return null; } // Accept both "prompt" and "prompt_template" const promptText = raw.prompt || raw.prompt_template; if (typeof promptText !== "string" || !promptText.trim()) { return null; } const step: InterviewStep = { type: "llm_dialog", key: key, prompt: promptText.trim(), }; if (typeof raw.label === "string" && raw.label.trim()) { step.label = raw.label.trim(); } if (typeof raw.system_prompt === "string" && raw.system_prompt.trim()) { step.system_prompt = raw.system_prompt.trim(); } if (typeof raw.model === "string" && raw.model.trim()) { step.model = raw.model.trim(); } if (typeof raw.temperature === "number") { step.temperature = raw.temperature; } if (typeof raw.max_tokens === "number") { step.max_tokens = raw.max_tokens; } return step; } if (type === "capture_frontmatter") { const key = getKey(); if (!key) { return null; } if (typeof raw.field !== "string" || !raw.field.trim()) { return null; } const step: InterviewStep = { type: "capture_frontmatter", key: key, field: raw.field.trim(), }; if (typeof raw.label === "string" && raw.label.trim()) { step.label = raw.label.trim(); } if (typeof raw.required === "boolean") { step.required = raw.required; } if (typeof raw.prompt === "string" && raw.prompt.trim()) { step.prompt = raw.prompt.trim(); } // Parse input (kind: select | number | text_line, options for select) if (raw.input && typeof raw.input === "object") { const input = raw.input as Record; const inputKind = typeof input.kind === "string" && input.kind.trim() ? (input.kind.trim() as "text_line" | "number" | "select") : "text_line"; step.input = { kind: inputKind }; if (inputKind === "select" && Array.isArray(input.options)) { step.input.options = []; for (const opt of input.options) { if (opt && typeof opt === "object") { const o = opt as Record; const label = typeof o.label === "string" ? o.label.trim() : ""; const value = typeof o.value === "number" ? o.value : typeof o.value === "string" ? o.value : ""; if (label !== "" || value !== "") { step.input!.options!.push({ label: label || String(value), value }); } } } } if (typeof input.min === "number") step.input.min = input.min; if (typeof input.max === "number") step.input.max = input.max; if (typeof input.step === "number") step.input.step = input.step; } // Parse output.template if (raw.output && typeof raw.output === "object") { const output = raw.output as Record; if (typeof output.template === "string") { step.output = { template: output.template }; } } return step; } if (type === "capture_text") { const key = getKey(); if (!key) { return null; } const step: InterviewStep = { type: "capture_text", key: key, }; if (typeof raw.label === "string" && raw.label.trim()) { step.label = raw.label.trim(); } if (typeof raw.required === "boolean") { step.required = raw.required; } if (typeof raw.section === "string" && raw.section.trim()) { step.section = raw.section.trim(); } if (typeof raw.prompt === "string" && raw.prompt.trim()) { step.prompt = raw.prompt.trim(); } // WP-26: Parse section_type if (typeof raw.section_type === "string" && raw.section_type.trim()) { step.section_type = raw.section_type.trim(); } // WP-26: Parse block_id if (typeof raw.block_id === "string" && raw.block_id.trim()) { step.block_id = raw.block_id.trim(); } // WP-26: Parse generate_block_id if (typeof raw.generate_block_id === "boolean") { step.generate_block_id = raw.generate_block_id; } // WP-26: Parse references if (Array.isArray(raw.references)) { step.references = []; for (const refRaw of raw.references) { if (refRaw && typeof refRaw === "object") { const ref = refRaw as Record; if (typeof ref.block_id === "string" && ref.block_id.trim()) { const reference: { block_id: string; edge_type?: string } = { block_id: ref.block_id.trim(), }; if (typeof ref.edge_type === "string" && ref.edge_type.trim()) { reference.edge_type = ref.edge_type.trim(); } step.references.push(reference); } } } } // Parse output.template if (raw.output && typeof raw.output === "object") { const output = raw.output as Record; if (typeof output.template === "string") { step.output = { template: output.template }; } } return step; } if (type === "capture_text_line") { const key = getKey(); if (!key) { return null; } const step: InterviewStep = { type: "capture_text_line", key: key, }; if (typeof raw.label === "string" && raw.label.trim()) { step.label = raw.label.trim(); } if (typeof raw.required === "boolean") { step.required = raw.required; } if (typeof raw.prompt === "string" && raw.prompt.trim()) { step.prompt = raw.prompt.trim(); } // Parse heading_level if (raw.heading_level && typeof raw.heading_level === "object") { const headingLevel = raw.heading_level as Record; step.heading_level = { enabled: headingLevel.enabled === true, default: typeof headingLevel.default === "number" ? Math.max(1, Math.min(6, headingLevel.default)) : 2, }; } // WP-26: Parse section_type if (typeof raw.section_type === "string" && raw.section_type.trim()) { step.section_type = raw.section_type.trim(); } // WP-26: Parse block_id if (typeof raw.block_id === "string" && raw.block_id.trim()) { step.block_id = raw.block_id.trim(); } // WP-26: Parse generate_block_id if (typeof raw.generate_block_id === "boolean") { step.generate_block_id = raw.generate_block_id; } // WP-26: Parse references if (Array.isArray(raw.references)) { step.references = []; for (const refRaw of raw.references) { if (refRaw && typeof refRaw === "object") { const ref = refRaw as Record; if (typeof ref.block_id === "string" && ref.block_id.trim()) { const reference: { block_id: string; edge_type?: string } = { block_id: ref.block_id.trim(), }; if (typeof ref.edge_type === "string" && ref.edge_type.trim()) { reference.edge_type = ref.edge_type.trim(); } step.references.push(reference); } } } } // Parse output.template if (raw.output && typeof raw.output === "object") { const output = raw.output as Record; if (typeof output.template === "string") { step.output = { template: output.template }; } } return step; } if (type === "instruction") { const key = getKey(); if (!key) { return null; } if (typeof raw.content !== "string") { return null; } const step: InterviewStep = { type: "instruction", key: key, content: raw.content, }; if (typeof raw.label === "string" && raw.label.trim()) { step.label = raw.label.trim(); } return step; } if (type === "entity_picker") { const key = getKey(); if (!key) { return null; } const step: InterviewStep = { type: "entity_picker", key: key, }; if (typeof raw.label === "string" && raw.label.trim()) { step.label = raw.label.trim(); } if (typeof raw.prompt === "string" && raw.prompt.trim()) { step.prompt = raw.prompt.trim(); } if (typeof raw.required === "boolean") { step.required = raw.required; } if (typeof raw.labelField === "string" && raw.labelField.trim()) { step.labelField = raw.labelField.trim(); } return step; } if (type === "review") { const key = getKey(); if (!key) { return null; } const step: InterviewStep = { type: "review", key: key, }; if (typeof raw.label === "string" && raw.label.trim()) { step.label = raw.label.trim(); } return step; } return null; } /** * Validate parsed config. */ export function validateConfig(config: InterviewConfig): string[] { const errors: string[] = []; // Check unique profile keys const profileKeys = new Set(); for (const profile of config.profiles) { if (profileKeys.has(profile.key)) { errors.push(`Duplicate profile key: ${profile.key}`); } else { profileKeys.add(profile.key); } } // Check capture_frontmatter.field in whitelist for (const profile of config.profiles) { validateSteps(profile.steps, config.frontmatterWhitelist, errors); } return errors; } function validateSteps( steps: InterviewStep[], whitelist: string[], errors: string[] ): void { for (const step of steps) { if (step.type === "capture_frontmatter") { if (!whitelist.includes(step.field)) { errors.push( `capture_frontmatter field '${step.field}' not in frontmatterWhitelist` ); } } else if (step.type === "loop") { validateSteps(step.items, whitelist, errors); } } }