From bfe750b6ea40cab480ab3d28bcf6ebea001ddb4f Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Mon, 20 Jul 2026 15:07:05 +0200 Subject: [PATCH] feat(ai): type-check raw app frontend files, not just the build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundling reports syntax errors and unresolved imports, but esbuild strips types without checking them, so a type error or a reference to an undefined variable — a guaranteed runtime crash — compiled cleanly and the tool answered that the frontend had no errors. Run the TypeScript worker over the app's files as well and report problems per file. React's types declare it as a UMD global, so without allowUmdGlobalAccess every JSX line in a file that does not import React is reported as an error, which is how these files are written. Enabling it also removes that false error for tsx scripts in the editor. Files the entry point never imports are checked too. They never ship, but they are still the user's source and show the same problems in the app editor. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/copilot/chat/global/core.ts | 67 +++++++++---- .../src/lib/components/lint/headlessLint.ts | 94 +++++++++++++++++++ .../lib/components/monacoLanguagesOptions.ts | 6 +- 3 files changed, 148 insertions(+), 19 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 6ffda6cbec..7ae7948e31 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -3806,30 +3806,61 @@ async function resolveLintTarget( } /** - * A raw app's frontend is a bundle, not a single document, so there is no per-file - * language service to query: the compiler either builds it or reports where it broke. + * Two passes, because neither alone is enough: the bundler resolves the whole import + * graph but strips types without checking them, and the type checker catches what that + * misses but does not link the app together. */ async function checkAppFrontend(path: string, ctx: WriteDraftCtx): Promise { const { workspace, toolId, toolCallbacks } = ctx - toolCallbacks.setToolStatus(toolId, { content: `Compiling frontend of app "${path}"...` }) + toolCallbacks.setToolStatus(toolId, { content: `Checking frontend of app "${path}"...` }) const value = await loadAppValueForRead(path, workspace) - try { - await bundleRawAppDraft({ workspace, files: value.files }) - const response = '✅ The app frontend compiles with no errors.' - toolCallbacks.setToolStatus(toolId, { - content: `Frontend of app "${path}" compiles`, - result: response + + const { lintAppFrontend } = await import('$lib/components/lint/headlessLint') + const [buildError, fileResults] = await Promise.all([ + bundleRawAppDraft({ workspace, files: value.files }).then( + () => undefined, + (e) => (e instanceof Error ? e.message : String(e)) + ), + lintAppFrontend({ appPath: path, files: value.files, workspace }).catch((e) => { + console.error('get_lint_errors: app frontend type check failed', e) + return undefined }) - return response - } catch (e) { - const message = e instanceof Error ? e.message : String(e) - const response = `❌ The app frontend failed to compile:\n\n${message}` - toolCallbacks.setToolStatus(toolId, { - content: `Frontend of app "${path}" failed to compile`, - result: response - }) - return response + ]) + + const sections: string[] = [] + if (buildError) { + sections.push(`❌ The app frontend failed to build:\n\n${buildError}`) } + if (fileResults === undefined) { + sections.push('⚠️ The frontend could not be type-checked, so type errors may be missing.') + } else if (fileResults.length > 0) { + const total = fileResults.reduce((n, f) => n + f.result.errorCount + f.result.warningCount, 0) + sections.push( + `❌ **${total} problem(s)** found by the type checker:\n` + + fileResults + .map( + (f) => + `\n**${f.filePath}**\n` + + [...f.result.errors, ...f.result.warnings] + .map((m) => `- Line ${m.startLineNumber}: ${m.message}`) + .join('\n') + ) + .join('\n') + ) + } + if (sections.length === 0) { + sections.push('✅ The app frontend builds and type-checks with no errors.') + } + + const failed = buildError !== undefined || (fileResults?.length ?? 0) > 0 + const response = sections.join('\n\n') + toolCallbacks.setToolStatus(toolId, { + content: failed + ? `Frontend of app "${path}" has problems` + : `Frontend of app "${path}" is clean`, + result: response + }) + return response } async function getLintErrors(args: LintTargetArgs, ctx: WriteDraftCtx): Promise { diff --git a/frontend/src/lib/components/lint/headlessLint.ts b/frontend/src/lib/components/lint/headlessLint.ts index 6e7eb4ef35..ba1a2d533b 100644 --- a/frontend/src/lib/components/lint/headlessLint.ts +++ b/frontend/src/lib/components/lint/headlessLint.ts @@ -258,3 +258,97 @@ async function waitForMarkersToSettle(uri: Uri, editorLang: string, timeoutMs: n await new Promise((r) => setTimeout(r, 50)) } } + +const APP_LINTABLE_FILE = /\.(tsx?|jsx?)$/ +const APP_DECLARATION_FILE = /\.d\.ts$/ + +export interface AppFileLintResult { + filePath: string + result: ScriptLintResult +} + +/** + * Type-checks a raw app's frontend files. Bundling only reports syntax and unresolved + * imports — esbuild strips types without checking them — so a type error or an undefined + * variable would otherwise reach the browser unreported. + * + * Every file is checked, including ones the entry point never imports: they are still the + * user's source and show the same problems in the app editor, even though they never ship. + */ +export async function lintAppFrontend(req: { + appPath: string + files: Record + workspace: string + timeoutMs?: number +}): Promise { + setMonacoTypescriptOptions() + await initializeVscode('headlessLint') + keepModelAroundToAvoidDisposalOfWorkers() + + const base = `file:///${req.appPath.replace(/^\//, '')}` + // Models for every file, so imports between them resolve the way they do at build time. + const created: string[] = [] + const reportable: { filePath: string; uriString: string }[] = [] + for (const [filePath, content] of Object.entries(req.files)) { + if (typeof content !== 'string' || !APP_LINTABLE_FILE.test(filePath)) continue + const uriString = `${base}${filePath.startsWith('/') ? filePath : `/${filePath}`}` + const uri = Uri.parse(uriString) + const existing = meditor.getModel(uri) + if (existing) { + if (existing.getValue() !== content) existing.setValue(content) + } else { + meditor.createModel(content, 'typescript', uri) + created.push(uriString) + } + // Generated declaration files are not the user's to fix. + if (!APP_DECLARATION_FILE.test(filePath)) reportable.push({ filePath, uriString }) + } + + const timeoutMs = req.timeoutMs ?? 5000 + try { + await withDeadline( + acquireAppTypes(req.workspace, req.appPath, base, req.files).catch((e) => + console.error('headlessLint: app type acquisition failed', e) + ), + timeoutMs + ) + + const out: AppFileLintResult[] = [] + for (const { filePath, uriString } of reportable) { + const uri = Uri.parse(uriString) + await waitForMarkersToSettle(uri, 'typescript', timeoutMs) + const result = readModelMarkers(uri) + if (result.errorCount > 0 || result.warningCount > 0) out.push({ filePath, result }) + } + return out + } finally { + for (const uriString of created) { + const model = meditor.getModel(Uri.parse(uriString)) + if (model && !model.isAttachedToEditor()) model.dispose() + } + } +} + +async function acquireAppTypes( + workspace: string, + appPath: string, + base: string, + files: Record +): Promise { + const key = `app:${workspace}:${appPath}` + let ata = ataByKey.get(key) + if (!ata) { + ata = await createWindmillAta({ + root: await genAtaRoot(workspace), + scriptPath: appPath, + modelUri: `${base}/index.tsx`, + absolutePathExtraLibs + }) + ataByKey.set(key, ata) + } + const sources = Object.entries(files) + .filter(([p, c]) => typeof c === 'string' && APP_LINTABLE_FILE.test(p)) + .map(([, c]) => c) + .join('\n') + await ata(sources) +} diff --git a/frontend/src/lib/components/monacoLanguagesOptions.ts b/frontend/src/lib/components/monacoLanguagesOptions.ts index 6b16f41a81..dc902fc81e 100644 --- a/frontend/src/lib/components/monacoLanguagesOptions.ts +++ b/frontend/src/lib/components/monacoLanguagesOptions.ts @@ -104,7 +104,11 @@ export function setMonacoTypescriptOptions() { allowImportingTsExtensions: true, allowSyntheticDefaultImports: true, moduleResolution: ModuleResolutionKind.NodeJs, - jsx: JsxEmit.React + jsx: JsxEmit.React, + // React's types declare it as a UMD global. Without this, every JSX line in a file + // that doesn't import React is reported as an error, which is how raw app files and + // anything using the automatic JSX runtime are written. + allowUmdGlobalAccess: true }) }