ttsc
Version:
General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.
1,131 lines • 69.6 kB
JavaScript
"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