UNPKG

ttsc

Version:

General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.

1,131 lines 69.6 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ResidentCheckWatchSession = void 0; exports.runBuild = runBuild; exports.planResidentCheckEntries = planResidentCheckEntries; exports.bufferResidentCheckEntryRequests = bufferResidentCheckEntryRequests; exports.takeResidentCheckEntryRequest = takeResidentCheckEntryRequest; exports.residentCheckRequest = residentCheckRequest; exports.mergeProjectInputSnapshots = mergeProjectInputSnapshots; exports.parseProjectInputSnapshot = parseProjectInputSnapshot; exports.isAbsoluteLocalProjectInputPath = isAbsoluteLocalProjectInputPath; exports.appendBuildOutput = appendBuildOutput; exports.createProcessDiagnostic = createProcessDiagnostic; exports.normalizeBuildOutput = normalizeBuildOutput; const node_path_1 = __importDefault(require("node:path")); const schema_1 = require("../../flags/schema"); const projectInputPathIdentity_1 = require("../../internal/projectInputPathIdentity"); const loadProjectPlugins_1 = require("../../plugin/internal/loadProjectPlugins"); const createNativeProjectContextArgs_1 = require("./project/createNativeProjectContextArgs"); const readProjectConfig_1 = require("./project/readProjectConfig"); const residentCheckProcess_1 = require("./residentCheckProcess"); const resolveBinary_1 = require("./resolveBinary"); const resolveTsgo_1 = require("./resolveTsgo"); const sharedHostHelpers_1 = require("./sharedHostHelpers"); const spawnNative_1 = require("./spawnNative"); /** * Merge extra environment variables over `process.env`, always injecting * `TTSC_NODE_BINARY` so child processes can re-invoke the same Node.js binary * without searching `PATH`. */ function mergeEnv(extra) { const base = { ...process.env, TTSC_NODE_BINARY: process.env.TTSC_NODE_BINARY ?? process.execPath, }; if (!extra) return base; return { ...base, ...extra }; } function createBuildTiming(options) { return { enabled: hasDiagnosticsFlag(options), lines: [], startedAt: process.hrtime.bigint(), }; } function hasDiagnosticsFlag(options) { return (hasEnabledPassthroughFlag(options, "--diagnostics") || hasEnabledPassthroughFlag(options, "--extendedDiagnostics")); } function hasEnabledPassthroughFlag(options, flag) { const passthrough = options.passthrough ?? []; for (let i = 0; i < passthrough.length; i++) { const token = passthrough[i]; // Identity, not spelling: the user forwards their own casing and ttsc must // read `--DIAGNOSTICS` the way tsgo does. if ((0, schema_1.resolveFlagSpec)(token)?.name !== flag) continue; const equalsIndex = token.indexOf("="); if (equalsIndex !== -1) { return token.slice(equalsIndex + 1).toLowerCase() !== "false"; } if (i + 1 < passthrough.length && isBooleanLiteral(passthrough[i + 1])) { return passthrough[i + 1].toLowerCase() !== "false"; } return true; } return false; } function recordTiming(timing, label, startedAt) { if (!timing.enabled) return; timing.lines.push(`${label}: ${formatTimingSeconds(hrtimeMs(startedAt))}`); } function appendTimingOutput(result, timing) { if (!timing.enabled) return result; const lines = [ ...timing.lines, `ttsc total time: ${formatTimingSeconds(hrtimeMs(timing.startedAt))}`, ]; return { ...result, stdout: appendStdout(result.stdout, lines.join("\n") + "\n"), }; } function appendStdout(stdout, text) { if (stdout.length === 0 || stdout.endsWith("\n")) return stdout + text; return `${stdout}\n${text}`; } function hrtimeMs(startedAt) { return Number(process.hrtime.bigint() - startedAt) / 1e6; } function formatTimingSeconds(ms) { return `${(ms / 1000).toFixed(3)}s`; } /** * Build the environment for a native plugin spawn. Injects `TTSC_TSGO_BINARY` * and `TTSC_TTSX_BINARY` alongside the base env from `mergeEnv`, plus * `TTSC_PLUGIN_CONFIG_DIR` when the caller declared a plugin config anchor (an * embedder compiling through a generated wrapper tsconfig) so config-file * discovery walks the real project instead of the wrapper's temp-dir ancestry. * For transform-stage plugins, also passes `TTSC_LINKED_PLUGINS_JSON` * containing any linked sources so they run inside the same process as the host * plugin. */ function nativePluginEnv(extra, execution, plugin) { const env = mergeEnv({ ...(execution.pluginConfigDir === undefined ? {} : { TTSC_PLUGIN_CONFIG_DIR: execution.pluginConfigDir }), TTSC_TSGO_BINARY: process.env.TTSC_TSGO_BINARY ?? execution.tsgo.binary, TTSC_TTSX_BINARY: process.env.TTSC_TTSX_BINARY ?? node_path_1.default.join(__dirname, "..", "..", "launcher", "ttsx.js"), ...extra, }); // The anchor is per-invocation state owned by this host: when this run // declared none (and the caller's env does not name one), drop any value // inherited from an ancestor ttsc process so a nested build never // mis-anchors its plugins at the outer project. if (execution.pluginConfigDir === undefined && extra?.TTSC_PLUGIN_CONFIG_DIR === undefined) { delete env.TTSC_PLUGIN_CONFIG_DIR; } if (plugin?.stage === "transform") { const linked = (0, sharedHostHelpers_1.linkedTransformPlugins)(execution.nativePlugins); if (linked.length !== 0) { env.TTSC_LINKED_PLUGINS_JSON = serializeNativePlugins(linked); } } return env; } /** * Run `ttsc` against a tsconfig. Returns once the binary exits so the CLI can * decide how to surface diagnostics. Does not throw on non-zero exit. */ function runBuild(options = {}) { const timing = createBuildTiming(options); const result = runBuildTimed(options, timing); return appendTimingOutput(result, timing); } /** * Analysis-only watch coordinator. * * The selected project and compatible check-stage processes stay resident * across ordinary source/data edits. A config, root-set, contributor, or plugin * topology transition calls for a full reset before the next cycle. Emit and * transform lanes can pass through the coordinator, but compatibility checks * keep them on the established one-shot path without starting sidecars. */ class ResidentCheckWatchSession { execution; pendingChanges = new Map(); projectInputs; processes = new Map(); async run(options, change = {}) { if (change.reload === true) this.reset(); const timing = createBuildTiming(options); const projectFree = runProjectFreeTerminalFlag(options); if (projectFree !== null) { this.reset(); return appendTimingOutput(projectFree, timing); } let execution = this.execution; const reusedExecution = execution !== undefined; let buildOptions; if (execution === undefined) { const setupStartedAt = process.hrtime.bigint(); execution = resolveExecutionContext(options); const discoveryOptions = this.captureProjectInputs(options); const prepared = prepareBuildExecution(discoveryOptions, timing, execution, setupStartedAt); buildOptions = prepared.buildOptions; if (prepared.result !== undefined) { this.reset(); return appendTimingOutput(prepared.result, timing); } if (!residentCheckExecutionIsCompatible(buildOptions, execution)) { this.reset(); return appendTimingOutput(runPreparedBuild(options, timing, execution, buildOptions), timing); } this.execution = execution; } else { buildOptions = applyProjectNoEmit(options, execution); } if (reusedExecution && this.refreshProjectInputTopology(options, execution)) { this.reset(); return this.run(options); } const checked = await this.runCheckPlugins(buildOptions, execution, timing, change); let result; if (checked.status !== 0) { result = appendTypeScriptDiagnosticsAfterPluginFailure(checked, buildOptions, execution); } else if (checkPluginsReportTypeScriptDiagnostics(execution.nativePlugins)) { result = checked; } else { result = appendBuildOutput(checked, runTsgo(execution, ["--noEmit"], buildOptions)); } return appendTimingOutput(result, timing); } /** Terminate every sidecar and discard the cached selection context. */ dispose() { this.reset(); } reset() { for (const process of this.processes.values()) process.dispose(); this.processes.clear(); this.pendingChanges.clear(); this.execution = undefined; this.projectInputs = undefined; } captureProjectInputs(options) { const onProjectInputs = options.onProjectInputs; if (onProjectInputs === undefined) return options; return { ...options, onProjectInputs: (snapshot) => { this.projectInputs = snapshot; onProjectInputs(snapshot); }, }; } refreshProjectInputTopology(options, execution) { if (options.onProjectInputs === undefined) return false; const next = discoverNativeProjectInputs(options, execution); const changed = this.projectInputs !== undefined && !projectInputSnapshotsEqual(this.projectInputs, next); this.projectInputs = next; options.onProjectInputs(next); return changed; } async runCheckPlugins(options, execution, timing, change) { let out = { diagnostics: [], status: 0, stderr: "", stdout: "", }; const checks = planResidentCheckEntries(execution.nativePlugins, (plugin) => createNativeCheckArgs(execution, options, plugin)); const request = residentCheckRequest(change, execution.projectRoot); // Buffer the cycle for every resident plugin before running any of them. // An earlier plugin may fail and short-circuit diagnostics, but a later // sidecar must still receive every filesystem transition when it resumes. bufferResidentCheckEntryRequests(this.pendingChanges, checks, request); for (const { args, entryIndex, key, plugin } of checks) { let result; if (key === undefined) { result = runNativePluginCommand(plugin, args, options, execution, "ttsc.check", timing, `ttsc check plugin ${plugin.name} time`); } else { const startedAt = process.hrtime.bigint(); let resident = this.processes.get(key); if (resident === undefined) { resident = new residentCheckProcess_1.ResidentCheckProcess({ args: ["check-serve", ...args.slice(1)], binary: plugin.binary, cwd: execution.projectRoot, env: nativePluginEnv(options.env, execution, plugin), }); this.processes.set(key, resident); } try { const reply = await resident.request(takeResidentCheckEntryRequest(this.pendingChanges, entryIndex)); result = normalizeBuildOutput({ status: reply.status, stderr: reply.stderr, stdout: reply.stdout, }, execution.projectRoot); } catch { resident.dispose(); this.processes.delete(key); // The one-shot fallback observes the complete current filesystem, // and a later sidecar starts cold, so neither needs old deltas. // A capability-aware host may still disappear or violate framing. // Preserve correctness by running the established one-shot command // for this cycle; the next cycle gets one clean respawn attempt. result = runNativePluginCommand(plugin, args, options, execution, "ttsc.check", { ...timing, enabled: false }, ""); } recordTiming(timing, `ttsc check plugin ${plugin.name} time`, startedAt); } out = appendBuildOutput(out, result); if (result.status !== 0) return out; } return out; } } exports.ResidentCheckWatchSession = ResidentCheckWatchSession; function projectInputSnapshotsEqual(left, right) { const leftReloadFiles = left.reloadFiles ?? []; const rightReloadFiles = right.reloadFiles ?? []; const leftReloadDirectories = left.reloadDirectories ?? []; const rightReloadDirectories = right.reloadDirectories ?? []; return (left.root === right.root && left.files.length === right.files.length && left.globs.length === right.globs.length && leftReloadDirectories.length === rightReloadDirectories.length && leftReloadFiles.length === rightReloadFiles.length && left.files.every((value, index) => value === right.files[index]) && left.globs.every((value, index) => value === right.globs[index]) && leftReloadDirectories.every((value, index) => value === rightReloadDirectories[index]) && leftReloadFiles.every((value, index) => value === rightReloadFiles[index])); } function residentCheckExecutionIsCompatible(options, execution) { return (options.emit === false && options.fix !== true && options.format !== true && forwardsTerminalTsgoFlag(options) === false && execution.nativePlugins.every((plugin) => plugin.stage === "check")); } function residentCheckProcessKey(plugin, args) { return `${plugin.binary}\0${plugin.name}\0${JSON.stringify(args)}`; } /** * Plan every configured check entry while sharing resident processes only by * binary/name/argument identity. */ function planResidentCheckEntries(plugins, createArgs) { return plugins .filter((candidate) => candidate.stage === "check") .map((plugin, entryIndex) => { const args = createArgs(plugin); return { args, entryIndex, key: plugin.capabilities?.residentCheck === true ? residentCheckProcessKey(plugin, args) : undefined, plugin, }; }); } /** Retain one complete change stream per configured resident check entry. */ function bufferResidentCheckEntryRequests(pending, checks, request) { for (const check of checks) { if (check.key === undefined) continue; pending.set(check.entryIndex, mergeResidentCheckRequests(pending.get(check.entryIndex), request)); } } /** Consume exactly one configured entry's buffered request. */ function takeResidentCheckEntryRequest(pending, entryIndex) { const request = pending.get(entryIndex); if (request === undefined) { throw new Error(`ttsc: resident check entry ${String(entryIndex)} has no buffered request`); } pending.delete(entryIndex); return request; } function residentCheckRequest(change, cwd) { const normalize = (values) => [...new Set(values?.map((value) => node_path_1.default.resolve(cwd, value)) ?? [])].sort(); const changed = normalize(change.changed); const external = normalize(change.external); return { ...(change.invalidate === true ? { invalidate: true } : {}), ...(changed.length === 0 ? {} : { changed }), ...(external.length === 0 ? {} : { external }), }; } function mergeResidentCheckRequests(previous, current) { const merge = (left, right) => [...new Set([...(left ?? []), ...(right ?? [])])].sort(); const changed = merge(previous?.changed, current.changed); const external = merge(previous?.external, current.external); return { ...(changed.length === 0 ? {} : { changed }), ...(external.length === 0 ? {} : { external }), ...(previous?.invalidate === true || current.invalidate === true ? { invalidate: true } : {}), }; } function runBuildTimed(options, timing) { const projectFree = runProjectFreeTerminalFlag(options); if (projectFree !== null) return projectFree; const setupStartedAt = process.hrtime.bigint(); const execution = resolveExecutionContext(options); return runBuildWithExecution(options, timing, execution, setupStartedAt); } function runBuildWithExecution(options, timing, execution, setupStartedAt) { const prepared = prepareBuildExecution(options, timing, execution, setupStartedAt); if (prepared.result !== undefined) return prepared.result; return runPreparedBuild(options, timing, execution, prepared.buildOptions); } function prepareBuildExecution(options, timing, execution, setupStartedAt) { if (execution.nativePlugins.length > 0 || execution.pluginSetupFailure !== undefined) { recordTiming(timing, "ttsc plugin setup time", setupStartedAt); } const buildOptions = applyProjectNoEmit(options, execution); if (execution.pluginSetupFailure !== undefined) { return { buildOptions, result: appendTypeScriptDiagnosticsAfterPluginFailure(execution.pluginSetupFailure, buildOptions, execution), }; } if (options.onProjectInputs !== undefined) { options.onProjectInputs(discoverNativeProjectInputs(options, execution)); } return { buildOptions }; } function runPreparedBuild(options, timing, execution, buildOptions) { if (execution.nativePlugins.length > 0) { const compilers = execution.nativePlugins.filter((plugin) => plugin.stage === "transform"); const checked = runNativeCheckPlugins(buildOptions, execution, timing); if (checked.status !== 0) { return appendTypeScriptDiagnosticsAfterPluginFailure(checked, buildOptions, execution); } if (buildOptions.emit === false) { if (buildOptions.format === true) { // Format mode is write-only by contract: the lint sidecar // already rewrote source files and reported nothing. Running // tsgo --noEmit OR a transform compiler afterwards would either // surface unrelated type errors as if they were format failures // (tsgo path) or apply transform-stage rewrites on top of the // formatted source (transform path), both of which break the // documented "ttsc format only formats" guarantee. The // short-circuit fires before either branch so format mode is a // single concern regardless of how many compilers the project // configures. Callers that want a recheck or a transform pass // after format should run `ttsc check` / `ttsc build` as a // separate invocation. return checked; } if (compilers.length !== 0) { (0, sharedHostHelpers_1.assertSharedHostCompatibility)(compilers, "emit"); const compiled = buildWithNativeCompilerPlugins(buildOptions, execution, compilers, timing); const result = appendBuildOutput(checked, compiled); return compiled.status === 0 ? result : appendTypeScriptDiagnosticsAfterPluginFailure(result, buildOptions, execution); } if (checkPluginsReportTypeScriptDiagnostics(execution.nativePlugins)) { return checked; } return appendBuildOutput(checked, runTsgo(execution, ["--noEmit"], buildOptions)); } let result; if (compilers.length !== 0) { (0, sharedHostHelpers_1.assertSharedHostCompatibility)(compilers, "emit"); const compiled = buildWithNativeCompilerPlugins(buildOptions, execution, compilers, timing); result = appendBuildOutput(checked, compiled); if (compiled.status !== 0) { result = appendTypeScriptDiagnosticsAfterPluginFailure(result, buildOptions, execution); } } else { if (buildOptions.skipDiagnosticsCheck !== true && !checkPluginsReportTypeScriptDiagnostics(execution.nativePlugins) && !forwardsTerminalTsgoFlag(buildOptions)) { const tsgoChecked = runTsgo(execution, ["--noEmit"], buildOptions); if (tsgoChecked.status !== 0) { return appendBuildOutput(checked, tsgoChecked); } } const args = createTsgoBuildArgs(execution, buildOptions, { listEmittedFiles: buildOptions.forceListEmittedFiles === true, }); const emitted = runTsgoBuild(execution, buildOptions, args); result = appendBuildOutput(checked, emitted); } return result; } if (buildOptions.format === true) { // Format mode is write-only by contract — see the matching // short-circuit in the with-native-plugins branch above. When no // native plugin is loaded there is nothing for `ttsc format` to // rewrite, so emit an empty success result rather than falling // through to a tsgo pass that would surface unrelated type errors // as if they were format failures. return { diagnostics: [], status: 0, stdout: "", stderr: "", }; } const args = createTsgoBuildArgs(execution, buildOptions, { // `--verbose` promises the emitted-file list on every lane, and the list // only exists if tsgo is asked for it. ttsc adds the flag for itself here // exactly as it does for `forceListEmittedFiles`, and strips the raw // `TSFILE:` lines back out below. listEmittedFiles: buildOptions.emit !== false && (buildOptions.forceListEmittedFiles === true || buildOptions.quiet === false), noEmitOnError: buildOptions.emit !== false && buildOptions.skipDiagnosticsCheck !== true && !forwardsTerminalTsgoFlag(buildOptions), }); return runTsgoBuild(execution, buildOptions, args); } /** * Answer a forwarded terminal flag whose meaning precedes a project, when no * project can be resolved. * * `ttsc --init` exists to write the starter `tsconfig.json`, and `ttsc --all` / * `ttsc -?` only print tsgo's help — none of them needs a project, yet all * three died in project resolution because that layer ran first and * unconditionally. The classification is `FLAG_SCHEMA`'s (`terminal` + * `projectFree`), so marking a further flag project-free needs no edit here. * * A resolvable project keeps the established lane untouched: the build path * still forwards the flag with `-p <tsconfig>` from the project root, so `ttsc * --init` inside an existing project still reports tsgo's TS5054 instead of * writing a second config into the current directory. Returns `null` when this * lane does not apply. */ function runProjectFreeTerminalFlag(options) { if (!forwardsProjectFreeTerminalTsgoFlag(options)) return null; if (options.resolvedProject !== undefined) return null; const cwd = node_path_1.default.resolve(options.cwd ?? process.cwd()); try { (0, readProjectConfig_1.readProjectConfig)({ cwd, projectRoot: options.projectRoot, tsconfig: options.tsconfig, }); return null; } catch { // Any resolution failure takes this branch, not only "not found": a // malformed config and an explicitly named missing `-p` path are equally // beside the point for a flag whose meaning does not presuppose a project. // Forward it to tsgo from the invocation directory instead of failing. } const tsgo = (0, resolveTsgo_1.resolveTsgo)({ ...options, cwd }); const res = (0, spawnNative_1.spawnNative)(tsgo.binary, [...(options.passthrough ?? [])], { cwd, env: mergeEnv(options.env), encoding: "utf8", }); if (res.error) { throw new Error(`ttsc: failed to spawn ${tsgo.binary}: ${res.error.message}`); } return normalizeBuildOutput({ status: res.status ?? 1, stdout: (0, spawnNative_1.outputText)(res.stdout), stderr: (0, spawnNative_1.outputText)(res.stderr), }, cwd); } /** * A tsconfig-level `noEmit: true` is an analysis-only build unless the user * explicitly asks `ttsc --emit` to override it. Treat it like CLI `--noEmit` * before composing tsgo/native-host arguments so ttsc does not add emit-only * guards around projects that cannot emit. */ function applyProjectNoEmit(options, execution) { if (options.emit !== undefined || execution.projectNoEmit !== true) { return options; } return { ...options, emit: false }; } function checkPluginsReportTypeScriptDiagnostics(plugins) { return plugins.some((plugin) => plugin.stage === "check" && plugin.reportsTypeScriptDiagnostics === true); } /** * Preserve a failed plugin's output and status while collecting TypeScript * diagnostics through an independent no-emit pass. * * A sidecar can fail before it loads the project Program, including when a Go * panic or another runtime error terminates the process. The plugin failure * must still block emit, but it must not hide unrelated errors in the user's * TypeScript source. The fallback runs only after a plugin failure, skips modes * whose contract intentionally omits diagnostics, and avoids appending a batch * the plugin already reported itself. */ function appendTypeScriptDiagnosticsAfterPluginFailure(failure, options, execution) { if (options.format === true || options.skipDiagnosticsCheck === true || forwardsTerminalTsgoFlag(options)) { return failure; } const typechecked = runTsgo(execution, ["--noEmit"], createPluginFailureTypecheckOptions(options)); const fallback = filterReportedTypeScriptDiagnostics(failure, typechecked, execution.projectRoot); if (fallback === null) { return failure; } // Structured consumers (the public API's `IFailure.diagnostics`) never see // stdout/stderr, so a plugin failure that reported no parsable diagnostics // must be seeded as one before recovered TypeScript diagnostics are appended // — otherwise the recovery would replace the plugin error with unrelated // type errors instead of surfacing both. const seeded = failure.diagnostics.length === 0 ? { ...failure, diagnostics: [createProcessDiagnostic(failure)] } : failure; const status = failure.status; return { ...appendBuildOutput(seeded, fallback), status, }; } /** * Make the recovery pass parseable regardless of the user's display flags. This * is an internal second pass, so plain output is required to remove only * diagnostics that the failed plugin already printed. */ function createPluginFailureTypecheckOptions(options) { const passthrough = []; for (let i = 0; i < (options.passthrough?.length ?? 0); i++) { const token = options.passthrough[i]; if ((0, schema_1.resolveFlagSpec)(token)?.name === "--pretty") { // `--pretty` is boolean: it owns a following token only when that token // is the literal `true`/`false`, and the inline form carries its own. if (!token.includes("=") && isBooleanLiteral(options.passthrough[i + 1] ?? "")) { i++; } continue; } passthrough.push(token); } return { ...options, passthrough, structuredDiagnostics: true, }; } /** Return only fallback diagnostics the failed plugin did not already report. */ function filterReportedTypeScriptDiagnostics(failure, typechecked, cwd) { if (typechecked.diagnostics.length === 0) { return typechecked.status === 0 ? null : typechecked; } const diagnostics = typechecked.diagnostics.filter((diagnostic) => !failure.diagnostics.some((existing) => compilerDiagnosticsEqual(existing, diagnostic))); if (diagnostics.length === 0) return null; if (diagnostics.length === typechecked.diagnostics.length) return typechecked; return { ...typechecked, diagnostics, stderr: filterCompilerDiagnosticText(typechecked.stderr, diagnostics, cwd), stdout: filterCompilerDiagnosticText(typechecked.stdout, diagnostics, cwd), }; } /** Remove diagnostic lines absent from the selected structured result. */ function filterCompilerDiagnosticText(text, diagnostics, cwd) { const out = []; let keepContinuation = true; for (const line of text.split(/\r?\n/)) { const plain = stripAnsi(line); const diagnostic = parseDiagnosticLine(plain, cwd); if (diagnostic !== null) { keepContinuation = diagnostics.some((selected) => compilerDiagnosticsEqual(selected, diagnostic)); if (keepContinuation) out.push(line); continue; } if (/^Found\s+\d+\s+errors?/i.test(plain)) continue; if (!keepContinuation && /^\s+/.test(line)) continue; keepContinuation = true; out.push(line); } return out.join("\n"); } /** Compare normalized compiler diagnostics before appending fallback output. */ function compilerDiagnosticsEqual(left, right) { return (left.category === right.category && left.code === right.code && left.file === right.file && diagnosticPositionsEqual(left, right) && diagnosticHeadline(left.messageText) === diagnosticHeadline(right.messageText)); } /** Compare offsets when available, otherwise compare rendered line/column. */ function diagnosticPositionsEqual(left, right) { if (left.start !== undefined && right.start !== undefined) { return left.start === right.start; } return left.line === right.line && left.character === right.character; } /** Remove pretty-rendered source context from a diagnostic message. */ function diagnosticHeadline(message) { return message.split(/\r?\n/, 1)[0].trim(); } /** * Report whether the caller forwarded a print-and-exit tsgo flag * (`--showConfig`, `--listFilesOnly`, `--all`, `--init`, `-?`), so ttsc can * avoid adding compile-only flags to a command that is not going to compile. * * Schema-derived, and resolved by flag identity rather than by exact spelling: * `resolveFlagSpec` applies the one normalization the parsing engine and the * generated Go allow-lists use, so `--showconfig` classifies exactly like * `--showConfig`. Adding a new terminal flag means editing `schema.ts` and * re-running `pnpm run gen:flags`; this predicate needs no edit, and it grows * no normalization of its own for the next consumer to forget. */ function forwardsTerminalTsgoFlag(options) { return (options.passthrough?.some((token) => (0, schema_1.resolveFlagSpec)(token)?.terminal === true) ?? false); } /** * Report whether the caller forwarded a terminal flag whose meaning does not * presuppose a resolved project (`--init`, `--all`, `-?`). * * Derived from `FLAG_SCHEMA[*].projectFree`, through the same identity * resolution as every other classification — never a literal list of flag names * beside this branch, which is the shape that let terminal-flag awareness exist * in one layer and be missing from the layer above it. */ function forwardsProjectFreeTerminalTsgoFlag(options) { return (options.passthrough?.some((token) => { const flag = (0, schema_1.resolveFlagSpec)(token); return flag?.terminal === true && flag.projectFree === true; }) ?? false); } /** * Report whether the caller forwarded a flag ttsc adds to tsgo internally — * e.g. `--listEmittedFiles` (ttsc adds it to learn emitted paths) or `--noEmit` * (ttsc adds it for the pre-emit type-check). When the user also forwards the * same flag, post-processing must keep the user-visible effect intact instead * of stripping it as ttsc-internal noise. * * Schema-derived: `FLAG_SCHEMA[*].internalShadow === true`. RC-2 from the RCA * (RCA section 3, `--listEmittedFiles` / `--showConfig` swallowed): the * per-flag `passthrough.includes("…")` check is now one structural lookup * against the schema, not one bespoke `if` per shadow flag. */ function forwardsInternalShadowFlag(options, flag) { const passthrough = options.passthrough; if (passthrough === undefined) return false; // Resolution covers the bare form (`--pretty`), the inline-value form // (`--pretty=true`), and every casing tsgo accepts (`--PRETTY`) — the // launcher forwards the user's own spelling verbatim, so comparing raw // strings would miss a spelling tsgo honours. return passthrough.some((token) => { const spec = (0, schema_1.resolveFlagSpec)(token); return spec?.internalShadow === true && spec.name === flag; }); } /** * Dispatch a build through the shared-host native plugin. The host plugin is * the one that owns the process (non-linked); all other transform plugins ride * inside it via the `--plugins-json` flag. */ function buildWithNativeCompilerPlugins(options, execution, plugins, timing) { const host = (0, sharedHostHelpers_1.selectSharedHostPlugin)(plugins); return runNativePluginCommand(host, createNativeBuildArgs(execution, options, plugins), options, execution, "ttsc.build", timing, transformHostTimingLabel(plugins)); } /** * Run `tsgo -p <tsconfig> [extraArgs]` and return the normalized result. Used * for the no-emit type-check pass that precedes file emission. */ function runTsgo(execution, extraArgs, options) { const res = (0, spawnNative_1.spawnNative)(execution.tsgo.binary, [ "-p", execution.tsconfig, ...extraArgs, ...createTsgoDiagnosticArgs(options), ...createTsgoThreadingArgs(options), ...(options.passthrough ?? []), ], { cwd: execution.projectRoot, env: mergeEnv(options.env), encoding: "utf8", }); if (res.error) { throw new Error("ttsc: failed to spawn " + execution.tsgo.binary + ": " + res.error.message); } return normalizeBuildOutput({ status: res.status ?? 1, stdout: (0, spawnNative_1.outputText)(res.stdout), stderr: (0, spawnNative_1.outputText)(res.stderr), }, execution.projectRoot); } /** * Run `tsgo` with the full emit arguments and parse `TSFILE:` lines from stdout * into `emittedFiles`. The TSFILE lines are stripped before the result is * returned so they do not appear in the user-facing output. */ function runTsgoBuild(execution, options, args) { const res = (0, spawnNative_1.spawnNative)(execution.tsgo.binary, args, { cwd: execution.projectRoot, env: mergeEnv(options.env), encoding: "utf8", }); if (res.error) { throw new Error("ttsc.build: failed to spawn " + execution.tsgo.binary + ": " + res.error.message); } const result = { status: res.status ?? 1, stdout: (0, spawnNative_1.outputText)(res.stdout), stderr: (0, spawnNative_1.outputText)(res.stderr), }; const emittedFiles = parseEmittedFiles(result.stdout); // The `TSFILE:` lines are tsgo's `--listEmittedFiles` output. ttsc adds that // flag internally to learn the emitted paths and strips the lines back out // as noise — but when the user themselves forwarded `--listEmittedFiles`, // the listing is what they asked for, so it must survive to stdout. // The lookup is schema-driven (FLAG_SCHEMA marks `--listEmittedFiles` with // `internalShadow: true`); see `forwardsInternalShadowFlag` for the RC-2 // background. const userListedEmitted = forwardsInternalShadowFlag(options, "--listEmittedFiles"); if (emittedFiles.length !== 0 && !userListedEmitted) { result.stdout = stripEmittedFileLines(result.stdout); } if (options.quiet === false) { result.stdout += verboseBuildSummary(execution, options, emittedFiles); } return normalizeBuildOutput({ ...result, emittedFiles }, execution.projectRoot); } /** * The `--verbose` summary for the direct-tsgo lane, in the shape * `cmd/ttsc/build.go` already prints on the native-host lane. * * Verbosity is a launcher-owned presentation concern: which lane `runBuild` * selects is an implementation detail the user cannot see, so a documented flag * must not change meaning with it. This lane never consumed `quiet` at all, * which is why the flag was silent on every project without a ttsc plugin. * * `sites=0` is a fact about this lane rather than a placeholder: it runs * precisely when the project declares no native plugin. A build that emitted * nothing prints the header and `emitted=0 files`, so the summary never claims * files that were not written. */ function verboseBuildSummary(execution, options, emittedFiles) { const lines = [ `// ttsc: tsconfig=${execution.tsconfig} cwd=${execution.cwd} sites=0 emit=${options.emit !== false}`, ]; if (options.emit !== false) { lines.push(`// ttsc: emitted=${emittedFiles.length} files`); for (const file of emittedFiles) { lines.push(` + ${node_path_1.default.relative(execution.cwd, file) || file}`); } } return `${lines.join("\n")}\n`; } /** Build the argument list for a direct `tsgo` build invocation. */ function createTsgoBuildArgs(execution, options, flags) { const args = ["-p", execution.tsconfig]; if (options.emit === true) { args.push("--noEmit", "false", "--emitDeclarationOnly", "false"); if (execution.rewriteRelativeImportExtensionsForEmit) { args.push("--rewriteRelativeImportExtensions"); } // The ttsx runtime build asks for an external map when the project emits // none, so its served emit carries a map to inline under the source URL. // Pushed before passthrough so an explicit user `--sourceMap` still wins. if (options.forceRuntimeSourceMap === true) { args.push("--sourceMap", "true"); } } else if (options.emit === false) { args.push("--noEmit"); } if (options.outDir) { args.push("--outDir", node_path_1.default.resolve(execution.cwd, options.outDir)); } if (flags.listEmittedFiles) { args.push("--listEmittedFiles"); } args.push(...createTsgoDiagnosticArgs(options)); args.push(...createTsgoThreadingArgs(options)); args.push(...(options.passthrough ?? [])); args.push(...isolatedTsgoOutputArgs(options)); if (flags.noEmitOnError === true) { args.push("--noEmitOnError"); } return args; } /** * Return `["--pretty", "false"]` when structured diagnostics are requested so * that the output can be parsed line-by-line, or an empty array otherwise. * * When the user explicitly forwarded `--pretty` (any value), the internal * `--pretty false` shadow is dropped so the user wins on the surface. ttsc's * own diagnostic parser will then see pretty-formatted output and fall back to * surfacing it verbatim — the RC-2 contract that `--pretty`'s `internalShadow: * true` flag in `FLAG_SCHEMA` declares. Without this guard the order in * `runTsgo` (internal flags first, passthrough last) would still let the user's * `--pretty true` override at the tsgo level, but ttsc would have already * committed to a structured-diagnostics post-process that no longer matches the * actual output. */ function createTsgoDiagnosticArgs(options) { if (options.structuredDiagnostics !== true) return []; if (forwardsInternalShadowFlag(options, "--pretty")) return []; return ["--pretty", "false"]; } /** * Forward the `--singleThreaded` / `--checkers` knobs to a `tsgo` invocation. * tsgo accepts both flags natively, so the no-plugin build lane only has to * pass them through; the type-check and emit passes share this so the checker * pool size stays consistent across both. */ function createTsgoThreadingArgs(options) { const args = []; if (options.singleThreaded === true) { args.push("--singleThreaded"); } if (options.checkers !== undefined) { args.push("--checkers", String(options.checkers)); } return args; } /** Build the argument list for a native plugin `build`/`check` invocation. */ function createNativeBuildArgs(execution, options, plugins) { const args = [ options.emit === false ? "check" : "build", "--tsconfig=" + execution.tsconfig, "--plugins-json=" + serializeNativePlugins(plugins), "--cwd=" + execution.projectRoot, ]; if ((0, sharedHostHelpers_1.selectSharedHostPlugin)(plugins).capabilities?.projectContextArgs === true) { args.push(...(0, createNativeProjectContextArgs_1.createNativeProjectContextArgs)(execution.project, execution.pluginConfigDir)); } if (options.emit === true) { args.push("--emit"); } if (options.outDir) { args.push("--outDir=" + node_path_1.default.resolve(execution.cwd, options.outDir)); } // Third-party transform hosts already treat the `check` subcommand as a // quiet no-emit pass. Keep ttsc-owned build modifiers off that lane so older // strict hosts do not reject unknown optional flags before analysis starts. if (options.emit !== false) { if (options.quiet === false) { args.push("--verbose"); } else if (options.quiet === true) { args.push("--quiet"); } } args.push(...createNativeTsgoArgs(options)); return args; } /** Build the argument list for a native plugin check/fix/format invocation. */ function createNativeCheckArgs(execution, options, plugin) { const args = [ nativeCheckSubcommand(options), "--tsconfig=" + execution.tsconfig, "--plugins-json=" + serializeNativePlugins(execution.nativePlugins), "--cwd=" + execution.projectRoot, ]; if (plugin.capabilities?.projectContextArgs === true) { args.push(...(0, createNativeProjectContextArgs_1.createNativeProjectContextArgs)(execution.project, execution.pluginConfigDir)); } if (options.outDir) { args.push("--outDir=" + node_path_1.default.resolve(execution.cwd, options.outDir)); } if (options.quiet === false) { args.push("--verbose"); } else if (options.quiet === true) { args.push("--quiet"); } args.push(...createNativeCheckThreadingArgs(options, plugin)); args.push(...createNativeCheckDiagnosticsArgs(options, plugin)); args.push(...createNativeTsgoArgs(options)); return args; } function createNativeProjectInputsArgs(execution, plugin) { const args = [ "project-inputs", "--tsconfig=" + execution.tsconfig, "--plugins-json=" + serializeNativePlugins(execution.nativePlugins), "--cwd=" + execution.projectRoot, ]; if (plugin.capabilities?.projectContextArgs === true) { args.push(...(0, createNativeProjectContextArgs_1.createNativeProjectContextArgs)(execution.project, execution.pluginConfigDir)); } return args; } // `--singleThreaded` / `--checkers` are forwarded to native check-stage hosts // only when the host is one ttsc itself owns (currently `@ttsc/lint`). #113 // forwarded both flags as bare CLI tokens to every native sidecar, then // commit ad3443a reverted that across the board because a third-party host // built before #113 has no `singleThreaded` / `checkers` flag in its // `flag.FlagSet` and would exit 2 on the unknown flag — so // `ttsc --singleThreaded` failed deterministically on every typia/nestia // transform-plugin project. // // The performance ceiling that caused, though, is real: format/check passes // through the lint sidecar are dominated by parallel parse + parallel rule // walk, and with the threading knob silently dropped, MT and ST runs of // `ttsc format` produced identical wall-clock numbers — the benchmark cell // became a non-measurement. The lint sidecar is built and shipped from this // repo, accepts both flags via `parseSubcommandFlags`, and threads them down // to `loadProgram` (parse phase) and `engine.SetSerial` (rule walk). The host // opts in through `capabilities.threadingArgs`, so a third-party check-stage // plugin keeps the strict-host behavior from ad3443a unless it declares the // same contract. Transform-stage hosts are never reached by this path (they go // through `createNativeBuildArgs`), so the typia/nestia regression remains // pinned by `test_plugin_corpus_single_threaded_flag_does_not_break_a_native_plugin_build`. function createNativeCheckThreadingArgs(options, plugin) { if (!nativeHostAcceptsThreadingArgs(plugin)) return []; const args = []; if (options.singleThreaded === true) { args.push("--singleThreaded"); } if (options.checkers !== undefined) { args.push("--checkers=" + String(options.checkers)); } return args; } /** * Return true when the loaded native check-stage host has declared * `capabilities.threadingArgs` in its plugin descriptor. * * The lint sidecar (`packages/lint/src/index.ts::createTtscPlugin`) opts in * because its `parseSubcommandFlags` handler accepts `--singleThreaded` and * `--checkers` directly and threads them into `loadProgram` (parse phase) and * `engine.SetSerial` (rule walk). Any other check-stage host that has not * declared the capability is treated as a third-party binary whose flag set is * unknown, matching the conservative default from commit ad3443a. * * The capability flag replaces the prior `plugin.name === "@ttsc/lint"` string * check: routing on a descriptor field instead of the plugin name lets the next * first-party check-stage plugin opt in without ttsc needing to learn its name. * See `ITtscPluginCapabilities` and issue #125 for the broader CLI-parser * cleanup this is the quick-win step of. */ function nativeHostAcceptsThreadingArgs(plugin) { return plugin.capabilities?.threadingArgs === true; } function createNativeCheckDiagnosticsArgs(options, plugin) { if (!nativeHostAcceptsDiagnosticsTiming(plugin)) return []; if (!hasDiagnosticsFlag(options)) return []; return ["--diagnostics"]; } function nativeHostAcceptsDiagnosticsTiming(plugin) { return plugin.capabilities?.diagnosticsTiming === true; } function transformHostTimingLabel(plugins) { return (`ttsc transform host [` + `${plugins.map((plugin) => plugin.name).join(", ")}] time`); } /** * Forward the tsgo flags ttsc did not recognize to a native sidecar as one * JSON-encoded `--tsgo-args` flag. The sidecar replays them through tsgo's own * option parser onto `CompilerOptions`, so a flag like `ttsc --strict` reaches * a plugin build the same way it reaches the plain tsgo lane. Encoded as a * single token so the sidecars' unknown-flag filters keep it intact. */ function createNativeTsgoArgs(options) { const passthrough = [ ...(nativeTsgoPassthroughArgs(options) ?? []), ...isolatedTsgoOutputArgs(options), ]; if (passthrough.length === 0) { return []; } return ["--tsgo-args=" + JSON.stringify(passthrough)]; } function isolatedTsgoOutputArgs(options) { const target = "isolateOutputsTo" in options && typeof options.isolateOutputsTo === "string" ? node_path_1.default.resolve(options.isolateOutputsTo) : undefined; if (target === undefined) return []; return [ "--outFile", "null", "--declarationDir", "null", "--tsBuildInfoFile", "null", "--outDir", target, ]; } function nativeTsgoPassthroughArgs(options) { const passthrough = options.passthrough; if (passthrough === undefined) return undefined; const out = []; for (let i = 0; i < passthrough.length; i++) { const token = passthrough[i]; if (isDiagnosticsPassthroughFlag(token)) { if (!token.includes("=") && i + 1 < passthrough.length && isBooleanLiteral(passthrough[i + 1])) { i++; } continue; } out.push(token); } return out; } function isDiagnosticsPassthroughFlag(token) { const name = (0, schema_1.resolveFlagSpec)(token)?.name; return name === "--diagnostics" || name === "--extendedDiagnostics"; } function isBooleanLiteral(token) { const normalized = token.toLowerCase(); return normalized === "true" || normalized === "false"; } /** * Decide which native plugin subcommand the lint sidecar should run for the * current `runBuild` invocation. The launcher selects exactly one of `fix` / * `format` / `check` via subcommand dispatch, so at most one of the * `options.fix` / `options.format` booleans is true. */ function nativeCheckSubcommand(options) { if (options.format === true) return "format"; if (options.fix === true) return "fix"; return "check"; } /** * Serialize the plugin list to a compac