Skip to content

Commit 88197d4

Browse files
the-ultclaude
andcommitted
fix(orval): re-export client extra files from the tags-split barrel
In `tags-split` mode with `tagsSplitDeduplication`, `writeSplitTagsMode` emits `<clientDir>/index.ts` — the only complete client entry point — but builds it from the per-tag implementation files alone. Client extra files are produced by the client builder, so Angular's `retrievalClient: 'both'` left every per-tag `*.resource.ts` unreachable from the barrel: client/pets/pets.service.ts client/pets/pets.resource.ts client/index.ts -> export * from './pets/pets.service'; # resource missing Consumers wanting the resource API had to import per-tag paths directly, which is exactly what a module-boundary-enforced monorepo forbids. `builder.extraFiles` is already in scope in `writeSplitTagsMode`, so the re-exports are composed into the barrel content before it is written rather than patched in afterwards. They are derived from the emitted paths, not from tag names: a mutation-only tag produces no resource file, and a name-derived barrel would export a file that is never written. Files outside the client directory belong to another barrel and are left alone. Each `*.resource.ts` carries its own copy of the shared httpResource boilerplate, so plain wildcards make those names ambiguous — TypeScript reports TS2308 and recommends exactly this remedy. The names are declared by the generator that emits them (`ClientFileBuilder.sharedExports`), built from the same constants the templates interpolate, and re-exported from a single file ahead of the wildcards. Ownership is per name rather than per file, so a generator emitting some of its shared declarations conditionally stays correct. Declared rather than inferred from the generated source: inference cannot tell intentional boilerplate from an accidental collision between two tags, and would silently resolve the latter to one arbitrary file — a wrong type at the call site in place of a build failure. Generators whose extra files carry no repeated declarations (hono, mcp) declare nothing and are unaffected. New `httpResourceBothTagsSplitBarrel` fixture covers it; the generated-output typecheck gate passes for all 16 clients. `http-resource.test.ts` asserts the declaration against the names two generated resource files actually share, so a new shared declaration cannot be added without being listed. No existing snapshot changed — no prior fixture combined `tagsSplitDeduplication` with a client emitting extra files. Workspace output still has the same gap: it emits no client barrel at all, and its workspace barrel omits extra files for the same ordering reason. The shared helper sits in core's utils so closing that is additive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f0c1ea9 commit 88197d4

21 files changed

Lines changed: 1218 additions & 7 deletions

packages/angular/src/http-resource.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3333,6 +3333,78 @@ describe('angular httpResource generator', () => {
33333333
expect(mapImportLine).toContain("from 'rxjs'");
33343334
}
33353335
});
3336+
3337+
// `sharedExports` tells the tags-split barrel writer which names it must
3338+
// re-export from a single file to avoid TS2308. The list is built from the
3339+
// same constants the templates interpolate, so it cannot drift on a
3340+
// *rename* — but it would drift if a new shared declaration were added to
3341+
// the boilerplate without being listed. This asserts the declaration
3342+
// against the names two generated resource files actually share.
3343+
it('declares exactly the names its per-tag resource files share', async () => {
3344+
const petsVerb = createVerbOption({ tags: ['Pets'] });
3345+
const healthVerb = createVerbOption({
3346+
operationId: 'getHealth',
3347+
operationName: 'getHealth',
3348+
route: '/health',
3349+
pathRoute: '/health',
3350+
tags: ['Health'],
3351+
params: [],
3352+
props: [],
3353+
});
3354+
3355+
const output = createOutput({
3356+
target: '/tmp/endpoints.ts',
3357+
mode: 'tags-split',
3358+
override: {
3359+
...createOutput().override,
3360+
angular: {
3361+
...angularOverride('both'),
3362+
},
3363+
},
3364+
});
3365+
3366+
const context = createContextSpec(output, {
3367+
workspace: '/tmp',
3368+
target: '/tmp/endpoints.ts',
3369+
projectName: 'pets',
3370+
});
3371+
3372+
const extraFiles = await generateHttpResourceExtraFiles(
3373+
{ getPetById: petsVerb, getHealth: healthVerb },
3374+
output,
3375+
context,
3376+
);
3377+
3378+
expect(extraFiles).toHaveLength(2);
3379+
3380+
const exportedNames = (content: string): Set<string> =>
3381+
new Set(
3382+
content
3383+
.split('\n')
3384+
.map(
3385+
(line) =>
3386+
line.match(
3387+
/^export\s+(?:declare\s+)?(?:abstract\s+)?(?:type|interface|const|let|var|function|class|enum)\s+([A-Za-z_$][\w$]*)/,
3388+
)?.[1],
3389+
)
3390+
.filter((name): name is string => name !== undefined),
3391+
);
3392+
3393+
const [first, second] = extraFiles;
3394+
const secondNames = exportedNames(second.content);
3395+
const actuallyShared = [...exportedNames(first.content)]
3396+
.filter((name) => secondNames.has(name))
3397+
.toSorted();
3398+
3399+
expect(actuallyShared.length).toBeGreaterThan(0);
3400+
expect(
3401+
[
3402+
...(first.sharedExports?.types ?? []),
3403+
...(first.sharedExports?.values ?? []),
3404+
].toSorted(),
3405+
).toEqual(actuallyShared);
3406+
expect(second.sharedExports).toEqual(first.sharedExports);
3407+
});
33363408
});
33373409

