diff --git a/studio/backend/core/data_recipe/local_callable_validators.py b/studio/backend/core/data_recipe/local_callable_validators.py index abaec5b069..7beec7684d 100644 --- a/studio/backend/core/data_recipe/local_callable_validators.py +++ b/studio/backend/core/data_recipe/local_callable_validators.py @@ -20,6 +20,7 @@ _OXC_LANG_TO_NODE_LANG = { "tsx": "tsx", } _OXC_VALIDATION_MODES = {"syntax", "lint", "syntax+lint"} +_OXC_CODE_SHAPES = {"auto", "module", "snippet"} _OXC_TOOL_DIR = Path(__file__).resolve().parent / "oxc-validator" _OXC_RUNNER_PATH = _OXC_TOOL_DIR / "validate.mjs" @@ -33,6 +34,7 @@ class OxcLocalCallableValidatorSpec: batch_size: int code_lang: str validation_mode: str + code_shape: str def split_oxc_local_callable_validators( @@ -83,6 +85,7 @@ def register_oxc_local_callable_validators( validation_function = _build_oxc_validation_function( spec.code_lang, spec.validation_mode, + spec.code_shape, ) builder.add_column( ValidationColumnConfig( @@ -129,7 +132,7 @@ def _parse_oxc_spec( if not target_columns: return None - code_lang, validation_mode = _parse_oxc_validation_marker(fn_name) + code_lang, validation_mode, code_shape = _parse_oxc_validation_marker(fn_name) batch_size = _parse_batch_size(column.get("batch_size")) drop = bool(column.get("drop") is True) @@ -140,6 +143,7 @@ def _parse_oxc_spec( batch_size=batch_size, code_lang=code_lang, validation_mode=validation_mode, + code_shape=code_shape, ) @@ -151,27 +155,29 @@ def _parse_batch_size(value: Any) -> int: return parsed if parsed >= 1 else 10 -def _parse_oxc_validation_marker(fn_name: str) -> tuple[str, str]: +def _parse_oxc_validation_marker(fn_name: str) -> tuple[str, str, str]: marker = f"{OXC_VALIDATION_FN_MARKER}:" if not fn_name.startswith(marker): - return "javascript", "syntax" + return "javascript", "syntax", "auto" suffix = fn_name[len(marker) :] parts = [part.strip() for part in suffix.split(":") if part.strip()] if len(parts) < 2: - return "javascript", "syntax" + return "javascript", "syntax", "auto" code_lang = parts[0] if parts[0] in _OXC_LANG_TO_NODE_LANG else "javascript" mode = parts[1] if parts[1] in _OXC_VALIDATION_MODES else "syntax" - return code_lang, mode + code_shape = parts[2] if len(parts) >= 3 and parts[2] in _OXC_CODE_SHAPES else "auto" + return code_lang, mode, code_shape @lru_cache(maxsize=8) -def _build_oxc_validation_function(lang: str, validation_mode: str): +def _build_oxc_validation_function(lang: str, validation_mode: str, code_shape: str): node_lang = _OXC_LANG_TO_NODE_LANG.get(lang, "js") mode = ( validation_mode if validation_mode in _OXC_VALIDATION_MODES else "syntax" ) + normalized_code_shape = code_shape if code_shape in _OXC_CODE_SHAPES else "auto" def _validator(df): import pandas as pd # imported lazily for local callable runtime @@ -190,6 +196,7 @@ def _build_oxc_validation_function(lang: str, validation_mode: str): results = _run_oxc_batch( node_lang=node_lang, validation_mode=mode, + code_shape=normalized_code_shape, code_values=code_values, ) if len(results) != row_count: @@ -199,7 +206,9 @@ def _build_oxc_validation_function(lang: str, validation_mode: str): ) return pd.DataFrame(results) - _validator.__name__ = f"{OXC_VALIDATION_FN_MARKER}_{node_lang}_{mode.replace('+', '_')}" + _validator.__name__ = ( + f"{OXC_VALIDATION_FN_MARKER}_{node_lang}_{mode.replace('+', '_')}_{normalized_code_shape}" + ) return _validator @@ -207,6 +216,7 @@ def _run_oxc_batch( *, node_lang: str, validation_mode: str, + code_shape: str, code_values: list[str], ) -> list[dict[str, Any]]: if not _OXC_RUNNER_PATH.exists(): @@ -218,6 +228,7 @@ def _run_oxc_batch( payload = { "lang": node_lang, "mode": validation_mode, + "code_shape": code_shape, "codes": code_values, } try: diff --git a/studio/backend/core/data_recipe/oxc-validator/validate.mjs b/studio/backend/core/data_recipe/oxc-validator/validate.mjs index a3ec29bf3c..b2efa7df1f 100644 --- a/studio/backend/core/data_recipe/oxc-validator/validate.mjs +++ b/studio/backend/core/data_recipe/oxc-validator/validate.mjs @@ -13,6 +13,9 @@ const LANG_TO_EXT = { }; const VALIDATION_MODES = new Set(["syntax", "lint", "syntax+lint"]); +const CODE_SHAPES = new Set(["auto", "module", "snippet"]); +const SNIPPET_PREFIX = "(() => {\n"; +const SNIPPET_SUFFIX = "\n})();\nexport {};\n"; const TOOL_DIR = dirname(fileURLToPath(import.meta.url)); function mapLang(value) { @@ -40,6 +43,14 @@ function mapMode(value) { return "syntax"; } +function mapCodeShape(value) { + const normalized = String(value || "").trim().toLowerCase(); + if (CODE_SHAPES.has(normalized)) { + return normalized; + } + return "auto"; +} + function parseFileIndex(filePath) { if (typeof filePath !== "string") { return null; @@ -52,6 +63,52 @@ function parseFileIndex(filePath) { return Number.isFinite(parsed) ? parsed : null; } +function toCodeString(code) { + return typeof code === "string" ? code : String(code ?? ""); +} + +function makeValidationEntry({ code, index, lang, codeShape }) { + const source = toCodeString(code); + if (codeShape === "snippet") { + return { + index, + lang, + code: `${SNIPPET_PREFIX}${source}${SNIPPET_SUFFIX}`, + offset: SNIPPET_PREFIX.length, + }; + } + return { + index, + lang, + code: source, + offset: 0, + }; +} + +function shiftOffset(value, offset) { + if (!Number.isInteger(value)) { + return null; + } + const shifted = value - offset; + return shifted >= 0 ? shifted : null; +} + +function remapDiagnosticOffsets(diagnostic, offset) { + if (!diagnostic || typeof diagnostic !== "object" || offset <= 0) { + return diagnostic; + } + return { + ...diagnostic, + labels: Array.isArray(diagnostic.labels) + ? diagnostic.labels.map((label) => ({ + ...label, + start: shiftOffset(label.start, offset), + end: shiftOffset(label.end, offset), + })) + : [], + }; +} + function normalizeParserError(error) { if (typeof error === "string") { return { @@ -160,48 +217,140 @@ function makeResult({ }; } -function validateSyntaxOne({ code, lang, index }) { - const ext = LANG_TO_EXT[lang] ?? "js"; - const filename = `snippet_${index}.${ext}`; - const source = typeof code === "string" ? code : String(code ?? ""); +function syntaxResultFromErrors(errors) { + const first = errors[0] ?? null; + return makeResult({ + isValid: errors.length === 0, + errorCount: errors.length, + warningCount: 0, + message: errors.slice(0, 3).map((error) => error.message).join(" | "), + severity: first ? first.severity : null, + labels: first ? first.labels : [], + codeframe: first ? first.codeframe : null, + }); +} +function runSyntaxParse(entry) { + const ext = LANG_TO_EXT[entry.lang] ?? "js"; + const filename = `snippet_${entry.index}.${ext}`; try { - const parsed = parseSync(filename, source, { - lang, + const parsed = parseSync(filename, entry.code, { + lang: entry.lang, sourceType: "module", showSemanticErrors: true, }); const errors = Array.isArray(parsed?.errors) - ? parsed.errors.map(normalizeParserError).filter(Boolean) + ? parsed.errors + .map(normalizeParserError) + .filter(Boolean) + .map((error) => remapDiagnosticOffsets(error, entry.offset)) : []; - const first = errors[0] ?? null; - return makeResult({ - isValid: errors.length === 0, - errorCount: errors.length, - warningCount: 0, - message: errors.slice(0, 3).map((error) => error.message).join(" | "), - severity: first ? first.severity : null, - labels: first ? first.labels : [], - codeframe: first ? first.codeframe : null, - }); + return errors; } catch (error) { - const normalized = normalizeParserError(error); - return makeResult({ - isValid: false, - errorCount: 1, - warningCount: 0, - message: normalized.message, - severity: normalized.severity, - labels: normalized.labels, - codeframe: normalized.codeframe, - }); + return [ + remapDiagnosticOffsets( + normalizeParserError(error), + entry.offset, + ), + ]; } } -function fallbackLintResults(indexedCodes, message) { +function pickPreferredErrorList(firstErrors, secondErrors) { + if (secondErrors.length < firstErrors.length) { + return secondErrors; + } + return firstErrors; +} + +function validateSyntaxOne({ code, lang, index, codeShape }) { + if (codeShape !== "auto") { + const lintEntry = makeValidationEntry({ + code, + index, + lang, + codeShape, + }); + const errors = runSyntaxParse(lintEntry); + return { + result: syntaxResultFromErrors(errors), + lintEntry, + }; + } + + const moduleEntry = makeValidationEntry({ + code, + index, + lang, + codeShape: "module", + }); + const moduleErrors = runSyntaxParse(moduleEntry); + if (moduleErrors.length === 0) { + return { + result: syntaxResultFromErrors(moduleErrors), + lintEntry: moduleEntry, + }; + } + + const snippetEntry = makeValidationEntry({ + code, + index, + lang, + codeShape: "snippet", + }); + const snippetErrors = runSyntaxParse(snippetEntry); + if (snippetErrors.length === 0) { + return { + result: syntaxResultFromErrors(snippetErrors), + lintEntry: snippetEntry, + }; + } + + const chosenErrors = pickPreferredErrorList(moduleErrors, snippetErrors); + const lintEntry = chosenErrors === snippetErrors ? snippetEntry : moduleEntry; + return { + result: syntaxResultFromErrors(chosenErrors), + lintEntry, + }; +} + +function resolveLintEntry({ code, lang, index, codeShape }) { + if (codeShape !== "auto") { + return makeValidationEntry({ + code, + index, + lang, + codeShape, + }); + } + + const moduleEntry = makeValidationEntry({ + code, + index, + lang, + codeShape: "module", + }); + if (runSyntaxParse(moduleEntry).length === 0) { + return moduleEntry; + } + + const snippetEntry = makeValidationEntry({ + code, + index, + lang, + codeShape: "snippet", + }); + if (runSyntaxParse(snippetEntry).length === 0) { + return snippetEntry; + } + + return moduleEntry; +} + +function fallbackLintResults(entries, message) { return new Map( - indexedCodes.map((item) => [ - item.index, + entries.map((entry) => [ + entry.index, makeResult({ isValid: false, errorCount: 1, @@ -213,18 +362,18 @@ function fallbackLintResults(indexedCodes, message) { ); } -function runLintBatch(indexedCodes, lang) { - if (indexedCodes.length === 0) { +function runLintBatch(entries) { + if (entries.length === 0) { return new Map(); } - const ext = LANG_TO_EXT[lang] ?? "js"; + const entryByIndex = new Map(entries.map((entry) => [entry.index, entry])); const tempDir = mkdtempSync(join(tmpdir(), "oxlint-")); try { - for (const item of indexedCodes) { - const filePath = join(tempDir, `snippet_${item.index}.${ext}`); - const source = typeof item.code === "string" ? item.code : String(item.code ?? ""); - writeFileSync(filePath, source, "utf8"); + for (const entry of entries) { + const ext = LANG_TO_EXT[entry.lang] ?? "js"; + const filePath = join(tempDir, `snippet_${entry.index}.${ext}`); + writeFileSync(filePath, entry.code, "utf8"); } const oxlintBin = join(TOOL_DIR, "node_modules", ".bin", "oxlint"); @@ -234,7 +383,7 @@ function runLintBatch(indexedCodes, lang) { }); if (exec.error) { return fallbackLintResults( - indexedCodes, + entries, `oxlint execution failed: ${exec.error.message}`, ); } @@ -242,7 +391,7 @@ function runLintBatch(indexedCodes, lang) { if (!stdout) { const stderr = String(exec.stderr || "").trim(); return fallbackLintResults( - indexedCodes, + entries, stderr || "oxlint returned empty output", ); } @@ -251,7 +400,7 @@ function runLintBatch(indexedCodes, lang) { try { parsed = JSON.parse(stdout); } catch { - return fallbackLintResults(indexedCodes, "oxlint JSON parse failed"); + return fallbackLintResults(entries, "oxlint JSON parse failed"); } const rawDiagnostics = Array.isArray(parsed?.diagnostics) @@ -260,10 +409,6 @@ function runLintBatch(indexedCodes, lang) { const byIndex = new Map(); for (const diag of rawDiagnostics) { - const normalized = normalizeLintDiagnostic(diag); - if (!normalized) { - continue; - } const filenameRaw = typeof diag?.filename === "string" ? diag.filename : ""; const filename = filenameRaw.startsWith("file://") @@ -273,14 +418,20 @@ function runLintBatch(indexedCodes, lang) { if (index === null) { continue; } + const normalized = normalizeLintDiagnostic(diag); + if (!normalized) { + continue; + } + const entry = entryByIndex.get(index); + const remapped = remapDiagnosticOffsets(normalized, entry?.offset ?? 0); const list = byIndex.get(index) ?? []; - list.push(normalized); + list.push(remapped); byIndex.set(index, list); } const results = new Map(); - for (const item of indexedCodes) { - const diagnostics = byIndex.get(item.index) ?? []; + for (const entry of entries) { + const diagnostics = byIndex.get(entry.index) ?? []; const errorDiagnostics = diagnostics.filter( (diag) => diag.severity === "error", ); @@ -291,7 +442,7 @@ function runLintBatch(indexedCodes, lang) { const messageSource = errorDiagnostics.length > 0 ? errorDiagnostics : warningDiagnostics; results.set( - item.index, + entry.index, makeResult({ isValid: errorDiagnostics.length === 0, errorCount: errorDiagnostics.length, @@ -308,7 +459,7 @@ function runLintBatch(indexedCodes, lang) { } return results; } catch (error) { - return fallbackLintResults(indexedCodes, `oxlint execution failed: ${error}`); + return fallbackLintResults(entries, `oxlint execution failed: ${error}`); } finally { rmSync(tempDir, { recursive: true, force: true }); } @@ -326,16 +477,21 @@ function readStdin() { }); } -function runValidation({ codes, lang, mode }) { +function runValidation({ codes, lang, mode, codeShape }) { if (mode === "syntax") { - return codes.map((code, index) => validateSyntaxOne({ code, lang, index })); + return codes.map((code, index) => + validateSyntaxOne({ code, lang, index, codeShape }).result, + ); } + if (mode === "lint") { - const indexedCodes = codes.map((code, index) => ({ index, code })); - const lintMap = runLintBatch(indexedCodes, lang); - return indexedCodes.map( - (item) => - lintMap.get(item.index) ?? + const entries = codes.map((code, index) => + resolveLintEntry({ code, lang, index, codeShape }), + ); + const lintMap = runLintBatch(entries); + return entries.map( + (entry) => + lintMap.get(entry.index) ?? makeResult({ isValid: true, errorCount: 0, @@ -344,20 +500,20 @@ function runValidation({ codes, lang, mode }) { ); } - const syntaxResults = codes.map((code, index) => - validateSyntaxOne({ code, lang, index }), + const syntaxRuns = codes.map((code, index) => + validateSyntaxOne({ code, lang, index, codeShape }), ); - const lintTargets = codes - .map((code, index) => ({ index, code })) - .filter((item) => syntaxResults[item.index]?.is_valid === true); - const lintMap = runLintBatch(lintTargets, lang); + const lintTargets = syntaxRuns + .filter((run) => run.result.is_valid === true) + .map((run) => run.lintEntry); + const lintMap = runLintBatch(lintTargets); - return syntaxResults.map((syntaxResult, index) => { - if (syntaxResult.is_valid !== true) { - return syntaxResult; + return syntaxRuns.map((run) => { + if (run.result.is_valid !== true) { + return run.result; } return ( - lintMap.get(index) ?? + lintMap.get(run.lintEntry.index) ?? makeResult({ isValid: true, errorCount: 0, @@ -389,8 +545,9 @@ async function main() { const lang = mapLang(payload?.lang); const mode = mapMode(payload?.mode); + const codeShape = mapCodeShape(payload?.code_shape); const codes = Array.isArray(payload?.codes) ? payload.codes : []; - const out = runValidation({ codes, lang, mode }); + const out = runValidation({ codes, lang, mode, codeShape }); process.stdout.write(JSON.stringify(out)); } @@ -398,3 +555,4 @@ main().catch((error) => { process.stderr.write(String(error?.stack || error)); process.exit(1); }); + diff --git a/studio/frontend/src/features/recipe-studio/dialogs/validators/validator-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/validators/validator-dialog.tsx index dfe3c568eb..de2d413fd4 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/validators/validator-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/validators/validator-dialog.tsx @@ -27,6 +27,10 @@ import { VALIDATOR_OXC_CODE_LANGS, VALIDATOR_SQL_CODE_LANGS, } from "../../utils/validators/code-lang"; +import { + OXC_CODE_SHAPES, + normalizeOxcCodeShape, +} from "../../utils/validators/oxc-code-shape"; import { OXC_VALIDATION_MODES, normalizeOxcValidationMode, @@ -48,10 +52,13 @@ export function ValidatorDialog({ const configs = useRecipeStudioStore((state) => state.configs); const targetColumnId = `${config.id}-target-column`; const oxcModeId = `${config.id}-oxc-mode`; + const oxcCodeShapeId = `${config.id}-oxc-code-shape`; const batchSizeId = `${config.id}-batch-size`; const oxcModeAnchorRef = useRef(null); + const oxcCodeShapeAnchorRef = useRef(null); const advancedOpen = config.advancedOpen === true; const selectedOxcMode = normalizeOxcValidationMode(config.oxc_validation_mode); + const selectedOxcCodeShape = normalizeOxcCodeShape(config.oxc_code_shape); const codeOptions = useMemo( () => Object.values(configs) @@ -144,43 +151,84 @@ export function ValidatorDialog({ )} {config.validator_type === "oxc" && ( -
- -
- - onUpdate({ - oxc_validation_mode: normalizeOxcValidationMode(value), - }) - } - itemToStringValue={(value) => value} - autoHighlight={true} - > - - - No modes available - - {(mode: string) => ( - - {mode} - - )} - - - +
+
+ +
+ + onUpdate({ + oxc_validation_mode: normalizeOxcValidationMode(value), + }) + } + itemToStringValue={(value) => value} + autoHighlight={true} + > + + + No modes available + + {(mode: string) => ( + + {mode} + + )} + + + +
+
+
+ +
+ + onUpdate({ + oxc_code_shape: normalizeOxcCodeShape(value), + }) + } + itemToStringValue={(value) => value} + autoHighlight={true} + > + + + No code-shape options + + {(shape: string) => ( + + {shape} + + )} + + + +
)} diff --git a/studio/frontend/src/features/recipe-studio/types/index.ts b/studio/frontend/src/features/recipe-studio/types/index.ts index 411cb0add8..9c2b8feac7 100644 --- a/studio/frontend/src/features/recipe-studio/types/index.ts +++ b/studio/frontend/src/features/recipe-studio/types/index.ts @@ -27,6 +27,7 @@ export type ValidatorCodeLang = | "sql:ansi"; export type ValidatorType = "code" | "oxc"; export type OxcValidationMode = "syntax" | "lint" | "syntax+lint"; +export type OxcCodeShape = "auto" | "module" | "snippet"; export type ExpressionDtype = "str" | "int" | "float" | "bool"; @@ -275,6 +276,8 @@ export type ValidatorConfig = { code_lang: ValidatorCodeLang; // ui-only (used for OXC validators) oxc_validation_mode: OxcValidationMode; + // ui-only (used for OXC validators) + oxc_code_shape: OxcCodeShape; // ui ergonomics (serialized to int in payload) batch_size: string; }; diff --git a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts index 89ebac9579..404cf018de 100644 --- a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts +++ b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts @@ -307,6 +307,7 @@ export function makeValidatorConfig( // biome-ignore lint/style/useNamingConvention: api schema code_lang: codeLang, oxc_validation_mode: "syntax", + oxc_code_shape: "auto", batch_size: "10", }; } diff --git a/studio/frontend/src/features/recipe-studio/utils/import/parsers/validator-parser.ts b/studio/frontend/src/features/recipe-studio/utils/import/parsers/validator-parser.ts index 8d28246d47..14d9d3116e 100644 --- a/studio/frontend/src/features/recipe-studio/utils/import/parsers/validator-parser.ts +++ b/studio/frontend/src/features/recipe-studio/utils/import/parsers/validator-parser.ts @@ -1,16 +1,17 @@ import type { ValidatorConfig } from "../../../types"; import { readNumberString } from "../helpers"; import { normalizeValidatorCodeLang } from "../../validators/code-lang"; +import { normalizeOxcCodeShape } from "../../validators/oxc-code-shape"; import { normalizeOxcValidationMode } from "../../validators/oxc-mode"; const OXC_VALIDATION_FN_MARKER = "unsloth_oxc_validator"; function parseOxcValidationMarker( validationFunctionRaw: string, -): { codeLang: string; mode: string } { +): { codeLang: string; mode: string; codeShape: string } { const marker = `${OXC_VALIDATION_FN_MARKER}:`; if (!validationFunctionRaw.startsWith(marker)) { - return { codeLang: "", mode: "syntax" }; + return { codeLang: "", mode: "syntax", codeShape: "auto" }; } const parts = validationFunctionRaw .slice(marker.length) @@ -18,11 +19,12 @@ function parseOxcValidationMarker( .map((value) => value.trim()) .filter(Boolean); if (parts.length < 2) { - return { codeLang: "", mode: "syntax" }; + return { codeLang: "", mode: "syntax", codeShape: "auto" }; } return { codeLang: parts[0], mode: parts[1], + codeShape: parts[2] ?? "auto", }; } @@ -50,7 +52,7 @@ export function parseValidator( validationFunctionRaw.startsWith(OXC_VALIDATION_FN_MARKER); const marker = isOxc ? parseOxcValidationMarker(validationFunctionRaw) - : { codeLang: "", mode: "syntax" }; + : { codeLang: "", mode: "syntax", codeShape: "auto" }; return { id, kind: "validator", @@ -66,6 +68,9 @@ export function parseValidator( oxc_validation_mode: isOxc ? normalizeOxcValidationMode(marker.mode) : "syntax", + oxc_code_shape: isOxc + ? normalizeOxcCodeShape(marker.codeShape) + : "auto", batch_size: readNumberString(column.batch_size) || "10", }; } diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/builders-validator.ts b/studio/frontend/src/features/recipe-studio/utils/payload/builders-validator.ts index 0abe4c50b7..ce6e8bf11b 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/builders-validator.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/builders-validator.ts @@ -49,7 +49,7 @@ export function buildValidatorColumn( validator_params: { // backend resolves this marker to a real callable. // biome-ignore lint/style/useNamingConvention: api schema - validation_function: `${OXC_VALIDATION_FN_MARKER}:${codeLang}:${config.oxc_validation_mode}`, + validation_function: `${OXC_VALIDATION_FN_MARKER}:${codeLang}:${config.oxc_validation_mode}:${config.oxc_code_shape ?? "auto"}`, }, // biome-ignore lint/style/useNamingConvention: api schema batch_size: parseBatchSize(config.batch_size), diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts b/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts index dd3195f654..bb02c23f50 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts @@ -6,6 +6,7 @@ import type { ValidatorConfig, } from "../../types"; import { VALIDATOR_OXC_CODE_LANGS } from "../validators/code-lang"; +import { isOxcCodeShape } from "../validators/oxc-code-shape"; import { isOxcValidationMode } from "../validators/oxc-mode"; export function validateSubcategoryConfigs( @@ -157,6 +158,15 @@ export function validateValidatorConfigs( ); continue; } + if ( + config.validator_type === "oxc" && + !isOxcCodeShape(config.oxc_code_shape) + ) { + errors.push( + `Validator ${config.name}: oxc_code_shape '${config.oxc_code_shape}' is invalid.`, + ); + continue; + } if ( config.validator_type !== "oxc" && (targetConfig.code_lang ?? "").trim() !== config.code_lang.trim() diff --git a/studio/frontend/src/features/recipe-studio/utils/validation.ts b/studio/frontend/src/features/recipe-studio/utils/validation.ts index 41b30147a0..b7805ce0c3 100644 --- a/studio/frontend/src/features/recipe-studio/utils/validation.ts +++ b/studio/frontend/src/features/recipe-studio/utils/validation.ts @@ -1,6 +1,7 @@ import type { NodeConfig } from "../types"; import { isValidSex, parseAgeRange, parseIntNumber, parseNumber } from "./parse"; import { VALIDATOR_OXC_CODE_LANGS, VALIDATOR_SQL_CODE_LANGS } from "./validators/code-lang"; +import { isOxcCodeShape } from "./validators/oxc-code-shape"; import { isOxcValidationMode } from "./validators/oxc-mode"; const TRACE_MODES = new Set(["none", "last_message", "all_messages"]); @@ -214,6 +215,9 @@ export function getConfigErrors(config: NodeConfig | null): string[] { if (!isOxcValidationMode(config.oxc_validation_mode)) { errors.push("OXC validation mode must be syntax, lint, or syntax+lint."); } + if (!isOxcCodeShape(config.oxc_code_shape)) { + errors.push("OXC code shape must be auto, module, or snippet."); + } } else if ( config.code_lang !== "python" && !VALIDATOR_SQL_CODE_LANGS.includes(config.code_lang) diff --git a/studio/frontend/src/features/recipe-studio/utils/validators/oxc-code-shape.ts b/studio/frontend/src/features/recipe-studio/utils/validators/oxc-code-shape.ts new file mode 100644 index 0000000000..3db49a5dc9 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/validators/oxc-code-shape.ts @@ -0,0 +1,23 @@ +import type { OxcCodeShape } from "../../types"; + +export const OXC_CODE_SHAPES: OxcCodeShape[] = [ + "auto", + "module", + "snippet", +]; + +export function isOxcCodeShape(value: string): value is OxcCodeShape { + return OXC_CODE_SHAPES.includes(value as OxcCodeShape); +} + +export function normalizeOxcCodeShape(value: unknown): OxcCodeShape { + if (typeof value !== "string") { + return "auto"; + } + const normalized = value.trim().toLowerCase(); + if (isOxcCodeShape(normalized)) { + return normalized; + } + return "auto"; +} +