@@ -2736,6 +2736,126 @@ describe('[#4688] HttpRequest is single-source across ./shared and ./ui', () =>
27362736 } ) ;
27372737} ) ;
27382738
2739+ // ─── [#4691] `HttpMethod` is gone from ./ui — the LAST dual-source row ───────
2740+ //
2741+ // The sibling of the #4688 pin above, and deliberately NOT the same fix. There,
2742+ // `./shared` and `./ui` named two declarations of the *same* shape and the cure
2743+ // was a re-export. Here the two declarations are genuinely different types:
2744+ //
2745+ // shared/http.zod.ts `export const/type HttpMethod` → 7 values (+HEAD/OPTIONS)
2746+ // shared/http.zod.ts `HttpMethodSchema`/`HttpMethodType` → 5 values (UI subset)
2747+ // ui/view.zod.ts `export type HttpMethod` (removed) → the 5-value one
2748+ //
2749+ // So re-exporting `./shared`'s into `./ui` would have widened the UI type to 7
2750+ // while `HttpRequestSchema.method` still accepts only 5 — a type that lies about
2751+ // its own runtime. The name was removed from `./ui` instead.
2752+ //
2753+ // Same reasoning as #4688 on the mechanism: `HttpMethod` is a TYPE, erased
2754+ // before any runtime assertion can see it, and #4642 established that a
2755+ // compile-time pin in this package is a no-op (`tsconfig.json` excludes
2756+ // `**/*.test.ts`; vitest never enables `typecheck`). The compiler-API test is
2757+ // therefore the load-bearing one; the runtime tests below it guard the value
2758+ // ranges the whole argument rests on.
2759+ describe ( '[#4691] `HttpMethod` is not exported from ./ui' , ( ) => {
2760+ it ( 'resolves the export surface: ./ui has no `HttpMethod`, ./shared and ./api share one' , async ( ) => {
2761+ const ts = ( await import ( 'typescript' ) ) . default ;
2762+ const { resolve, relative, dirname } = await import ( 'node:path' ) ;
2763+ const { fileURLToPath } = await import ( 'node:url' ) ;
2764+
2765+ const specDir = resolve ( dirname ( fileURLToPath ( import . meta. url ) ) , '../..' ) ;
2766+ const entries = {
2767+ './shared' : resolve ( specDir , 'src/shared/index.ts' ) ,
2768+ './ui' : resolve ( specDir , 'src/ui/index.ts' ) ,
2769+ './api' : resolve ( specDir , 'src/api/index.ts' ) ,
2770+ } ;
2771+ const program = ts . createProgram ( Object . values ( entries ) , {
2772+ module : ts . ModuleKind . ESNext ,
2773+ moduleResolution : ts . ModuleResolutionKind . Bundler ,
2774+ skipLibCheck : true ,
2775+ noEmit : true ,
2776+ } ) ;
2777+ const checker = program . getTypeChecker ( ) ;
2778+ const unalias = ( s : import ( 'typescript' ) . Symbol ) =>
2779+ s . getFlags ( ) & ts . SymbolFlags . Alias ? checker . getAliasedSymbol ( s ) : s ;
2780+
2781+ const exportsOf = ( sub : keyof typeof entries ) => {
2782+ const sf = program . getSourceFile ( entries [ sub ] ) ;
2783+ const moduleSym = sf && checker . getSymbolAtLocation ( sf ) ;
2784+ // Without this guard a resolution failure would make every assertion
2785+ // below pass vacuously — the exact way a gate goes dormant (#4642).
2786+ expect ( moduleSym , `${ sub } module symbol must resolve` ) . toBeTruthy ( ) ;
2787+ return checker . getExportsOfModule ( moduleSym ! ) ;
2788+ } ;
2789+
2790+ // A sanity anchor: if this entry resolved to nothing, `find` returning
2791+ // undefined for `HttpMethod` below would prove nothing at all.
2792+ const uiExports = exportsOf ( './ui' ) ;
2793+ expect ( uiExports . length , './ui must export a non-trivial surface' ) . toBeGreaterThan ( 50 ) ;
2794+
2795+ // 1. The row this change removes: `./ui` no longer names `HttpMethod`.
2796+ expect ( uiExports . map ( ( e ) => e . getName ( ) ) ) . not . toContain ( 'HttpMethod' ) ;
2797+
2798+ // 2. …but it still offers the 5-value type under its honest name, so the
2799+ // migration stays inside this entry point.
2800+ const uiMethodType = uiExports . find ( ( e ) => e . getName ( ) === 'HttpMethodType' ) ;
2801+ expect ( uiMethodType , './ui must export `HttpMethodType`' ) . toBeTruthy ( ) ;
2802+
2803+ const originOf = ( sym : import ( 'typescript' ) . Symbol , label : string ) => {
2804+ const decl = unalias ( sym ) . declarations ?. [ 0 ] ;
2805+ expect ( decl , `${ label } must have a declaration` ) . toBeTruthy ( ) ;
2806+ const declFile = decl ! . getSourceFile ( ) ;
2807+ return `${ relative ( specDir , declFile . fileName ) } :${
2808+ declFile . getLineAndCharacterOfPosition ( decl ! . getStart ( ) ) . line + 1
2809+ } `;
2810+ } ;
2811+
2812+ expect ( originOf ( uiMethodType ! , './ui HttpMethodType' ) )
2813+ . toMatch ( / ^ s r c \/ s h a r e d \/ h t t p \. z o d \. t s : \d + $ / ) ;
2814+
2815+ // 3. `./shared` and `./api` keep naming ONE declaration `HttpMethod` — the
2816+ // 7-value one. This change must not have disturbed that side.
2817+ const origins = new Map < string , string > ( ) ;
2818+ for ( const sub of [ './shared' , './api' ] as const ) {
2819+ const exported = exportsOf ( sub ) . find ( ( e ) => e . getName ( ) === 'HttpMethod' ) ;
2820+ expect ( exported , `${ sub } must still export \`HttpMethod\`` ) . toBeTruthy ( ) ;
2821+ origins . set ( sub , originOf ( exported ! , `${ sub } HttpMethod` ) ) ;
2822+ }
2823+ expect ( origins . get ( './api' ) ) . toBe ( origins . get ( './shared' ) ) ;
2824+ expect ( origins . get ( './shared' ) ) . toMatch ( / ^ s r c \/ s h a r e d \/ h t t p \. z o d \. t s : \d + $ / ) ;
2825+ } ) ;
2826+
2827+ it ( 'keeps the two value ranges distinct: 7 for `HttpMethod`, 5 for `HttpMethodSchema`' , async ( ) => {
2828+ const sharedEntry = await import ( '../shared/index' ) ;
2829+ const apiEntry = await import ( '../api/index' ) ;
2830+
2831+ // `./api` re-exports the const, so this is one object seen twice.
2832+ expect ( apiEntry . HttpMethod ) . toBe ( sharedEntry . HttpMethod ) ;
2833+
2834+ expect ( [ ...sharedEntry . HttpMethod . options ] . sort ( ) ) . toEqual (
2835+ [ 'DELETE' , 'GET' , 'HEAD' , 'OPTIONS' , 'PATCH' , 'POST' , 'PUT' ] ,
2836+ ) ;
2837+ expect ( [ ...sharedEntry . HttpMethodSchema . options ] . sort ( ) ) . toEqual (
2838+ [ 'DELETE' , 'GET' , 'PATCH' , 'POST' , 'PUT' ] ,
2839+ ) ;
2840+
2841+ // The subset relation is the whole reason the two names cannot merge.
2842+ expect ( sharedEntry . HttpMethod . options ) . toContain ( 'HEAD' ) ;
2843+ expect ( sharedEntry . HttpMethodSchema . options ) . not . toContain ( 'HEAD' ) ;
2844+ } ) ;
2845+
2846+ it ( 'rejects `HEAD` at the parse layer — the runtime the ./ui type must not out-promise' , async ( ) => {
2847+ const uiEntry = await import ( '../ui/index' ) ;
2848+
2849+ expect ( ( ) => uiEntry . HttpMethodSchema . parse ( 'HEAD' ) ) . toThrow ( ) ;
2850+ expect ( ( ) => uiEntry . HttpMethodSchema . parse ( 'OPTIONS' ) ) . toThrow ( ) ;
2851+ expect ( ( ) => uiEntry . HttpRequestSchema . parse ( { url : '/api/data' , method : 'HEAD' } ) ) . toThrow ( ) ;
2852+ // …while the 5 it does accept still round-trip, so the guard above is not
2853+ // passing because the schema rejects everything.
2854+ expect ( uiEntry . HttpRequestSchema . parse ( { url : '/api/data' , method : 'PATCH' } ) . method )
2855+ . toBe ( 'PATCH' ) ;
2856+ } ) ;
2857+ } ) ;
2858+
27392859describe ( 'ADR-0089 — visibleWhen unification (view form)' , ( ) => {
27402860 it ( 'normalizes a deprecated `visibleOn` alias to `visibleWhen` on a form field' , ( ) => {
27412861 const parsed = FormFieldSchema . parse ( { field : 'state' , visibleOn : "record.country == 'US'" } ) ;
0 commit comments