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 }) }