From 71472614505b2c390f23a9586ffbd7e19efef588 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Mon, 20 Jul 2026 12:43:39 +0200 Subject: [PATCH] feat(ai): headless TypeScript lint service Lints script, flow-step and raw-app runnable code with no editor mounted, by driving the same Monaco model, extra libs and acquired types an editor would. Settling waits for the worker's own diagnostic count rather than a fixed sleep, which also terminates immediately on clean code, where no marker change event is ever emitted. Editor.getLintErrors now reads markers through the same helper, so the two cannot drift apart. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/Editor.svelte | 13 +- .../src/lib/components/lint/headlessLint.ts | 212 ++++++++++++++++++ .../lib/components/monacoLanguagesOptions.ts | 6 +- 3 files changed, 219 insertions(+), 12 deletions(-) create mode 100644 frontend/src/lib/components/lint/headlessLint.ts diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index d704b71f38..b8bde4edb1 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -107,6 +107,7 @@ fetchCustomWmillTypesData } from './lint/typescriptExtraLibs' import { createWindmillAta, genAtaRoot } from './lint/typescriptAta' + import { readModelMarkers } from './lint/headlessLint' import { aiChatManager } from './copilot/chat/AIChatManager.svelte' import type { Selection } from 'monaco-editor' import { canHavePreprocessor, getPreprocessorModuleCode } from '$lib/script_helpers' @@ -536,17 +537,7 @@ if (!model) { return { errorCount: 0, warningCount: 0, errors: [], warnings: [] } } - - const markers = meditor.getModelMarkers({ resource: model.uri }) - const errors = markers.filter((m) => m.severity === MarkerSeverity.Error) - const warnings = markers.filter((m) => m.severity === MarkerSeverity.Warning) - - return { - errorCount: errors.length, - warningCount: warnings.length, - errors, - warnings - } + return readModelMarkers(model.uri) } let command: IDisposable | undefined = undefined diff --git a/frontend/src/lib/components/lint/headlessLint.ts b/frontend/src/lib/components/lint/headlessLint.ts new file mode 100644 index 0000000000..75a08bc431 --- /dev/null +++ b/frontend/src/lib/components/lint/headlessLint.ts @@ -0,0 +1,212 @@ +import { editor as meditor, MarkerSeverity, Uri } from 'monaco-editor' +import { + getJavaScriptWorker, + getTypeScriptWorker +} from '@codingame/monaco-vscode-standalone-typescript-language-features' +import { scriptLangToEditorLang } from '$lib/scripts' +import type { ScriptLintResult } from '../copilot/chat/shared' +import { initializeVscode, keepModelAroundToAvoidDisposalOfWorkers } from '../vscode' +import { + setMonacoJavascriptOptions, + setMonacoTypescriptOptions, + TS_DIAGNOSTIC_CODES_TO_IGNORE +} from '../monacoLanguagesOptions' +import { computeModelPath, computeModelUri } from './monacoUri' +import { ensureCustomWmillTypes, ensureResourceTypeNamespace } from './typescriptExtraLibs' +import { createWindmillAta, genAtaRoot } from './typescriptAta' + +// Lints code without a mounted editor, by driving the same Monaco model + global +// typescriptDefaults an editor would. Diagnostics belong to the model, not the editor, +// so results match what opening the editor on the same item would show — provided the +// URI and the extra libs are derived through the shared helpers in this directory. + +const HEADLESS_LINT_LANGS = ['bun', 'bunnative', 'nativets', 'tsx', 'javascript', 'jsx'] + +export function canLintHeadless(scriptLang: string | undefined): boolean { + return HEADLESS_LINT_LANGS.includes(scriptLang ?? '') +} + +export interface HeadlessLintRequest { + content: string + scriptLang: string + /** Canonical editor path without extension: script path, `/`, `/`. */ + path: string + workspace: string +} + +export interface HeadlessLintOutcome extends ScriptLintResult { + /** An editor already owns this model and its content differs from what was requested. */ + contentMismatch: boolean +} + +export function readModelMarkers(uri: Uri): ScriptLintResult { + const markers = meditor.getModelMarkers({ resource: uri }) + const errors = markers.filter((m) => m.severity === MarkerSeverity.Error) + const warnings = markers.filter((m) => m.severity === MarkerSeverity.Warning) + return { errorCount: errors.length, warningCount: warnings.length, errors, warnings } +} + +// Models we created ourselves, oldest first. An editor may later adopt one by URI, so +// eviction must never dispose a model that is attached to an editor. +const ownedModels: string[] = [] +const MAX_OWNED_MODELS = 4 + +const absolutePathExtraLibs = new Map void }>() +const ataByKey = new Map Promise>() +const pendingByUri = new Map>() + +function evictOwnedModels(keepUri: string) { + while (ownedModels.length > MAX_OWNED_MODELS) { + const candidate = ownedModels.shift() + if (!candidate || candidate === keepUri) continue + const model = meditor.getModel(Uri.parse(candidate)) + if (model && !model.isAttachedToEditor()) { + model.dispose() + } + } +} + +export async function lintCode( + req: HeadlessLintRequest, + opts?: { timeoutMs?: number } +): Promise { + if (!canLintHeadless(req.scriptLang)) { + throw new Error(`Headless linting is not supported for language ${req.scriptLang}`) + } + const editorLang = scriptLangToEditorLang(req.scriptLang as any) + const uriString = computeModelUri( + computeModelPath(req.path, req.scriptLang), + req.scriptLang, + editorLang + ) + + // Two lints of the same URI would race on model content; run them in order. + const previous = pendingByUri.get(uriString) ?? Promise.resolve(undefined) + const run = previous + .catch(() => undefined) + .then(() => lintOne(req, uriString, editorLang, opts?.timeoutMs ?? 3000)) + pendingByUri.set( + uriString, + run.finally(() => { + if (pendingByUri.get(uriString) === run) pendingByUri.delete(uriString) + }) as Promise + ) + return run +} + +async function lintOne( + req: HeadlessLintRequest, + uriString: string, + editorLang: string, + timeoutMs: number +): Promise { + setMonacoTypescriptOptions() + if (editorLang === 'javascript') { + setMonacoJavascriptOptions() + } + await initializeVscode('headlessLint') + keepModelAroundToAvoidDisposalOfWorkers() + + const uri = Uri.parse(uriString) + let model = meditor.getModel(uri) + let contentMismatch = false + if (model) { + const isOurs = ownedModels.includes(uriString) + if (isOurs) { + if (model.getValue() !== req.content) model.setValue(req.content) + } else { + // An editor owns this model: its content is what the user sees and what they would + // see the markers for. Lint it as-is rather than overwriting their buffer. + contentMismatch = model.getValue() !== req.content + } + } else { + model = meditor.createModel(req.content, editorLang, uri) + ownedModels.push(uriString) + evictOwnedModels(uriString) + } + + if (editorLang === 'typescript') { + await Promise.all([ + ensureResourceTypeNamespace(req.workspace, req.scriptLang).catch((e) => + console.error('headlessLint: resource types unavailable', e) + ), + ensureCustomWmillTypes(req.workspace).catch(() => undefined) + ]) + } + + if (req.scriptLang === 'bun' || req.scriptLang === 'bunnative') { + await acquireTypes(req, uriString).catch((e) => + console.error('headlessLint: type acquisition failed', e) + ) + } + + await waitForMarkersToSettle(uri, editorLang, timeoutMs) + return { ...readModelMarkers(uri), contentMismatch } +} + +async function acquireTypes(req: HeadlessLintRequest, uriString: string): Promise { + const key = `${req.workspace}:${uriString}` + let ata = ataByKey.get(key) + if (!ata) { + ata = await createWindmillAta({ + root: await genAtaRoot(req.workspace), + scriptPath: req.path, + modelUri: uriString, + absolutePathExtraLibs + }) + ataByKey.set(key, ata) + if (req.scriptLang === 'bun') { + await ata('import "bun-types"') + } + } + await ata(req.content) +} + +async function getWorkerFor(uri: Uri, editorLang: string) { + const getWorker = editorLang === 'javascript' ? getJavaScriptWorker : getTypeScriptWorker + // The language mode activates asynchronously once a model of that language exists, so + // the worker accessor rejects for a short while after the very first model is created. + const deadline = Date.now() + 5000 + let lastError: unknown + while (Date.now() < deadline) { + try { + const client = await getWorker() + return await client(uri) + } catch (e) { + lastError = e + await new Promise((r) => setTimeout(r, 200)) + } + } + throw lastError ?? new Error('typescript worker unavailable') +} + +async function countExpectedMarkers(uri: Uri, editorLang: string): Promise { + try { + const worker = await getWorkerFor(uri, editorLang) + const fileName = uri.toString() + const [syntactic, semantic, suggestions] = await Promise.all([ + worker.getSyntacticDiagnostics(fileName), + worker.getSemanticDiagnostics(fileName), + worker.getSuggestionDiagnostics(fileName) + ]) + return [...syntactic, ...semantic, ...suggestions].filter( + (d) => !TS_DIAGNOSTIC_CODES_TO_IGNORE.includes(d.code) + ).length + } catch (e) { + console.error('headlessLint: could not query the typescript worker', e) + return undefined + } +} + +// The editor's own lint paths sleep a fixed delay and hope. Instead, ask the worker how +// many diagnostics it has and wait for that many markers to be published — which also +// terminates promptly for clean code, where no marker-change event ever fires. +async function waitForMarkersToSettle(uri: Uri, editorLang: string, timeoutMs: number) { + const expected = await countExpectedMarkers(uri, editorLang) + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const published = meditor.getModelMarkers({ resource: uri, owner: editorLang }).length + if (expected !== undefined && published === expected) return + await new Promise((r) => setTimeout(r, 50)) + } +} diff --git a/frontend/src/lib/components/monacoLanguagesOptions.ts b/frontend/src/lib/components/monacoLanguagesOptions.ts index 5e0a19c08a..6b16f41a81 100644 --- a/frontend/src/lib/components/monacoLanguagesOptions.ts +++ b/frontend/src/lib/components/monacoLanguagesOptions.ts @@ -34,6 +34,10 @@ export function setMonacoJsonOptions() { let typescriptInitialized = false +// Codes Monaco is told to drop before publishing markers. Anything reasoning about how +// many markers a model should end up with has to apply the same filter. +export const TS_DIAGNOSTIC_CODES_TO_IGNORE = [1108, 7006, 7034, 7019, 7005] + export function setMonacoTypescriptOptions() { if (typescriptInitialized) return typescriptInitialized = true @@ -71,7 +75,7 @@ export function setMonacoTypescriptOptions() { noSyntaxValidation: false, noSuggestionDiagnostics: false, - diagnosticCodesToIgnore: [1108, 7006, 7034, 7019, 7005] + diagnosticCodesToIgnore: TS_DIAGNOSTIC_CODES_TO_IGNORE }) typescriptDefaults.setCompilerOptions({