From ef5e125039ddc1e7d7e87fa2ce44d5cdebda466c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 02:39:43 +0000 Subject: [PATCH] fix(build): report declaration-build failures as AB4716 with TypeScript diagnostics (#176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declaration generation rides rsbuild-plugin-dts, which aborts with one prose line naming only its Rslib environment. The CLI wrapped that in the AB5000 catch-all, whose dev-lock meaning misdirected triage of #174's emit-only TS4023 regression, and the five underlying diagnostics were lost — recovering them needed a manual `tsc --declaration --emitDeclarationOnly` replay. The package build now performs that replay itself: on a declaration abort it re-runs declaration emit over the same synthesized dts tsconfig, through the `typescript` resolved from the consumer project, into a throwaway directory, and reports one AB4716 error per recovered diagnostic with the file, (line,column), TS code, message, and sourcePath. AB4716 joins the AB471x package-build `lib` family. Every diagnostic carries a recovery hint naming the trap: emit-only errors such as TS4023 are invisible to `tsc --noEmit`. --- .changeset/declaration-build-diagnostics.md | 5 + docs/diagnostics.md | 33 +++- .../src/build/declaration-diagnostics.ts | 183 ++++++++++++++++++ .../agent-bundle/src/build/package-build.ts | 39 +++- packages/agent-bundle/src/build/rslib.ts | 10 + .../agent-bundle/tests/package-build.test.ts | 48 +++++ 6 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 .changeset/declaration-build-diagnostics.md create mode 100644 packages/agent-bundle/src/build/declaration-diagnostics.ts diff --git a/.changeset/declaration-build-diagnostics.md b/.changeset/declaration-build-diagnostics.md new file mode 100644 index 000000000..1f89caabf --- /dev/null +++ b/.changeset/declaration-build-diagnostics.md @@ -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`. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index bd3da4ffc..098b8b195 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -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). | @@ -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) diff --git a/packages/agent-bundle/src/build/declaration-diagnostics.ts b/packages/agent-bundle/src/build/declaration-diagnostics.ts new file mode 100644 index 000000000..915070d2c --- /dev/null +++ b/packages/agent-bundle/src/build/declaration-diagnostics.ts @@ -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 = + /^(?[^(]+)\((?\d+),(?\d+)\): (?:error|warning) (?TS\d+): (?.+)$/u; +const programWideDiagnostic = /^(?:error|warning) (?TS\d+): (?.+)$/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 => { + 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 }), + }; + })); +}; diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 39e4f7f97..a1c3a9f8a 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -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'; @@ -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 @@ -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[0], + declaration: { readonly entryName: string; readonly tsconfigPath: string } | undefined, +): Promise => { + 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); @@ -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) diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index bbfdb5143..1f9e7184b 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -100,6 +100,16 @@ const asRslibRspackHatch = ( const entryLibId = (entry: Pick): 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 (`/`) stays // a token instead of resolving against the cwd. const generatedEntryModulePath = (outputRoot: string, entry: RslibEntry): string => diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 55d7c7f8c..7fe4263a9 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -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);