33383410
// ── urlEncodeParameters ─────────────────────────────────────────────

packages/angular/src/http-resource.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
OutputMode,
3131
pascal,
3232
type ResReqTypesValue,
33+
type SharedExports,
3334
toObjectString,
3435
upath,
3536
getImportExtension,
@@ -298,6 +299,38 @@ type HttpResourceFactoryName =
298299
| 'httpResource.blob';
299300

300301
const HTTP_RESOURCE_OPTIONS_TYPE_NAME = 'OrvalHttpResourceOptions';
302+
const HTTP_RESOURCE_REQUEST_EXTENSION_TYPE_NAME =
303+
'OrvalHttpResourceRequestExtension';
304+
const RESOURCE_STATE_TYPE_NAME = 'ResourceState';
305+
const APPLY_REQUEST_EXTENSION_FUNCTION_NAME = 'applyOrvalRequestExtension';
306+
const TO_RESOURCE_STATE_FUNCTION_NAME = 'toResourceState';
307+
308+
/**
309+
* Boilerplate every generated `*.resource.ts` declares.
310+
*
311+
* @remarks
312+
* `buildHttpResourceOptionsUtilities` and `buildResourceStateUtilities` are
313+
* called unconditionally for each resource file, so in tag-based modes each
314+
* tag repeats all five declarations. A barrel wildcard-exporting more than one
315+
* of those files would make every name here ambiguous (TS2308), so the names
316+
* are declared for the barrel writer to re-export explicitly from one file.
317+
*
318+
* These are the same constants the templates interpolate, so a rename cannot
319+
* desynchronise the two. `http-resource.test.ts` additionally asserts this list
320+
* against the names two generated resource files actually share, which catches
321+
* a *new* shared declaration being added without being listed here.
322+
*/
323+
const HTTP_RESOURCE_SHARED_EXPORTS: SharedExports = {
324+
types: [
325+
HTTP_RESOURCE_OPTIONS_TYPE_NAME,
326+
HTTP_RESOURCE_REQUEST_EXTENSION_TYPE_NAME,
327+
RESOURCE_STATE_TYPE_NAME,
328+
],
329+
values: [
330+
APPLY_REQUEST_EXTENSION_FUNCTION_NAME,
331+
TO_RESOURCE_STATE_FUNCTION_NAME,
332+
],
333+
};
301334

