Skip to content

Commit cee25b8

Browse files
Merge pull request #265 from ScriptedAlchemy/fix/176-declaration-diagnostics
fix(build): report declaration-build failures as AB4716 with the underlying TypeScript diagnostics
2 parents bb9e82c + ef5e125 commit cee25b8

6 files changed

Lines changed: 314 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": minor
3+
---
4+
5+
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`.

‎docs/diagnostics.md‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ gate a build, a validation, or a dev rebuild.
1919
| `AB4500` | Registered config extensions (strict finite JSON). |
2020
| `AB46xx` | Assets and the generated-runtime floor. |
2121
| `AB470x` | Package build `bin` configuration (`AB4706`: artifact output overlaps `dist`). |
22-
| `AB471x` | Package build `lib` configuration. |
22+
| `AB471x` | Package build `lib` configuration (`AB4710`–`AB4715`) and declaration generation (`AB4716`; see below). |
2323
| `AB472x` | The `tools.rsbuild` / `tools.rspack` escape hatch. |
2424
| `AB473x` | Migration nudges (informational; see below). |
2525
| `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). |
@@ -30,6 +30,37 @@ gate a build, a validation, or a dev rebuild.
3030
| `AB8xxx` | Development server configuration. |
3131
| `AB9xxx` | Eval selection, harnesses, and persisted runs. |
3232

33+
## Declaration generation (`AB4716`)
34+
35+
A `lib` entry with `dts` enabled compiles its source directory as its own
36+
TypeScript program. When that declaration emit fails, the bundler aborts with
37+
one prose line naming only its own environment, so the framework replays
38+
declaration emit over the same synthesized project (the consumer's own
39+
`typescript`, the same tsconfig, `--declaration --emitDeclarationOnly`) and
40+
reports **one `AB4716` error per recovered TypeScript diagnostic**, each
41+
carrying the file, the `(line,column)` position, the `TS` code, and the
42+
compiler's message, plus a `sourcePath`:
43+
44+
```text
45+
[AB4716] Declaration generation for lib entry "index" failed:
46+
src/operations/audible.ts(79,14): TS4023: Exported variable 'audibleOperations'
47+
has or is using name 'CliCommandDefinition' from external module "…" but cannot be named.
48+
```
49+
50+
When no diagnostic can be recovered — the project has no resolvable
51+
`typescript`, or the replay passes because the failure was elsewhere in
52+
declaration generation — the failure still reports as a single `AB4716`
53+
carrying the bundler's own message. Declaration failures never fall through
54+
to the `AB5000` catch-all, whose dev-lock meaning previously misdirected
55+
triage.
56+
57+
The recovery hint names the trap these failures share: declaration-emit
58+
errors such as `TS4023` (an exported value whose inferred type names a type
59+
its module does not export) are invisible to `tsc --noEmit`, so a green
60+
`typecheck` script proves nothing about them. Reproduce them with
61+
`tsc --declaration --emitDeclarationOnly` over the lib entry source
62+
directory.
63+
3364
## Migration nudges (`AB4730`–`AB4735`)
3465

