Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/declaration-build-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": minor
---

Declaration-build failures now report as the dedicated `AB4716` code instead of the `AB5000` catch-all, and carry the TypeScript diagnostics that caused them. When the `lib` dts pass aborts, the package build replays declaration emit over the same synthesized tsconfig using the consumer project's own `typescript`, so every underlying error reaches human and `--json` CLI output with its file, `(line,column)`, `TS` code, and message — plus a recovery hint that emit-only errors such as `TS4023` are invisible to `tsc --noEmit`.
33 changes: 32 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ gate a build, a validation, or a dev rebuild.
| `AB4500` | Registered config extensions (strict finite JSON). |
| `AB46xx` | Assets and the generated-runtime floor. |
| `AB470x` | Package build `bin` configuration (`AB4706`: artifact output overlaps `dist`). |
| `AB471x` | Package build `lib` configuration. |
| `AB471x` | Package build `lib` configuration (`AB4710`–`AB4715`) and declaration generation (`AB4716`; see below). |
| `AB472x` | The `tools.rsbuild` / `tools.rspack` escape hatch. |
| `AB473x` | Migration nudges (informational; see below). |
| `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). |
Expand All @@ -30,6 +30,37 @@ gate a build, a validation, or a dev rebuild.
| `AB8xxx` | Development server configuration. |
| `AB9xxx` | Eval selection, harnesses, and persisted runs. |

## Declaration generation (`AB4716`)

A `lib` entry with `dts` enabled compiles its source directory as its own
TypeScript program. When that declaration emit fails, the bundler aborts with
one prose line naming only its own environment, so the framework replays
declaration emit over the same synthesized project (the consumer's own
`typescript`, the same tsconfig, `--declaration --emitDeclarationOnly`) and
reports **one `AB4716` error per recovered TypeScript diagnostic**, each
carrying the file, the `(line,column)` position, the `TS` code, and the
compiler's message, plus a `sourcePath`:

```text
[AB4716] Declaration generation for lib entry "index" failed:
src/operations/audible.ts(79,14): TS4023: Exported variable 'audibleOperations'
has or is using name 'CliCommandDefinition' from external module "…" but cannot be named.
```

When no diagnostic can be recovered — the project has no resolvable
`typescript`, or the replay passes because the failure was elsewhere in
declaration generation — the failure still reports as a single `AB4716`
carrying the bundler's own message. Declaration failures never fall through
to the `AB5000` catch-all, whose dev-lock meaning previously misdirected
triage.

The recovery hint names the trap these failures share: declaration-emit
errors such as `TS4023` (an exported value whose inferred type names a type
its module does not export) are invisible to `tsc --noEmit`, so a green
`typecheck` script proves nothing about them. Reproduce them with
`tsc --declaration --emitDeclarationOnly` over the lib entry source
directory.

## Migration nudges (`AB4730`–`AB4735`)

The entry conventions and the framework-owned stdio lifecycle shell (RFC #50)
Expand Down
183 changes: 183 additions & 0 deletions packages/agent-bundle/src/build/declaration-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { execFile as executeFile } from 'node:child_process';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { promisify } from 'node:util';

import type { Diagnostic } from '../core/diagnostics.ts';

const execFile = promisify(executeFile);

/**
* Declaration generation rides rsbuild-plugin-dts, which aborts a failed pass
* with one prose line naming only the Rslib environment — the TypeScript
* diagnostics that actually failed the emit stay inside the forked worker.
* This module recovers them by replaying declaration emit over the very
* tsconfig the failed build used, so the CLI reports the file, line, and TS
* code instead of a catch-all.
*
* `AB4716` joins the `AB471x` package-build `lib` family (see
* `docs/diagnostics.md`); it is never `AB5000`, whose dev-lock meaning
* misdirected triage of exactly this failure.
*/
export const declarationBuildCode = 'AB4716';

const emitOnlyRecovery = 'Fix the reported TypeScript declaration errors and rebuild. '
+ 'Declaration-emit errors such as TS4023 (an exported value naming a type its module does not export) '
+ 'never appear under `tsc --noEmit`; replay them with `tsc --declaration --emitDeclarationOnly` '
+ 'over the lib entry source directory.';

export interface TypeScriptEmitLocation {
readonly column: number;
/** As printed by `tsc`: relative to the project root, or absolute. */
readonly file: string;
readonly line: number;
}

export interface TypeScriptEmitDiagnostic {
/** Absent for whole-program diagnostics such as option errors. */
readonly location?: TypeScriptEmitLocation;
readonly message: string;
/** The TypeScript diagnostic code, e.g. `TS4023`. */
readonly tsCode: string;
}

const locatedDiagnostic =
/^(?<file>[^(]+)\((?<line>\d+),(?<column>\d+)\): (?:error|warning) (?<tsCode>TS\d+): (?<message>.+)$/u;
const programWideDiagnostic = /^(?:error|warning) (?<tsCode>TS\d+): (?<message>.+)$/u;

/**
* Parses the `--pretty false` diagnostic format, which is stable across
* TypeScript 5 and the TypeScript 7 native compiler. Continuation and
* related-information lines are indented and carry no code, so they are
* skipped rather than misparsed.
*/
export const parseTypeScriptDiagnostics = (output: string): readonly TypeScriptEmitDiagnostic[] => {
const diagnostics: TypeScriptEmitDiagnostic[] = [];
for (const line of output.split(/\r?\n/u)) {
const located = locatedDiagnostic.exec(line)?.groups;
if (located !== undefined) {
diagnostics.push({
location: {
column: Number(located.column),
file: located.file!,
line: Number(located.line),
},
message: located.message!.trim(),
tsCode: located.tsCode!,
});
continue;
}
const programWide = programWideDiagnostic.exec(line)?.groups;
if (programWide !== undefined) {
diagnostics.push({ message: programWide.message!.trim(), tsCode: programWide.tsCode! });
}
}
return Object.freeze(diagnostics);
};

/**
* The consumer project's own compiler, resolved exactly like the dts build
* resolves it: a project pinning TypeScript 5 must never be replayed through
* a different copy hoisted somewhere above it.
*/
const typeScriptCli = (projectRoot: string): string | undefined => {
let manifest: string;
try {
manifest = createRequire(join(projectRoot, 'package.json')).resolve('typescript/package.json');
} catch {
return undefined;
}
const cli = join(dirname(manifest), 'lib', 'tsc.js');
return existsSync(cli) ? cli : undefined;
};

const processOutput = (error: unknown): string => {
const streams = error as { readonly stderr?: unknown; readonly stdout?: unknown };
return [streams.stdout, streams.stderr]
.filter((stream): stream is string => typeof stream === 'string')
.join('\n');
};

/**
* Replays `tsc --declaration --emitDeclarationOnly` over the synthesized dts
* project. The overrides pin emit on regardless of what the consumer
* tsconfig this project extends declares (`noEmit`, `declarationDir`, and
* incremental build info all belong to the consumer's own type check), and
* the declarations land in a throwaway sibling of the synthesized project so
* the replay never touches the package output or the project tree.
*
* A replay that cannot run (no resolvable compiler) or that passes returns no
* diagnostics; the caller still reports the failure, just without detail.
*/
export const replayDeclarationEmit = async (options: {
readonly projectRoot: string;
readonly tsconfigPath: string;
}): Promise<readonly TypeScriptEmitDiagnostic[]> => {
const cli = typeScriptCli(options.projectRoot);
if (cli === undefined) return Object.freeze([]);
const outDir = join(dirname(options.tsconfigPath), 'declaration-replay');
try {
await execFile(process.execPath, [
cli,
'--project', options.tsconfigPath,
'--declaration',
'--declarationDir', outDir,
'--emitDeclarationOnly',
'--incremental', 'false',
'--noEmit', 'false',
'--outDir', outDir,
'--pretty', 'false',
], { cwd: options.projectRoot, maxBuffer: 32 * 1024 * 1024 });
return Object.freeze([]);
} catch (error) {
return parseTypeScriptDiagnostics(processOutput(error));
}
};

/** Project-relative when the file lives inside the project, absolute otherwise. */
const formatLocation = (projectRoot: string, location: TypeScriptEmitLocation): {
readonly display: string;
readonly sourcePath: string;
} => {
const sourcePath = isAbsolute(location.file) ? location.file : resolve(projectRoot, location.file);
const relativePath = relative(projectRoot, sourcePath).replaceAll('\\', '/');
const shown = relativePath.length === 0 || relativePath.startsWith('..') ? sourcePath : relativePath;
return { display: `${shown}(${location.line},${location.column}): `, sourcePath };
};

/**
* One `AB4716` error per recovered TypeScript diagnostic, each carrying the
* file, position, and TS code so `--json` consumers and the terminal see the
* same detail the manual `tsc --emitDeclarationOnly` replay produced. When
* nothing could be recovered the failure still reports under `AB4716` with
* the bundler's own message, never the `AB5000` catch-all.
*/
export const declarationBuildDiagnostics = (options: {
readonly entryName: string;
readonly failure: string;
readonly projectRoot: string;
readonly typeScriptDiagnostics: readonly TypeScriptEmitDiagnostic[];
}): readonly Diagnostic[] => {
const prefix = `Declaration generation for lib entry ${JSON.stringify(options.entryName)} failed`;
if (options.typeScriptDiagnostics.length === 0) {
return Object.freeze([{
code: declarationBuildCode,
message: `${prefix}: ${options.failure}`,
recovery: emitOnlyRecovery,
severity: 'error' as const,
}]);
}
return Object.freeze(options.typeScriptDiagnostics.map((diagnostic): Diagnostic => {
const location = diagnostic.location === undefined
? undefined
: formatLocation(options.projectRoot, diagnostic.location);
return {
code: declarationBuildCode,
message: `${prefix}: ${location?.display ?? ''}${diagnostic.tsCode}: ${diagnostic.message}`,
recovery: emitOnlyRecovery,
severity: 'error' as const,
...(location === undefined ? {} : { sourcePath: location.sourcePath }),
};
}));
};
39 changes: 36 additions & 3 deletions packages/agent-bundle/src/build/package-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { basename, dirname, join, relative, resolve } from 'node:path';

import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts';
import { DiagnosticError } from '../core/diagnostics.ts';
import { assertInside } from '../core/paths.ts';
import { declarationBuildDiagnostics, replayDeclarationEmit } from './declaration-diagnostics.ts';
import { listArtifactFiles, publishArtifact, resolveArtifactDestination } from './emit.ts';
import { scanEntryExports } from './entry-exports.ts';
import { runtimeIgnoredRoot } from './entries.ts';
Expand All @@ -14,7 +16,8 @@ import {
generatedExecutableEntrySource,
generatedRenderedRouteWorkerSource,
} from './entry-shell.ts';
import { buildWithRslib, type RslibEntry } from './rslib.ts';
import type { BundledOutputEvidence } from './provenance.ts';
import { buildWithRslib, isDeclarationGenerationFailure, type RslibEntry } from './rslib.ts';

/**
* The framework-owned npm package build: `bin` entries become self-executing
Expand Down Expand Up @@ -186,6 +189,34 @@ export const planPackageEntries = async (
return Object.freeze(entries);
};

/**
* Runs the synthesized package build, translating a declaration-generation
* abort into `AB4716` errors that name the underlying TypeScript diagnostics.
* The bundler reports declaration failures as one prose line, so the detail is
* recovered by replaying declaration emit over the same synthesized project
* the failed pass used — which is exactly the manual
* `tsc --declaration --emitDeclarationOnly` triage this removes.
*/
const buildPackageEntries = async (
options: Parameters<typeof buildWithRslib>[0],
declaration: { readonly entryName: string; readonly tsconfigPath: string } | undefined,
): Promise<readonly BundledOutputEvidence[]> => {
try {
return await buildWithRslib(options);
} catch (error) {
if (declaration === undefined || !isDeclarationGenerationFailure(error)) throw error;
throw new DiagnosticError(declarationBuildDiagnostics({
entryName: declaration.entryName,
failure: error instanceof Error ? error.message : String(error),
projectRoot: options.cwd,
typeScriptDiagnostics: await replayDeclarationEmit({
projectRoot: options.cwd,
tsconfigPath: declaration.tsconfigPath,
}),
}));
}
};

/** Maps one emitted `.d.ts` back to the authored module it declares. */
const declarationSource = (sourceDir: string, declarationPath: string): string | undefined => {
const stem = declarationPath.slice(0, -'.d.ts'.length);
Expand Down Expand Up @@ -222,14 +253,16 @@ export const buildPackageOutputs = async (options: {
const cliRuntimeShell = entries.some((entry) => entry.aliases?.[cliEntryRuntimeSpecifier] !== undefined)
? cliEntryRuntimePath()
: undefined;
const evidence = await buildWithRslib({
const evidence = await buildPackageEntries({
cwd: projectRoot,
entries,
...(cliRuntimeShell === undefined ? {} : { ignoredSourcePaths: [runtimeIgnoredRoot(cliRuntimeShell)] }),
logLevel: 'error',
outputRoot: stageRoot,
...(options.tools === undefined ? {} : { tools: options.tools }),
});
}, dtsTsconfig === undefined || packageBuild.lib === undefined
? undefined
: { entryName: packageBuild.lib.name, tsconfigPath: dtsTsconfig.path });
const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs]));
await Promise.all(entries
.filter((entry) => entry.executable)
Expand Down
10 changes: 10 additions & 0 deletions packages/agent-bundle/src/build/rslib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ const asRslibRspackHatch = (

const entryLibId = (entry: Pick<RslibEntry, 'name'>): string => `agent-bundle-${entry.name}`;

/**
* rsbuild-plugin-dts aborts a failed declaration pass with a stackless prose
* Error naming only the Rslib environment ("Error occurred in
* agent-bundle-index declaration files generation.") — there is no structured
* signal to key on, so the phrase is the contract. A build failure this does
* not match is not a declaration failure and keeps its own reporting.
*/
export const isDeclarationGenerationFailure = (error: unknown): boolean =>
error instanceof Error && /declaration files/iu.test(error.message);

// join (not resolve) so a tokenized output root (`<output>/<target>`) stays
// a token instead of resolving against the cwd.
const generatedEntryModulePath = (outputRoot: string, entry: RslibEntry): string =>
Expand Down
48 changes: 48 additions & 0 deletions packages/agent-bundle/tests/package-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,54 @@ describe('framework-owned package build', () => {
await expect(stat(join(root, 'dist', 'answer.test.d.ts'))).rejects.toMatchObject({ code: 'ENOENT' });
}, 120_000);

it('reports a failed declaration build as AB4716 carrying the underlying TypeScript diagnostics', async () => {
const root = await fixtureRoot({
...conventionFixture(),
// An exported factory whose inferred declaration type must name a type
// its own module does not export. The failure is emit-only: `--noEmit`
// type checking stays clean, so only declaration generation catches it.
'src/cli-command.ts': [
'interface CliCommandDefinition { readonly name: string }',
'',
'export const defineCliCommand = (name: string): CliCommandDefinition => ({ name });',
'',
].join('\n'),
'src/index.ts': [
"import { defineCliCommand } from './cli-command';",
'',
"export const audibleOperations = () => ({ list: defineCliCommand('list') });",
'',
].join('\n'),
});
await installTypescriptToolchain(root);

const stderr: string[] = [];
const exitCode = await runCli(
['build', '--root', root, '--output', 'artifact'],
{ stderr: { write: (chunk: string) => stderr.push(chunk) }, stdout: { write: () => undefined } },
);
expect(exitCode).toBe(1);

const diagnostics = JSON.parse(stderr.join('')) as readonly {
code: string;
message: string;
recovery?: string;
sourcePath?: string;
}[];
// The dedicated declaration code, never the AB5000 catch-all that
// collides with the dev-lock meaning.
expect(diagnostics.length).toBeGreaterThan(0);
expect([...new Set(diagnostics.map((diagnostic) => diagnostic.code))]).toEqual(['AB4716']);

const emitError = diagnostics.find((diagnostic) => diagnostic.message.includes('TS4023'));
expect(emitError).toBeDefined();
expect(emitError!.message).toContain('src/index.ts(3,14)');
expect(emitError!.message).toContain("Exported variable 'audibleOperations'");
expect(emitError!.message).toContain('CliCommandDefinition');
expect(emitError!.sourcePath).toBe(join(root, 'src', 'index.ts'));
expect(emitError!.recovery).toContain('--noEmit');
}, 120_000);

it('rejects artifact outputs that overlap the package output directory', async () => {
const root = await fixtureRoot(conventionFixture());
await expect(build({ output: 'dist', packageOutputs: true, root })).rejects.toThrow(/overlaps the package build output/u);
Expand Down
Loading