302335
const getHttpResourceFactory = (
303336
response: { readonly isBlob: boolean },
@@ -1221,7 +1254,7 @@ export function ${resourceName}(${implementationArgs}): HttpResourceRef<${resour
12211254
};
12221255

12231256
const buildHttpResourceOptionsUtilities = (omitParse: boolean): string => `
1224-
export interface OrvalHttpResourceRequestExtension {
1257+
export interface ${HTTP_RESOURCE_REQUEST_EXTENSION_TYPE_NAME} {
12251258
/** Extra headers merged over generated headers. Pass a function to read signals reactively. */
12261259
headers?: HttpResourceRequest['headers'] | (() => HttpResourceRequest['headers']);
12271260
/** Angular HttpContext forwarded to the underlying request. Pass a function to derive it reactively. */
@@ -1234,7 +1267,7 @@ export type ${HTTP_RESOURCE_OPTIONS_TYPE_NAME}<TValue, TRaw = unknown, TOmitPars
12341267
(TOmitParse extends true
12351268
? Omit<HttpResourceOptions<TValue, TRaw>, 'parse'>
12361269
: HttpResourceOptions<TValue, TRaw>) &
1237-
OrvalHttpResourceRequestExtension;
1270+
${HTTP_RESOURCE_REQUEST_EXTENSION_TYPE_NAME};
12381271
12391272
function mergeOrvalResourceHeaders(
12401273
base: HttpResourceRequest['headers'],
@@ -1270,9 +1303,9 @@ function mergeOrvalResourceHeaders(
12701303
return { ...base, ...extra };
12711304
}
12721305
1273-
export function applyOrvalRequestExtension(
1306+
export function ${APPLY_REQUEST_EXTENSION_FUNCTION_NAME}(
12741307
request: string | HttpResourceRequest,
1275-
options?: OrvalHttpResourceRequestExtension,
1308+
options?: ${HTTP_RESOURCE_REQUEST_EXTENSION_TYPE_NAME},
12761309
): HttpResourceRequest {
12771310
const base: HttpResourceRequest = typeof request === 'string' ? { url: request } : request;
12781311
if (
@@ -1349,7 +1382,7 @@ const buildResourceStateUtilities = (): string => `
13491382
*
13501383
* Uses \`globalThis.Error\` to avoid collision with API model types named \`Error\`.
13511384
*/
1352-
export interface ResourceState<T> {
1385+
export interface ${RESOURCE_STATE_TYPE_NAME}<T> {
13531386
readonly value: Signal<T | undefined>;
13541387
readonly status: Signal<ResourceStatus>;
13551388
readonly error: Signal<globalThis.Error | undefined>;
@@ -1362,7 +1395,7 @@ export interface ResourceState<T> {
13621395
* Wraps an HttpResourceRef to expose a consistent ResourceState interface.
13631396
* Useful when integrating with NgRx SignalStore via withResource().
13641397
*/
1365-
export function toResourceState<T>(ref: HttpResourceRef<T>): ResourceState<T> {
1398+
export function ${TO_RESOURCE_STATE_FUNCTION_NAME}<T>(ref: HttpResourceRef<T>): ${RESOURCE_STATE_TYPE_NAME}<T> {
13661399
return {
13671400
value: ref.value,
13681401
status: ref.status,
@@ -1774,6 +1807,7 @@ const buildHttpResourceExtraFile = (
17741807
return {
17751808
content: `${header}${importImplementation}${mutatorImports}${implementation}`,
17761809
path: outputPath,
1810+
sharedExports: HTTP_RESOURCE_SHARED_EXPORTS,
17771811
};
17781812
};
17791813

packages/core/src/types.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1759,9 +1759,42 @@ export type ClientBuilder = (
17591759
output?: NormalizedOutputOptions,
17601760
) => GeneratorClient | Promise<GeneratorClient>;
17611761

1762+
/**
1763+
* Names a generated file declares that its sibling files of the same kind
1764+
* declare identically.
1765+
*
1766+
* @remarks
1767+
* A generator that emits one file per tag repeats any shared boilerplate in
1768+
* every one of them. Wildcard-exporting two such files from a barrel makes
1769+
* each repeated name ambiguous (TS2308), so the barrel writer re-exports them
1770+
* explicitly from a single file first.
1771+
*
1772+
* The generator declares these rather than the barrel writer inferring them
1773+
* from the emitted source. Inference cannot distinguish intentional
1774+
* duplication from an accidental name collision between two tags, and would
1775+
* silently resolve the latter to one arbitrary file — turning a compile error
1776+
* into a wrong type at the call site.
1777+
*/
1778+
export interface SharedExports {
1779+
/**
1780+
* Type-only declarations. Re-exported via `export type { ... }` — kept
1781+
* separate from {@link SharedExports.values} because a type re-exported
1782+
* without the `type` modifier is an error under `verbatimModuleSyntax`,
1783+
* which Angular projects enable by default.
1784+
*/
1785+
readonly types: readonly string[];
1786+
/** Value declarations, re-exported via `export { ... }`. */
1787+
readonly values: readonly string[];
1788+
}
1789+
17621790
export interface ClientFileBuilder {
17631791
path: string;
17641792
content: string;
1793+
/**
1794+
* Declared by generators whose extra files repeat shared boilerplate. Omit
1795+
* when a file declares nothing its siblings also declare.
1796+
*/
1797+
sharedExports?: SharedExports;
17651798
}
17661799
export type ClientExtraFilesBuilder = (
17671800
verbOptions: Record<string, GeneratorVerbOptions>,
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { buildBarrelReExports } from './barrel-re-exports';
4+
5+
// Mirrors Angular's `retrievalClient: 'both'` output: one file per tag, each
6+
// repeating the same httpResource boilerplate.
7+
const resourceEntry = (tag: string) => ({
8+
specifier: `./${tag}/${tag}.resource`,
9+
sharedExports: {
10+
types: ['OrvalHttpResourceOptions', 'ResourceState'],
11+
values: ['toResourceState'],
12+
},
13+
});
14+
15+
describe('buildBarrelReExports', () => {
16+
it('wildcard-exports every entry', () => {
17+
expect(
18+
buildBarrelReExports([
19+
{ specifier: './pets/pets.resource' },
20+
{ specifier: './health/health.resource' },
21+
]),
22+
).toEqual([
23+
"export * from './pets/pets.resource';",
24+
"export * from './health/health.resource';",
25+
]);
26+
});
27+
28+
it('re-exports names declared by more than one entry ahead of the wildcards', () => {
29+
const lines = buildBarrelReExports([
30+
resourceEntry('health'),
31+
resourceEntry('pets'),
32+
]);
33+
34+
expect(lines).toEqual([
35+
"export type { OrvalHttpResourceOptions, ResourceState } from './health/health.resource';",
36+
"export { toResourceState } from './health/health.resource';",
37+
"export * from './health/health.resource';",
38+
"export * from './pets/pets.resource';",
39+
]);
40+
});
41+
42+
it('leaves a name declared by a single entry on its wildcard', () => {
43+
const lines = buildBarrelReExports([
44+
resourceEntry('health'),
45+
{
46+
specifier: './pets/pets.resource',
47+
sharedExports: { types: ['SomethingElse'], values: [] },
48+
},
49+
]);
50+
51+
expect(lines.join('\n')).not.toContain('SomethingElse');
52+
expect(lines.join('\n')).not.toContain('OrvalHttpResourceOptions');
53+
});
54+
55+
it('attributes each shared name to the first entry declaring it', () => {
56+
// `ResourceState` is absent from the first entry, so intersecting the
57+
// shared set with entry one would drop it and leave TS2308 unresolved.
58+
const lines = buildBarrelReExports([
59+
{
60+
specifier: './a/a.resource',
61+
sharedExports: { types: ['Shared'], values: [] },
62+
},
63+
{
64+
specifier: './b/b.resource',
65+
sharedExports: { types: ['Shared', 'ResourceState'], values: [] },
66+
},
67+
{
68+
specifier: './c/c.resource',
69+
sharedExports: { types: ['ResourceState'], values: [] },
70+
},
71+
]);
72+
73+
expect(lines).toEqual([
74+
"export type { Shared } from './a/a.resource';",
75+
"export type { ResourceState } from './b/b.resource';",
76+
"export * from './a/a.resource';",
77+
"export * from './b/b.resource';",
78+
"export * from './c/c.resource';",
79+
]);
80+
});
81+
82+
it('skips names the barrel already re-exports by name', () => {
83+
const lines = buildBarrelReExports(
84+
[resourceEntry('health'), resourceEntry('pets')],
85+
['ResourceState'],
86+
);
87+
88+
expect(lines).toContain(
89+
"export type { OrvalHttpResourceOptions } from './health/health.resource';",
90+
);
91+
expect(lines.join('\n')).not.toContain(
92+
'export type { OrvalHttpResourceOptions, ResourceState }',
93+
);
94+
});
95+
96+
it('ignores entries that declare no shared exports', () => {
97+
const lines = buildBarrelReExports([
98+
{ specifier: './pets/pets.handlers' },
99+
{ specifier: './health/health.handlers' },
100+
]);
101+
102+
expect(lines.every((line) => line.startsWith('export *'))).toBe(true);
103+
});
104+
105+
it('returns nothing for no entries', () => {
106+
expect(buildBarrelReExports([])).toEqual([]);
107+
});
108+
});

0 commit comments

Comments
 (0)