ttsc
Version:
General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.
1,117 lines • 49.3 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PLUGIN_DESCRIPTOR_SHIM_SOURCE = void 0;
exports.loadProjectPlugins = loadProjectPlugins;
exports.hasProjectPluginEntries = hasProjectPluginEntries;
const node_child_process_1 = __importDefault(require("node:child_process"));
const node_fs_1 = __importDefault(require("node:fs"));
const node_module_1 = require("node:module");
const node_os_1 = __importDefault(require("node:os"));
const node_path_1 = __importDefault(require("node:path"));
const paths_1 = require("../../compiler/internal/paths");
const readConfigJson_1 = require("../../compiler/internal/project/readConfigJson");
const readProjectConfig_1 = require("../../compiler/internal/project/readProjectConfig");
const buildSourcePlugin_1 = require("./buildSourcePlugin");
const descriptorProcessFailure_1 = require("./descriptorProcessFailure");
const GO_MOD_SEARCH_MAX_DEPTH = 3;
/**
* Resolve, load, and build all native plugin sidecars for a TypeScript project.
*
* Reads the project config, discovers plugin entries (from tsconfig and package
* auto-discovery), validates and composes their descriptors, then invokes
* `buildSourcePlugin` to compile each Go source package into a cached binary.
* Returns the ordered set of loaded native plugins alongside the parsed project
* config.
*
* @param options.binary - Absolute path to the ttsc native helper binary.
* @param options.cacheDir - Override the plugin binary cache directory.
* @param options.cwd - Working directory for resolving relative paths.
* @param options.entries - Explicit plugin entries; `false` disables all
* plugins (skips both tsconfig entries and package auto-discovery).
* @param options.env - Effective environment for source-plugin builds and the
* `ttsx` descriptor child (`{ ...process.env, ...context.env }`). Defaults to
* `process.env` for CLI callers, so ambient behavior is unchanged.
* @param options.file - Path to the tsconfig/jsconfig file.
* @param options.pluginConfigDir - Caller-declared anchor for plugin
* config-file discovery (see `ITtscPluginFactoryContext.pluginConfigDir`).
* @param options.projectRoot - Override the project root directory.
* @param options.tsconfig - Alias for `file`.
*/
function loadProjectPlugins(options) {
const effectiveEnv = options.env ?? process.env;
const project = (0, readProjectConfig_1.readProjectConfig)({
cwd: options.cwd,
file: options.file,
projectRoot: options.projectRoot,
tsconfig: options.tsconfig,
});
const entries = options.entries === false
? []
: resolvePluginEntries(project, options.entries).filter((entry) => entry.config.enabled !== false);
if (entries.length === 0) {
options.onWatchInputs?.([]);
return {
nativePlugins: [],
project,
};
}
const cwd = node_path_1.default.resolve(options.cwd ?? process.cwd());
const context = {
binary: options.binary,
cwd,
...(options.pluginConfigDir === undefined || options.pluginConfigDir === ""
? {}
: { pluginConfigDir: node_path_1.default.resolve(cwd, options.pluginConfigDir) }),
projectRoot: project.root,
tsconfig: project.path,
};
const plugins = composePluginSources(entries, entries.map((entry) => loadPluginEntry(entry.config, { ...context, plugin: entry.config }, entry.baseDir, effectiveEnv)));
const ttscVersion = readTtscVersion();
const tsgoVersion = readTsgoVersion(context.projectRoot);
const records = plugins.map((plugin, index) => {
const stage = resolvePluginStage(plugin);
validatePluginSource(plugin);
const contributors = validatePluginContributors(plugin);
const source = resolvePluginSource(plugin.source, context.projectRoot);
const kind = resolveNativeSourceKind(source, plugin, entries[index].config, index);
if (kind === "linked" && stage !== "transform") {
throw new Error(`ttsc: plugin "${pluginLabel(plugin, entries[index].config, index)}" source is a linked Go package, but only transform-stage plugins can be linked into a compiler host`);
}
const linkedContributorName = kind === "linked"
? `linked_${String(index).padStart(6, "0")}`
: undefined;
return {
capabilities: plugin.capabilities,
contributors,
config: entries[index].config,
kind,
label: pluginLabel(plugin, entries[index].config, index),
linkedContributorName,
name: plugin.name,
reportsTypeScriptDiagnostics: plugin.reportsTypeScriptDiagnostics === true,
source,
stage,
};
});
options.onWatchInputs?.(records.flatMap((record) => [
record.source,
...(record.contributors?.map((contributor) => contributor.source) ?? []),
]));
const linkedContributors = records
.filter((record) => record.stage === "transform")
.flatMap((record) => record.kind === "linked"
? [{ name: record.linkedContributorName, source: record.source }]
: []);
const transformHosts = records.filter((record) => record.stage === "transform" && record.kind === "executable");
const hostContributors = linkedContributors.length === 0 ? undefined : linkedContributors;
const builtTransformHosts = new Map();
for (const record of transformHosts) {
builtTransformHosts.set(record, (0, buildSourcePlugin_1.buildSourcePlugin)({
baseDir: context.projectRoot,
cacheDir: options.cacheDir,
contributors: mergeContributors(record.contributors, hostContributors),
env: effectiveEnv,
pluginName: record.label,
source: record.source,
ttscVersion,
tsgoVersion,
}));
}
const fallbackDriverHost = transformHosts.length === 0 && linkedContributors.length !== 0
? (0, buildSourcePlugin_1.buildSourcePlugin)({
baseDir: context.projectRoot,
cacheDir: options.cacheDir,
contributors: linkedContributors,
env: effectiveEnv,
label: "linked plugin host",
pluginName: "linked-plugin-host",
source: node_path_1.default.join(ttscPackageRoot(), "cmd", "utility-host"),
ttscVersion,
tsgoVersion,
})
: undefined;
const selectedTransformHost = transformHosts.length === 0
? fallbackDriverHost
: builtTransformHosts.get(transformHosts[0]);
const nativePlugins = records.map((record) => {
const binary = record.stage === "transform" && record.kind === "linked"
? selectedTransformHost
: record.stage === "transform"
? builtTransformHosts.get(record)
: (0, buildSourcePlugin_1.buildSourcePlugin)({
baseDir: context.projectRoot,
cacheDir: options.cacheDir,
contributors: record.contributors,
env: effectiveEnv,
pluginName: record.label,
source: record.source,
ttscVersion,
tsgoVersion,
});
if (binary === undefined) {
throw new Error(`ttsc: plugin "${record.label}" is a linked Go package, but no compiler host is available`);
}
return {
binary,
capabilities: record.capabilities,
config: record.config,
contributors: record.contributors,
kind: record.kind,
name: record.name,
reportsTypeScriptDiagnostics: record.reportsTypeScriptDiagnostics,
source: record.source,
stage: record.stage,
};
});
return {
nativePlugins: orderNativePlugins(nativePlugins),
project,
};
}
function composePluginSources(entries, plugins) {
const aggregates = plugins
.map((plugin, index) => ({ index, plugin }))
.filter(({ plugin }) => Array.isArray(plugin.composes));
if (aggregates.length === 0) {
return [...plugins];
}
for (const { plugin } of aggregates) {
for (const target of plugin.composes) {
if (typeof target !== "string" || target.trim() === "") {
throw new Error(`ttsc: plugin "${plugin.name}" has an invalid "composes" target; ` +
`targets must be non-empty plugin names or transform specifiers`);
}
}
}
// Composition is intentionally one hop only: A.composes=[B] sends B to A's
// binary, but if B.composes=[C] then C uses B's original source and does NOT
// cascade to A. Detect cycles (A.composes=[B] && B.composes=[A]) and throw,
// otherwise the silent reswap below would mis-route both plugins.
for (const { index: i, plugin: a } of aggregates) {
for (const { index: j, plugin: b } of aggregates) {
if (i === j)
continue;
const aTransform = entries[i]?.config.transform;
const bTransform = entries[j]?.config.transform;
const aComposesB = a.composes.some((alias) => matchesPluginAlias(alias, b, bTransform));
const bComposesA = b.composes.some((alias) => matchesPluginAlias(alias, a, aTransform));
if (aComposesB && bComposesA) {
throw new Error(`ttsc: plugin composes cycle detected between "${a.name}" and "${b.name}"; ` +
`each plugin lists the other in its "composes" array — composition is one hop only, not transitive`);
}
}
}
return plugins.map((plugin, index) => {
const transform = entries[index]?.config.transform;
const matchingAggregates = aggregates.filter(({ index: aggregateIndex, plugin: aggregatePlugin }) => aggregateIndex !== index &&
aggregatePlugin.composes.some((alias) => matchesPluginAlias(alias, plugin, transform)));
if (matchingAggregates.length > 1) {
throw new Error(`ttsc: plugin "${plugin.name}" is composed by multiple aggregate plugins; ` +
`each plugin entry can be redirected to only one aggregate native host`);
}
const aggregate = matchingAggregates[0];
if (aggregate === undefined) {
return plugin;
}
// A composed plugin's source is rerouted to the aggregate's binary,
// so its own `contributors` would link into a different host than
// it was authored against. The "one binary" guarantee in the
// protocol doc holds only when the composed plugin defers entirely
// to the aggregate; reject early instead of silently producing two
// diverging binaries.
if (plugin.contributors && plugin.contributors.length > 0) {
throw new Error(`ttsc: plugin "${plugin.name}" is composed by "${aggregate.plugin.name}" but declares its own "contributors"; ` +
`move the contributors onto the aggregate plugin or drop the composes redirect`);
}
return {
...plugin,
source: aggregate.plugin.source,
contributors: aggregate.plugin.contributors,
// The composed plugin's runtime BINARY is the aggregate's binary,
// so the CLI surface (which flags the sidecar parses) is the
// aggregate's. Inherit `capabilities` from the aggregate so a
// capability the aggregate declares — e.g. threadingArgs — does
// not get silently dropped just because the composed entry's own
// descriptor omitted it. If the aggregate did not set capabilities
// we keep the composed plugin's own as a fallback.
capabilities: aggregate.plugin.capabilities ?? plugin.capabilities,
};
});
}
function matchesPluginAlias(alias, plugin, transform) {
return (alias === plugin.name ||
(typeof transform === "string" && alias === transform));
}
/**
* Return `true` when the project has at least one enabled plugin entry.
*
* Used by callers that need to skip plugin-specific work when no plugins are
* configured, without paying the full cost of `loadProjectPlugins`.
*
* @param entries - Explicit entries; `false` always returns `false`.
*/
function hasProjectPluginEntries(project, entries) {
if (entries === false) {
return false;
}
return resolvePluginEntries(project, entries).some((entry) => entry.config.enabled !== false);
}
function resolvePluginEntries(project, entries) {
if (entries !== undefined) {
return entries.map((config) => ({
baseDir: project.root,
config,
}));
}
const configured = project.compilerOptions.plugins.map((config, index) => {
// A bare/package plugin specifier (e.g. "typia/lib/transform") must resolve
// from the project's own node_modules, not from the tsconfig that declared
// it: an `extends`ed base config (a shared `tests/config/tsconfig.json`)
// declares the plugin, but the package is installed under the consuming
// project. Only a relative specifier ("./plugin") is meaningful relative to
// the declaring config's directory. Mirrors discoverPackagePluginEntries.
const declaringDir = project.pluginBaseDirs[index];
const baseDir = typeof config.transform === "string" &&
isRelativePluginSpecifier(config.transform) &&
declaringDir !== undefined
? declaringDir
: project.root;
return { baseDir, config };
});
return [...configured, ...discoverPackagePluginEntries(project, configured)];
}
function discoverPackagePluginEntries(project, configured) {
const projectPackageJson = findNearestPackageJson(project.root);
if (projectPackageJson === undefined) {
return [];
}
const projectPackageRoot = node_path_1.default.dirname(projectPackageJson);
const projectManifest = readPackageManifest(projectPackageJson);
if (projectManifest === undefined) {
return [];
}
const configuredTransforms = createConfiguredTransformSet(configured);
const out = [];
for (const name of directDependencyNames(projectManifest)) {
const packageJson = resolveDependencyPackageJson(name, projectPackageRoot);
if (packageJson === undefined) {
continue;
}
const manifest = readPackageManifest(packageJson);
const config = readPackagePluginConfig(name, manifest);
if (config === undefined || config.enabled === false) {
continue;
}
const packageRoot = node_path_1.default.dirname(packageJson);
const transform = config.transform;
if (typeof transform !== "string") {
continue;
}
const baseDir = isRelativePluginSpecifier(transform)
? packageRoot
: projectPackageRoot;
const resolved = resolvePluginRequest(transform, baseDir);
if (hasConfiguredTransform(configuredTransforms, transform, resolved)) {
continue;
}
out.push({
baseDir,
config,
});
addConfiguredTransform(configuredTransforms, transform, resolved);
}
return out;
}
function createConfiguredTransformSet(entries) {
const raw = new Set();
const resolved = new Set();
for (const entry of entries) {
const transform = entry.config.transform;
if (typeof transform !== "string" || transform.length === 0) {
continue;
}
if (!isRelativePluginSpecifier(transform)) {
raw.add(transform);
}
try {
resolved.add(resolvePluginRequest(transform, entry.baseDir));
}
catch {
// Keep the normal plugin loading error path for invalid explicit entries.
}
}
return { raw, resolved };
}
function hasConfiguredTransform(configuredTransforms, transform, resolved) {
return (configuredTransforms.resolved.has(resolved) ||
(!isRelativePluginSpecifier(transform) &&
configuredTransforms.raw.has(transform)));
}
function addConfiguredTransform(configuredTransforms, transform, resolved) {
if (!isRelativePluginSpecifier(transform)) {
configuredTransforms.raw.add(transform);
}
configuredTransforms.resolved.add(resolved);
}
function directDependencyNames(manifest) {
const seen = new Set();
const out = [];
for (const dependencies of [
manifest.dependencies,
manifest.devDependencies,
]) {
if (!isRecord(dependencies)) {
continue;
}
for (const name of Object.keys(dependencies)) {
if (seen.has(name)) {
continue;
}
seen.add(name);
out.push(name);
}
}
return out;
}
function resolveDependencyPackageJson(name, projectRoot) {
const direct = node_path_1.default.join(projectRoot, "node_modules", ...name.split("/"));
const directManifest = node_path_1.default.join(direct, "package.json");
if (node_fs_1.default.existsSync(directManifest)) {
return resolveRealPath(directManifest);
}
const projectPackage = node_path_1.default.join(projectRoot, "package.json");
const projectRequire = (0, node_module_1.createRequire)(projectPackage);
try {
return resolveRealPath(projectRequire.resolve(`${name}/package.json`));
}
catch {
try {
return findNearestPackageJson(projectRequire.resolve(name));
}
catch {
return undefined;
}
}
}
function findNearestPackageJson(location) {
let current = node_fs_1.default.statSync(location).isDirectory()
? location
: node_path_1.default.dirname(location);
while (true) {
const manifest = node_path_1.default.join(current, "package.json");
if (node_fs_1.default.existsSync(manifest)) {
return resolveRealPath(manifest);
}
const parent = node_path_1.default.dirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
}
/**
* Read a package manifest, or `undefined` when the file is absent or is not a
* JSON object. A malformed manifest throws naming the file: these are usually
* files the user did not author, which makes an unattributed `JSON.parse`
* message worse here than anywhere else.
*/
function readPackageManifest(file) {
if (!node_fs_1.default.existsSync(file)) {
return undefined;
}
const parsed = (0, readConfigJson_1.readJsonFile)(file);
return isRecord(parsed) ? parsed : undefined;
}
function readPackagePluginConfig(packageName, manifest) {
const ttsc = manifest?.ttsc;
if (!isRecord(ttsc) || !("plugin" in ttsc)) {
return undefined;
}
const plugin = ttsc.plugin;
if (!isRecord(plugin) || Array.isArray(plugin)) {
throw new Error(`ttsc: package ${JSON.stringify(packageName)} declares invalid "ttsc.plugin"; expected an object`);
}
if (typeof plugin.transform !== "string" || plugin.transform.length === 0) {
throw new Error(`ttsc: package ${JSON.stringify(packageName)} declares invalid "ttsc.plugin.transform"; expected a non-empty string`);
}
return { ...plugin };
}
function isRecord(value) {
return typeof value === "object" && value !== null;
}
function orderNativePlugins(plugins) {
return [
...plugins.filter((plugin) => plugin.stage === "check"),
...plugins.filter((plugin) => plugin.stage === "transform"),
];
}
function loadPluginEntry(entry, base, baseDir, effectiveEnv) {
return withPluginLoaderEnv(() => {
const specifier = entry.transform;
if (typeof specifier !== "string" || specifier.length === 0) {
throw new Error(`ttsc: plugin entry is missing a string "transform" field`);
}
const request = resolvePluginRequest(specifier, baseDir);
// `dirname`/`filename` are per-entry: each plugin entry resolves to its own
// descriptor module, so they are derived here from the resolved `request`
// rather than carried on the shared base context. They give factories a
// load-mode-independent stand-in for `__dirname`/`__filename`, which are
// undefined when a descriptor loads through ttsx or as ESM.
const context = {
...base,
dirname: node_path_1.default.dirname(request),
filename: request,
};
const mod = requirePluginEntry(request, context, effectiveEnv);
const candidate = mod.createTtscPlugin ??
mod.default ??
mod.plugin ??
mod;
if (typeof candidate === "function") {
const plugin = candidate(context);
if (!isTtscPlugin(plugin)) {
throw new Error(`ttsc: plugin "${specifier}" does not export a valid ttsc plugin`);
}
rejectJsTransformFunctions(specifier, plugin);
return plugin;
}
if (isTtscPlugin(candidate)) {
rejectJsTransformFunctions(specifier, candidate);
return candidate;
}
throw new Error(`ttsc: plugin "${specifier}" does not export a valid ttsc plugin`);
});
}
/**
* Require a plugin descriptor entry, falling back to `ttsx` when Node cannot
* load a `.ts` source entry directly.
*
* A descriptor entry that is `.ts` source — especially a package root that
* re-exports a runtime alongside the descriptor — fails Node's loader on its
* first extensionless import or un-stripped type, and its imports can fan out
* into a whole transitive graph of source packages. Rather than reimplement
* that graph build, run the entry through `ttsx`, which already builds each
* `.ts` dependency on demand. The run is forced plugins-off across the whole
* graph (`--no-plugins` for the entry, `TTSC_PLUGIN_DESCRIPTOR_LOAD` for every
* dependency), so the descriptor's own — possibly self-hosting — transform
* never runs and cannot deadlock. A package that loads directly (a compiled
* descriptor, or Bun's native `.ts`) never reaches the fallback.
*/
function requirePluginEntry(request, context, effectiveEnv) {
try {
return require(request);
}
catch (error) {
if (!TS_SOURCE_PATTERN.test(request)) {
throw error;
}
const descriptor = loadDescriptorViaTtsx(request, context, effectiveEnv);
if (descriptor === undefined) {
throw error;
}
return { default: descriptor };
}
}
const TS_SOURCE_PATTERN = /\.(?:[cm]?ts|tsx)$/i;
/**
* The descriptor shim's emitted source.
*
* Exported so a regression can inspect the same bytes ttsx executes. This
* template consumes its own escapes, so reading this file's text instead would
* check characters no consumer ever sees — and a dropped backslash turns an
* escape into the character it was escaping: a raw line terminator inside a
* string literal, which stops the shim parsing and takes every descriptor load
* with it. Its `@ttsc/lint` twin has carried that guard since the same defect
* shipped there.
*/
exports.PLUGIN_DESCRIPTOR_SHIM_SOURCE = [
`// @ts-nocheck`,
`import { writeFileSync } from "node:fs";`,
`import { pathToFileURL } from "node:url";`,
// The import is inside the try, not above it. A descriptor that cannot be
// found, or whose module body throws, fails exactly where a descriptor
// whose factory throws does, and a caller deserves the same reason for
// both — "Cannot find module ./missing" is as actionable as anything the
// factory could have said.
`try {`,
` const mod = await import(pathToFileURL(process.env.TTSC_PLUGIN_ENTRY).href);`,
` const context = JSON.parse(process.env.TTSC_PLUGIN_CONTEXT);`,
` const candidate = mod.createTtscPlugin ?? mod.default ?? mod.plugin ?? mod;`,
` const descriptor =`,
` typeof candidate === "function" ? candidate(context) : candidate;`,
` writeFileSync(process.env.TTSC_PLUGIN_DESCRIPTOR_OUT, JSON.stringify(descriptor));`,
`} catch (error) {`,
// The stack streams to the user's stderr on its own. This puts the reason
// a caller can act on into the channel the parent already reads, so the
// failure is not reduced to a bare exit status.
// The escape is doubled on purpose: this template consumes one level, so
// `\\n` here is what puts the two-character escape into the emitted shim.
// A single `\n` would put a raw line terminator inside a string literal,
// and the shim would stop parsing — taking every descriptor load with it.
` process.stderr.write((error instanceof Error && error.stack ? error.stack : String(error)) + "\\n");`,
` try {`,
` writeFileSync(process.env.TTSC_PLUGIN_DESCRIPTOR_OUT, JSON.stringify({ __ttscLoaderError: error instanceof Error ? error.message : String(error) }));`,
` } catch {}`,
` process.exit(1);`,
`}`,
``,
].join("\n");
/**
* Evaluate a `.ts` plugin descriptor entry in a child `ttsx` process and return
* the descriptor it produces. A generated shim imports the entry, invokes its
* factory with `context`, and writes the descriptor as JSON; `ttsx` runs the
* shim with plugins disabled across the whole graph. Returns `undefined` when
* `ttsx` is unavailable, so the caller can rethrow the original load error.
*/
function loadDescriptorViaTtsx(request, context, effectiveEnv) {
// Binary discovery prefers the instance environment, then the ambient
// process.env (where `withPluginLoaderEnv` injects ttsc's own node/ttsx paths
// just before this runs), then the running interpreter.
const node = effectiveEnv.TTSC_NODE_BINARY ??
process.env.TTSC_NODE_BINARY ??
process.execPath;
const ttsx = effectiveEnv.TTSC_TTSX_BINARY ?? process.env.TTSC_TTSX_BINARY;
if (ttsx === undefined || ttsx.length === 0) {
return undefined;
}
const dir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "ttsc-plugin-descriptor-"));
const out = node_path_1.default.join(dir, "descriptor.json");
const shim = node_path_1.default.join(dir, "load-descriptor.mts");
// ttsx type-checks and builds the shim's own project, so it needs a tsconfig
// to anchor on; a minimal one is enough (the shim is `@ts-nocheck`).
node_fs_1.default.writeFileSync(node_path_1.default.join(dir, "tsconfig.json"), JSON.stringify({
compilerOptions: {
module: "nodenext",
moduleResolution: "nodenext",
skipLibCheck: true,
target: "es2022",
},
}));
node_fs_1.default.writeFileSync(shim, exports.PLUGIN_DESCRIPTOR_SHIM_SOURCE);
try {
const result = node_child_process_1.default.spawnSync(node, [ttsx, "--no-plugins", shim], {
cwd: context.projectRoot,
encoding: "utf8",
env: {
...effectiveEnv,
// Carry ttsc's own node/ttsx locators explicitly so the child (which
// may recurse into further descriptor loads) finds them even when the
// instance-env snapshot predates `withPluginLoaderEnv`.
TTSC_NODE_BINARY: node,
TTSC_TTSX_BINARY: ttsx,
TTSC_PLUGIN_CONTEXT: JSON.stringify({
binary: context.binary,
cwd: context.cwd,
dirname: context.dirname,
filename: context.filename,
plugin: context.plugin,
projectRoot: context.projectRoot,
tsconfig: context.tsconfig,
}),
TTSC_PLUGIN_DESCRIPTOR_LOAD: "1",
TTSC_PLUGIN_DESCRIPTOR_OUT: out,
TTSC_PLUGIN_ENTRY: request,
},
// Both child streams are human output, and they go straight to this
// process's stderr as they are written. The descriptor itself travels
// through a file, so nothing here needs collecting — and collecting it
// only to replay it afterwards is what forced an invented output ceiling.
stdio: ["ignore", 2, 2],
windowsHide: true,
});
const processFailure = (0, descriptorProcessFailure_1.pluginDescriptorProcessFailure)(result, request);
if (processFailure) {
// The descriptor's stack already reached the user's stderr as it ran.
// What it could not put there is a reason a caller can act on, so that
// arrives through the result file instead.
const reason = (0, descriptorProcessFailure_1.pluginDescriptorFailureReason)(out);
throw reason === ""
? processFailure
: new Error(`${processFailure.message}
${reason}`);
}
if (!node_fs_1.default.existsSync(out)) {
throw new Error(`ttsc: plugin descriptor "${request}" evaluation through ttsx produced no descriptor output.`);
}
const text = node_fs_1.default.readFileSync(out, "utf8");
try {
return JSON.parse(text);
}
catch (error) {
throw new Error(`ttsc: plugin descriptor "${request}" produced invalid JSON: ${errorMessage(error)}`);
}
}
finally {
removeEvaluationTempDir(dir);
}
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
function withPluginLoaderEnv(run) {
const previousNode = process.env.TTSC_NODE_BINARY;
const previousTtsx = process.env.TTSC_TTSX_BINARY;
process.env.TTSC_NODE_BINARY ??= process.execPath;
process.env.TTSC_TTSX_BINARY ??= node_path_1.default.join(__dirname, "..", "..", "launcher", "ttsx.js");
try {
return run();
}
finally {
restoreEnv("TTSC_NODE_BINARY", previousNode);
restoreEnv("TTSC_TTSX_BINARY", previousTtsx);
}
}
function restoreEnv(key, value) {
if (value === undefined) {
delete process.env[key];
}
else {
process.env[key] = value;
}
}
function isTtscPlugin(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function rejectJsTransformFunctions(specifier, candidate) {
if ("transformSource" in candidate || "transformOutput" in candidate) {
throw new Error(`ttsc: plugin "${specifier}" declares unsupported JS transform functions; ` +
"declare a native backend instead");
}
}
function resolvePluginStage(plugin) {
if (plugin.stage === undefined) {
return "transform";
}
if (!isPluginStage(plugin.stage)) {
if (plugin.stage === "output") {
throw new Error(`ttsc: plugin "${plugin.name}" requested removed stage "output"; ` +
"upgrade the plugin to a transform-stage descriptor compatible with this ttsc version");
}
throw new Error(`ttsc: plugin "${plugin.name}" requested unsupported stage ${JSON.stringify(plugin.stage)}`);
}
return plugin.stage;
}
function validatePluginSource(plugin) {
if (typeof plugin.source !== "string" || plugin.source.length === 0) {
throw new Error(`ttsc: plugin must declare source`);
}
}
function pluginLabel(plugin, config, index) {
if (typeof plugin.name === "string" && plugin.name.length !== 0) {
return plugin.name;
}
if (typeof config.transform === "string" && config.transform.length !== 0) {
return config.transform;
}
return `#${index}`;
}
function resolvePluginSource(source, projectRoot) {
return resolveRealPath(node_path_1.default.isAbsolute(source) ? source : node_path_1.default.resolve(projectRoot, source));
}
function resolveNativeSourceKind(source, plugin, config, index) {
const packageDir = resolveGoPackageDir(source, pluginLabel(plugin, config, index));
if ((0, paths_1.findNearestGoMod)(packageDir, GO_MOD_SEARCH_MAX_DEPTH) === null) {
throw new Error(`ttsc: plugin "${pluginLabel(plugin, config, index)}" source must be inside a Go module with go.mod within ${GO_MOD_SEARCH_MAX_DEPTH} parent directories: ${source}`);
}
const packageName = readGoPackageName(packageDir);
if (packageName === null) {
throw new Error(`ttsc: plugin "${pluginLabel(plugin, config, index)}" source must contain at least one non-test ".go" file with a package declaration: ${packageDir}`);
}
return packageName === "main" ? "executable" : "linked";
}
function resolveGoPackageDir(source, label) {
if (!node_fs_1.default.existsSync(source)) {
// A descriptor factory runs without CommonJS globals when ttsc loads it
// through ttsx or as ESM — `__dirname`/`__filename`/`require` are undefined,
// so a `source` derived from them mis-resolves (often against cwd) and lands
// here. Name that failure mode explicitly instead of leaving a bare
// not-found path: the breakage is otherwise silent. (See #248.)
throw new Error(`ttsc: plugin "${label}" source does not exist: ${source}\n` +
` Plugin descriptors run without CommonJS globals: __dirname, __filename, ` +
`and require are undefined when ttsc loads a descriptor through ttsx or as ESM. ` +
`If this path was derived from one of them, use context.dirname / ` +
`context.filename (the descriptor's own directory and file, populated in ` +
`every load mode), or resolve it from context.projectRoot, e.g. ` +
`createRequire(path.join(context.projectRoot, "package.json"))` +
`.resolve("<your-package>/package.json").`);
}
const stat = node_fs_1.default.statSync(source);
if (stat.isFile() && node_path_1.default.basename(source) === "go.mod") {
return node_path_1.default.dirname(source);
}
if (stat.isDirectory()) {
return source;
}
throw new Error(`ttsc: plugin "${label}" source must be a Go package directory or go.mod file: ${source}`);
}
function readGoPackageName(dir) {
for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile() ||
!entry.name.endsWith(".go") ||
entry.name.endsWith("_test.go")) {
continue;
}
const file = node_path_1.default.join(dir, entry.name);
for (const line of node_fs_1.default.readFileSync(file, "utf8").split(/\r?\n/)) {
const match = /^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)\b/.exec(line);
if (match) {
return match[1];
}
}
}
return null;
}
const CONTRIBUTOR_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
function validatePluginContributors(plugin) {
const contributors = plugin.contributors;
if (contributors === undefined)
return undefined;
if (!Array.isArray(contributors)) {
throw new Error(`ttsc: plugin "${plugin.name}" "contributors" must be an array of { name, source } entries`);
}
if (contributors.length === 0)
return undefined;
const seen = new Set();
const out = [];
for (const [index, entry] of contributors.entries()) {
if (typeof entry !== "object" || entry === null) {
throw new Error(`ttsc: plugin "${plugin.name}" contributors[${index}] must be an object`);
}
const { name, source } = entry;
if (typeof name !== "string" || !CONTRIBUTOR_NAME_PATTERN.test(name)) {
throw new Error(`ttsc: plugin "${plugin.name}" contributors[${index}].name must match /^[a-z][a-z0-9_]*$/; ` +
`got ${JSON.stringify(name)}`);
}
if (seen.has(name)) {
throw new Error(`ttsc: plugin "${plugin.name}" contributors[${index}] duplicate name ${JSON.stringify(name)}`);
}
seen.add(name);
if (typeof source !== "string" || source.length === 0) {
throw new Error(`ttsc: plugin "${plugin.name}" contributors[${index}].source must be a non-empty string`);
}
if (!node_path_1.default.isAbsolute(source)) {
throw new Error(`ttsc: plugin "${plugin.name}" contributors[${index}].source must be an absolute path; ` +
`got ${JSON.stringify(source)}`);
}
if (!node_fs_1.default.existsSync(source) || !node_fs_1.default.statSync(source).isDirectory()) {
throw new Error(`ttsc: plugin "${plugin.name}" contributors[${index}].source must be an existing directory: ${source}`);
}
// Pre-flight check that the directory actually carries a buildable
// contributor package. Without this, an accidentally-empty directory
// (or a directory containing only `_test.go` files, which `go build`
// silently skips) reaches the synthesized blank-import step and Go's
// compile error surfaces with a scratch-tempdir path that doesn't
// name the contributor entry. Catching it here lets us name the
// entry the user actually authored.
if (!hasBuildableGoSource(source)) {
throw new Error(`ttsc: plugin "${plugin.name}" contributors[${index}].source must contain at least one non-test ".go" file: ${source}`);
}
out.push({ name, source: resolveRealPath(source) });
}
return out;
}
function mergeContributors(first, second) {
const out = [...(first ?? []), ...(second ?? [])];
return out.length === 0 ? undefined : out;
}
function isPluginStage(value) {
return value === "transform" || value === "check";
}
function hasBuildableGoSource(dir) {
// `go build` consumes `.go` files but silently ignores `_test.go`. A
// contributor whose source dir holds only test files would compile to
// an empty package and surface as an opaque scratch-tempdir error;
// require at least one production `.go` file so the validator can
// name the contributor entry instead.
let entries;
try {
entries = node_fs_1.default.readdirSync(dir);
}
catch {
return false;
}
return entries.some((name) => name.endsWith(".go") && !name.endsWith("_test.go"));
}
function resolvePluginRequest(specifier, projectRoot) {
if (node_path_1.default.isAbsolute(specifier)) {
return resolveRealPath(specifier);
}
if (isRelativePluginSpecifier(specifier)) {
return resolveRealPath(node_path_1.default.resolve(projectRoot, specifier));
}
// A package whose main `.` entry is a runtime barrel cannot double as a
// plugin descriptor entry: loading it during plugin bootstrap drags the
// runtime in (and, for a self-hosting transform like typia, deadlocks —
// loading the transform would have to build the runtime the transform
// emits). Such a package opts in with a `ttsc` export condition that points
// at a runtime-free descriptor; honour it here, scoped to plugin resolution.
const conditioned = resolvePluginExportCondition(specifier, projectRoot);
if (conditioned !== null) {
return conditioned;
}
return resolveRealPath(require.resolve(specifier, { paths: [projectRoot] }));
}
/**
* Condition names ttsc activates when resolving a plugin entry's package
* `exports`.
*/
const PLUGIN_EXPORT_CONDITIONS = [
"ttsc",
"node",
"require",
"default",
];
/**
* Resolve a bare plugin specifier under the dedicated `ttsc` export condition.
*
* A package whose `.` entry is a runtime barrel (e.g. `typia`, whose index
* re-exports the whole validator runtime) cannot serve as the plugin descriptor
* entry: loading it during plugin bootstrap pulls the runtime in and, for a
* self-hosting transform, forms a cycle. Such a package opts in by adding a
* `ttsc` condition to its `exports` that points at a runtime-free descriptor:
*
* "exports": { ".": { "ttsc": "./lib/transform.js", "default": "./lib/index.js"
* } }
*
* The condition is honoured ONLY here, scoped to plugin-entry resolution. A
* process-wide `--conditions=ttsc` would also redirect the package's normal
* `import`s to the descriptor and break its runtime, so it must not be used.
*
* Returns an absolute path when the package opts in, or `null` to fall back to
* the normal `require.resolve` — no `exports`, no `ttsc` branch for the
* requested subpath, or an unresolved/missing target — so a package that does
* not opt in resolves exactly as it did before.
*/
function resolvePluginExportCondition(specifier, baseDir) {
const split = splitPackageSpecifier(specifier);
if (split === null) {
return null;
}
const packageJson = resolveDependencyPackageJson(split.packageName, baseDir);
if (packageJson === undefined) {
return null;
}
const exportsField = readPackageManifest(packageJson)?.exports;
if (exportsField === undefined) {
return null;
}
const target = selectExportTarget(exportsField, split.subpath);
// Only take over when the package actually opts in with a `ttsc` condition
// for this subpath; otherwise defer so behaviour is unchanged for every
// package that does not.
if (target === undefined || !containsCondition(target, "ttsc")) {
return null;
}
const resolved = resolveConditionalTarget(target, PLUGIN_EXPORT_CONDITIONS);
if (resolved === null || !resolved.startsWith("./")) {
return null;
}
const file = node_path_1.default.resolve(node_path_1.default.dirname(packageJson), resolved);
return node_fs_1.default.existsSync(file) ? resolveRealPath(file) : null;
}
/**
* Split a bare specifier into its package name and the `.`-prefixed subpath it
* addresses (`"typia"` → `.`, `"typia/lib/transform"` → `./lib/transform`,
* `"@scope/pkg/sub"` → `./sub`). Returns `null` for a relative/empty specifier
* or a malformed scoped name.
*/
function splitPackageSpecifier(specifier) {
if (specifier.length === 0 || specifier.startsWith(".")) {
return null;
}
const segments = specifier.split("/");
const nameSegments = specifier.startsWith("@") ? 2 : 1;
if (segments.length < nameSegments) {
return null;
}
const rest = segments.slice(nameSegments).join("/");
return {
packageName: segments.slice(0, nameSegments).join("/"),
subpath: rest.length === 0 ? "." : `./${rest}`,
};
}
/**
* The `exports` entry addressing `subpath`, applying Node's rule that an
* `exports` value with no `.`-prefixed keys is sugar for the `.` target.
* Returns `undefined` when no entry addresses the subpath.
*/
function selectExportTarget(exportsField, subpath) {
if (typeof exportsField === "string" || Array.isArray(exportsField)) {
return subpath === "." ? exportsField : undefined;
}
if (typeof exportsField !== "object" || exportsField === null) {
return undefined;
}
const record = exportsField;
const isSubpathMap = Object.keys(record).some((key) => key === "." || key.startsWith("./"));
if (!isSubpathMap) {
// Conditions object: the whole value is the `.` target.
return subpath === "." ? exportsField : undefined;
}
if (Object.prototype.hasOwnProperty.call(record, subpath) &&
!subpath.includes("*") &&
!subpath.endsWith("/")) {
return record[subpath];
}
const patterns = Object.keys(record)
.filter((key) => exportPatternReplacement(key, subpath) !== undefined)
.sort(compareExportPatternKeys);
if (patterns.length === 0) {
return undefined;
}
const pattern = patterns[0];
return substituteExportTarget(record[pattern], exportPatternReplacement(pattern, subpath));
}
/** Capture the middle of one valid single-star exports key. */
function exportPatternReplacement(pattern, subpath) {
const star = pattern.indexOf("*");
if (!pattern.startsWith("./") ||
star === -1 ||
pattern.indexOf("*", star + 1) !== -1) {
return undefined;
}
const prefix = pattern.slice(0, star);
const suffix = pattern.slice(star + 1);
if (subpath.length < pattern.length ||
!subpath.startsWith(prefix) ||
!subpath.endsWith(suffix)) {
return undefined;
}
return subpath.slice(prefix.length, subpath.length - suffix.length);
}
/** Node exports patterns rank longer prefixes, then longer full keys, first. */
function compareExportPatternKeys(left, right) {
const leftPrefix = left.indexOf("*");
const rightPrefix = right.indexOf("*");
if (leftPrefix !== rightPrefix) {
return rightPrefix - leftPrefix;
}
return right.length - left.length;
}
/** Substitute the selected pattern capture into every string target branch. */
function substituteExportTarget(target, replacement) {
if (typeof target === "string") {
return target.split("*").join(replacement);
}
if (Array.isArray(target)) {
return target.map((entry) => substituteExportTarget(entry, replacement));
}
if (typeof target !== "object" || target === null) {
return target;
}
return Object.fromEntries(Object.entries(target).map(([condition, value]) => [
condition,
substituteExportTarget(value, replacement),
]));
}
/** True when condition key `condition` appears anywhere in a (nested) target. */
function containsCondition(target, condition) {
if (Array.isArray(target)) {
return target.some((entry) => containsCondition(entry, condition));
}
if (typeof target !== "object" || target === null) {
return false;
}
return Object.entries(target).some(([key, value]) => key === condition || containsCondition(value, condition));
}
/**
* Resolve a (possibly conditional) export target to a relative file string,
* honouring `conditions` — a string is the target, an array is a fallback list,
* an object picks the first key in the active condition set (package key order
* wins, as Node does), and an explicit `null` blocks the target.
*/
function resolveConditionalTarget(target, conditions) {
if (typeof target === "string") {
return target;
}
if (target === null || target === undefined) {
return null;
}
if (Array.isArray(target)) {
for (const entry of target) {
const resolved = resolveConditionalTarget(entry, conditions);
if (resolved !== null) {
return resolved;
}
}
return null;
}
if (typeof target !== "object") {
return null;
}
const active = new Set(conditions);
for (const [key, value] of Object.entries(target)) {
if (active.has(key)) {
const resolved = resolveConditionalTarget(value, conditions);
if (resolved !== null) {
return resolved;
}
}
}
return null;
}
function resolveRealPath(location) {
try {
return node_fs_1.default.realpathSync(location);
}
catch {
return location;
}
}
function isRelativePluginSpecifier(specifier) {
return (specifier === "." ||
specifier === ".." ||
specifier.startsWith("./") ||
specifier.startsWith("../") ||
specifier.startsWith(".\\") ||
specifier.startsWith("..\\"));
}
let cachedTtscVersion = null;
function readTtscVersion() {
if (cachedTtscVersion !== null) {
return cachedTtscVersion;
}
try {
const file = node_path_1.default.join(ttscPackageRoot(), "package.json");
const pkg = JSON.parse(node_fs_1.default.readFileSync(file, "utf8"));
cachedTtscVersion = pkg.version ?? "0.0.0";
}
catch {
cachedTtscVersion = "0.0.0";
}
return cachedTtscVersion;
}
function ttscPackageRoot() {
return node_path_1.default.resolve(__dirname, "..", "..", "..");
}
function readTsgoVersion(projectRoot) {
try {
const projectRequire = (0, node_module_1.createRequire)(node_path_1.default.join(projectRoot, "package.json"));
const pkgPath = projectRequire.resolve("typescript/package.json");
const pkg = JSON.parse(node_fs_1.default.readFileSync(pkgPath, "utf8"));
return pkg.version ?? "unknown";
}
catch {
return "unknown";
}
}
/**
* Remove an evaluation temp directory without letting cleanup replace a result.
*
* This runs from a `finally`, so a throw here would surface instead of the
* evaluation's own outcome — and on Windows a grandchild that inherited a
* handle, or a scanner holding the file, can make removal fail. Leaving bytes
* in the system temp directory is by far the lesser outcome.
*/
function removeEvaluationTempDir(directory) {
try {
node_fs_1.default.rmSync(directory, { force: true, recursive: true });
}
catch {
// Best effort.
}
}
//# sourceMappingURL=loadProjectPlugins.js.map