3566
The entry conventions and the framework-owned stdio lifecycle shell (RFC #50)
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { execFile as executeFile } from 'node:child_process';
2+
import { existsSync } from 'node:fs';
3+
import { createRequire } from 'node:module';
4+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5+
import { promisify } from 'node:util';
6+
7+
import type { Diagnostic } from '../core/diagnostics.ts';
8+
9+
const execFile = promisify(executeFile);
10+
11+
/**
12+
* Declaration generation rides rsbuild-plugin-dts, which aborts a failed pass
13+
* with one prose line naming only the Rslib environment — the TypeScript
14+
* diagnostics that actually failed the emit stay inside the forked worker.
15+
* This module recovers them by replaying declaration emit over the very
16+
* tsconfig the failed build used, so the CLI reports the file, line, and TS
17+
* code instead of a catch-all.
18+
*
19+
* `AB4716` joins the `AB471x` package-build `lib` family (see
20+
* `docs/diagnostics.md`); it is never `AB5000`, whose dev-lock meaning
21+
* misdirected triage of exactly this failure.
22+
*/
23+
export const declarationBuildCode = 'AB4716';
24+
25+
const emitOnlyRecovery = 'Fix the reported TypeScript declaration errors and rebuild. '
26+
+ 'Declaration-emit errors such as TS4023 (an exported value naming a type its module does not export) '
27+
+ 'never appear under `tsc --noEmit`; replay them with `tsc --declaration --emitDeclarationOnly` '
28+
+ 'over the lib entry source directory.';
29+
30+
export interface TypeScriptEmitLocation {
31+
readonly column: number;
32+
/** As printed by `tsc`: relative to the project root, or absolute. */
33+
readonly file: string;
34+
readonly line: number;
35+
}
36+
37+
export interface TypeScriptEmitDiagnostic {
38+
/** Absent for whole-program diagnostics such as option errors. */
39+
readonly location?: TypeScriptEmitLocation;
40+
readonly message: string;
41+
/** The TypeScript diagnostic code, e.g. `TS4023`. */
42+
readonly tsCode: string;
43+
}
44+
45+
const locatedDiagnostic =
46+
/^(?<file>[^(]+)\((?<line>\d+),(?<column>\d+)\): (?:error|warning) (?<tsCode>TS\d+): (?<message>.+)$/u;
47+
const programWideDiagnostic = /^(?:error|warning) (?<tsCode>TS\d+): (?<message>.+)$/u;
48+
49+
/**
50+
* Parses the `--pretty false` diagnostic format, which is stable across
51+
* TypeScript 5 and the TypeScript 7 native compiler. Continuation and
52+
* related-information lines are indented and carry no code, so they are
53+
* skipped rather than misparsed.
54+
*/
55+
export const parseTypeScriptDiagnostics = (output: string): readonly TypeScriptEmitDiagnostic[] => {
56+
const diagnostics: TypeScriptEmitDiagnostic[] = [];
57+
for (const line of output.split(/\r?\n/u)) {
58+
const located = locatedDiagnostic.exec(line)?.groups;
59+
if (located !== undefined) {
60+
diagnostics.push({
61+
location: {
62+
column: Number(located.column),
63+
file: located.file!,
64+
line: Number(located.line),
65+
},
66+
message: located.message!.trim(),
67+
tsCode: located.tsCode!,
68+
});
69+
continue;
70+
}
71+
const programWide = programWideDiagnostic.exec(line)?.groups;
72+
if (programWide !== undefined) {
73+
diagnostics.push({ message: programWide.message!.trim(), tsCode: programWide.tsCode! });
74+
}
75+
}
76+
return Object.freeze(diagnostics);
77+
};
78+
79+
/**
80+
* The consumer project's own compiler, resolved exactly like the dts build
81+
* resolves it: a project pinning TypeScript 5 must never be replayed through
82+
* a different copy hoisted somewhere above it.
83+
*/
84+
const typeScriptCli = (projectRoot: string): string | undefined => {
85+
let manifest: string;
86+
try {
87+
manifest = createRequire(join(projectRoot, 'package.json')).resolve('typescript/package.json');
88+
} catch {
89+
return undefined;
90+
}
91+
const cli = join(dirname(manifest), 'lib', 'tsc.js');
92+
return existsSync(cli) ? cli : undefined;
93+
};
94+
95+
const processOutput = (error: unknown): string => {
96+
const streams = error as { readonly stderr?: unknown; readonly stdout?: unknown };
97+
return [streams.stdout, streams.stderr]
98+
.filter((stream): stream is string => typeof stream === 'string')
99+
.join('\n');
100+
};
101+
102+
/**
103+
* Replays `tsc --declaration --emitDeclarationOnly` over the synthesized dts
104+
* project. The overrides pin emit on regardless of what the consumer
105+
* tsconfig this project extends declares (`noEmit`, `declarationDir`, and
106+
* incremental build info all belong to the consumer's own type check), and
107+
* the declarations land in a throwaway sibling of the synthesized project so
108+
* the replay never touches the package output or the project tree.
109+
*
110+
* A replay that cannot run (no resolvable compiler) or that passes returns no
111+
* diagnostics; the caller still reports the failure, just without detail.
112+
*/
113+
export const replayDeclarationEmit = async (options: {
114+
readonly projectRoot: string;
115+
readonly tsconfigPath: string;
116+
}): Promise<readonly TypeScriptEmitDiagnostic[]> => {
117+
const cli = typeScriptCli(options.projectRoot);
118+
if (cli === undefined) return Object.freeze([]);
119+
const outDir = join(dirname(options.tsconfigPath), 'declaration-replay');
120+
try {
121+
await execFile(process.execPath, [
122+
cli,
123+
'--project', options.tsconfigPath,
124+
'--declaration',
125+
'--declarationDir', outDir,
126+
'--emitDeclarationOnly',
127+
'--incremental', 'false',
128+
'--noEmit', 'false',
129+
'--outDir', outDir,
130+
'--pretty', 'false',
131+
], { cwd: options.projectRoot, maxBuffer: 32 * 1024 * 1024 });
132+
return Object.freeze([]);
133+
} catch (error) {
134+
return parseTypeScriptDiagnostics(processOutput(error));
135+
}
136+
};
137+
138+
/** Project-relative when the file lives inside the project, absolute otherwise. */
139+
const formatLocation = (projectRoot: string, location: TypeScriptEmitLocation): {
140+
readonly display: string;
141+
readonly sourcePath: string;
142+
} => {
143+
const sourcePath = isAbsolute(location.file) ? location.file : resolve(projectRoot, location.file);
144+
const relativePath = relative(projectRoot, sourcePath).replaceAll('\\', '/');
145+
const shown = relativePath.length === 0 || relativePath.startsWith('..') ? sourcePath : relativePath;
146+
return { display: `${shown}(${location.line},${location.column}): `, sourcePath };
147+
};
148+
149+
/**
150+
* One `AB4716` error per recovered TypeScript diagnostic, each carrying the
151+
* file, position, and TS code so `--json` consumers and the terminal see the
152+
* same detail the manual `tsc --emitDeclarationOnly` replay produced. When
153+
* nothing could be recovered the failure still reports under `AB4716` with
154+
* the bundler's own message, never the `AB5000` catch-all.
155+
*/
156+
export const declarationBuildDiagnostics = (options: {
157+
readonly entryName: string;
158+
readonly failure: string;
159+
readonly projectRoot: string;
160+
readonly typeScriptDiagnostics: readonly TypeScriptEmitDiagnostic[];
161+
}): readonly Diagnostic[] => {
162+
const prefix = `Declaration generation for lib entry ${JSON.stringify(options.entryName)} failed`;
163+
if (options.typeScriptDiagnostics.length === 0) {
164+
return Object.freeze([{
165+
code: declarationBuildCode,
166+
message: `${prefix}: ${options.failure}`,
167+
recovery: emitOnlyRecovery,
168+
severity: 'error' as const,
169+
}]);
170+
}
171+
return Object.freeze(options.typeScriptDiagnostics.map((diagnostic): Diagnostic => {
172+
const location = diagnostic.location === undefined
173+
? undefined
174+
: formatLocation(options.projectRoot, diagnostic.location);
175+
return {
176+
code: declarationBuildCode,
177+
message: `${prefix}: ${location?.display ?? ''}${diagnostic.tsCode}: ${diagnostic.message}`,
178+
recovery: emitOnlyRecovery,
179+
severity: 'error' as const,
180+
...(location === undefined ? {} : { sourcePath: location.sourcePath }),
181+
};
182+
}));
183+
};

‎packages/agent-bundle/src/build/package-build.ts‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
33
import { basename, dirname, join, relative, resolve } from 'node:path';
44

55
import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts';
6+
import { DiagnosticError } from '../core/diagnostics.ts';
67
import { assertInside } from '../core/paths.ts';
8+
import { declarationBuildDiagnostics, replayDeclarationEmit } from './declaration-diagnostics.ts';
79
import { listArtifactFiles, publishArtifact, resolveArtifactDestination } from './emit.ts';
810
import { scanEntryExports } from './entry-exports.ts';
911
import { runtimeIgnoredRoot } from './entries.ts';
@@ -14,7 +16,8 @@ import {
1416
generatedExecutableEntrySource,
1517
generatedRenderedRouteWorkerSource,
1618
} from './entry-shell.ts';
17-
import { buildWithRslib, type RslibEntry } from './rslib.ts';
19+
import type { BundledOutputEvidence } from './provenance.ts';
20+
import { buildWithRslib, isDeclarationGenerationFailure, type RslibEntry } from './rslib.ts';
1821

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

192+
/**
193+
* Runs the synthesized package build, translating a declaration-generation
194+
* abort into `AB4716` errors that name the underlying TypeScript diagnostics.
195+
* The bundler reports declaration failures as one prose line, so the detail is
196+
* recovered by replaying declaration emit over the same synthesized project
197+
* the failed pass used — which is exactly the manual
198+
* `tsc --declaration --emitDeclarationOnly` triage this removes.
199+
*/
200+
const buildPackageEntries = async (
201+
options: Parameters<typeof buildWithRslib>[0],
202+
declaration: { readonly entryName: string; readonly tsconfigPath: string } | undefined,
203+
): Promise<readonly BundledOutputEvidence[]> => {
204+
try {
205+
return await buildWithRslib(options);
206+
} catch (error) {
207+
if (declaration === undefined || !isDeclarationGenerationFailure(error)) throw error;
208+
throw new DiagnosticError(declarationBuildDiagnostics({
209+
entryName: declaration.entryName,
210+
failure: error instanceof Error ? error.message : String(error),
211+
projectRoot: options.cwd,
212+
typeScriptDiagnostics: await replayDeclarationEmit({
213+
projectRoot: options.cwd,
214+
tsconfigPath: declaration.tsconfigPath,
215+
}),
216+
}));
217+
}
218+
};
219+
189220
/** Maps one emitted `.d.ts` back to the authored module it declares. */
190221
const declarationSource = (sourceDir: string, declarationPath: string): string | undefined => {
191222
const stem = declarationPath.slice(0, -'.d.ts'.length);
@@ -222,14 +253,16 @@ export const buildPackageOutputs = async (options: {
222253
const cliRuntimeShell = entries.some((entry) => entry.aliases?.[cliEntryRuntimeSpecifier] !== undefined)
223254
? cliEntryRuntimePath()
224255
: undefined;
225-
const evidence = await buildWithRslib({
256+
const evidence = await buildPackageEntries({
226257
cwd: projectRoot,
227258
entries,
228259
...(cliRuntimeShell === undefined ? {} : { ignoredSourcePaths: [runtimeIgnoredRoot(cliRuntimeShell)] }),
229260
logLevel: 'error',
230261
outputRoot: stageRoot,
231262
...(options.tools === undefined ? {} : { tools: options.tools }),
232-
});
263+
}, dtsTsconfig === undefined || packageBuild.lib === undefined
264+
? undefined
265+
: { entryName: packageBuild.lib.name, tsconfigPath: dtsTsconfig.path });
233266
const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs]));
234267
await Promise.all(entries
235268
.filter((entry) => entry.executable)

‎packages/agent-bundle/src/build/rslib.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ const asRslibRspackHatch = (
100100

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

103+
/**
104+
* rsbuild-plugin-dts aborts a failed declaration pass with a stackless prose
105+
* Error naming only the Rslib environment ("Error occurred in
106+
* agent-bundle-index declaration files generation.") — there is no structured
107+
* signal to key on, so the phrase is the contract. A build failure this does
108+
* not match is not a declaration failure and keeps its own reporting.
109+
*/
110+
export const isDeclarationGenerationFailure = (error: unknown): boolean =>
111+
error instanceof Error && /declaration files/iu.test(error.message);
112+
103113
// join (not resolve) so a tokenized output root (`<output>/<target>`) stays
104114
// a token instead of resolving against the cwd.
105115
const generatedEntryModulePath = (outputRoot: string, entry: RslibEntry): string =>

‎packages/agent-bundle/tests/package-build.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,54 @@ describe('framework-owned package build', () => {
200200
await expect(stat(join(root, 'dist', 'answer.test.d.ts'))).rejects.toMatchObject({ code: 'ENOENT' });
201201
}, 120_000);
202202

203+
it('reports a failed declaration build as AB4716 carrying the underlying TypeScript diagnostics', async () => {
204+
const root = await fixtureRoot({
205+
...conventionFixture(),
206+
// An exported factory whose inferred declaration type must name a type
207+
// its own module does not export. The failure is emit-only: `--noEmit`
208+
// type checking stays clean, so only declaration generation catches it.
209+
'src/cli-command.ts': [
210+
'interface CliCommandDefinition { readonly name: string }',
211+
'',
212+
'export const defineCliCommand = (name: string): CliCommandDefinition => ({ name });',
213+
'',
214+
].join('\n'),
215+
'src/index.ts': [
216+
"import { defineCliCommand } from './cli-command';",
217+
'',
218+
"export const audibleOperations = () => ({ list: defineCliCommand('list') });",
219+
'',
220+
].join('\n'),
221+
});
222+
await installTypescriptToolchain(root);
223+
224+
const stderr: string[] = [];
225+
const exitCode = await runCli(
226+
['build', '--root', root, '--output', 'artifact'],
227+
{ stderr: { write: (chunk: string) => stderr.push(chunk) }, stdout: { write: () => undefined } },
228+
);
229+
expect(exitCode).toBe(1);
230+
231+
const diagnostics = JSON.parse(stderr.join('')) as readonly {
232+
code: string;
233+
message: string;
234+
recovery?: string;
235+
sourcePath?: string;
236+
}[];
237+
// The dedicated declaration code, never the AB5000 catch-all that
238+
// collides with the dev-lock meaning.
239+
expect(diagnostics.length).toBeGreaterThan(0);
240+
expect([...new Set(diagnostics.map((diagnostic) => diagnostic.code))]).toEqual(['AB4716']);
241+
242+
const emitError = diagnostics.find((diagnostic) => diagnostic.message.includes('TS4023'));
243+
expect(emitError).toBeDefined();
244+
expect(emitError!.message).toContain('src/index.ts(3,14)');
245+
expect(emitError!.message).toContain("Exported variable 'audibleOperations'");
246+
expect(emitError!.message).toContain('CliCommandDefinition');
247+
expect(emitError!.sourcePath).toBe(join(root, 'src', 'index.ts'));
248+
expect(emitError!.recovery).toContain('--noEmit');
249+
}, 120_000);
250+
203251
it('rejects artifact outputs that overlap the package output directory', async () => {
204252
const root = await fixtureRoot(conventionFixture());
205253
await expect(build({ output: 'dist', packageOutputs: true, root })).rejects.toThrow(/overlaps the package build output/u);

0 commit comments

Comments
 (0)