···11import type { Refs, Symbol, SymbolMeta } from '@hey-api/codegen-core';
22-import type { IR } from '@hey-api/shared';
22+import type { IR, SchemaExtractor } from '@hey-api/shared';
3344import type { $ } from '../../../py-dsl';
55import type { PydanticPlugin } from '../types';
···2929};
30303131export type IrSchemaToAstOptions = {
3232+ /** The plugin instance. */
3233 plugin: PydanticPlugin['Instance'];
3434+ /** Optional schema extractor function. */
3535+ schemaExtractor?: SchemaExtractor;
3636+ /** The plugin state references. */
3337 state: Refs<PluginState>;
3438};
3539
···11import type { Refs, SymbolMeta } from '@hey-api/codegen-core';
22-import type { IR } from '@hey-api/shared';
22+import type { IR, SchemaExtractor } from '@hey-api/shared';
33import type ts from 'typescript';
4455import type { $ } from '../../../ts-dsl';
···1313};
14141515export type IrSchemaToAstOptions = {
1616+ /** The plugin instance. */
1617 plugin: ArktypePlugin['Instance'];
1818+ /** Optional schema extractor function. */
1919+ schemaExtractor?: SchemaExtractor;
2020+ /** The plugin state references. */
1721 state: Refs<PluginState>;
1822};
1923
···55import type { GetIntegerLimit } from '../../../plugins/shared/utils/formats';
66import type { $, DollarTsDsl } from '../../../ts-dsl';
77import type { Pipe, PipeResult, Pipes, PipesUtils } from '../shared/pipes';
88-import type { Ast, PluginState } from '../shared/types';
99-import type { ValibotPlugin } from '../types';
88+import type { Ast, IrSchemaToAstOptions, PluginState } from '../shared/types';
1091110export type Resolvers = Plugin.Resolvers<{
1211 /**
···70697170type ValidatorResolver = (ctx: ValidatorResolverContext) => PipeResult | null | undefined;
72717373-interface BaseContext extends DollarTsDsl {
7474- /**
7575- * Functions for working with pipes.
7676- */
7777- pipes: PipesUtils & {
7272+type BaseContext = DollarTsDsl &
7373+ Pick<IrSchemaToAstOptions, 'plugin' | 'schemaExtractor'> & {
7474+ /**
7575+ * Functions for working with pipes.
7676+ */
7777+ pipes: PipesUtils & {
7878+ /**
7979+ * The current pipe.
8080+ *
8181+ * In Valibot, this represents a list of call expressions ("pipes")
8282+ * being assembled to form a schema definition.
8383+ *
8484+ * Each pipe can be extended, modified, or replaced to customize
8585+ * the resulting schema.
8686+ */
8787+ current: Pipes;
8888+ };
7889 /**
7979- * The current pipe.
8080- *
8181- * In Valibot, this represents a list of call expressions ("pipes")
8282- * being assembled to form a schema definition.
8383- *
8484- * Each pipe can be extended, modified, or replaced to customize
8585- * the resulting schema.
9090+ * Provides access to commonly used symbols within the plugin.
8691 */
8787- current: Pipes;
8888- };
8989- /**
9090- * The plugin instance.
9191- */
9292- plugin: ValibotPlugin['Instance'];
9393- /**
9494- * Provides access to commonly used symbols within the plugin.
9595- */
9696- symbols: {
9797- v: Symbol;
9292+ symbols: {
9393+ v: Symbol;
9494+ };
9895 };
9999-}
1009610197export interface EnumResolverContext extends BaseContext {
10298 /**
···11import type { Refs, SymbolMeta } from '@hey-api/codegen-core';
22-import type { IR } from '@hey-api/shared';
22+import type { IR, SchemaExtractor } from '@hey-api/shared';
33import type ts from 'typescript';
4455import type { ValibotPlugin } from '../types';
66import type { Pipes } from './pipes';
77+import type { ProcessorContext } from './processor';
7889export type Ast = {
910 hasLazyExpression?: boolean;
···1213};
13141415export type IrSchemaToAstOptions = {
1616+ /** The plugin instance. */
1517 plugin: ValibotPlugin['Instance'];
1818+ /** Optional schema extractor function. */
1919+ schemaExtractor?: SchemaExtractor<ProcessorContext>;
2020+ /** The plugin state references. */
1621 state: Refs<PluginState>;
1722};
1823
···11+/**
22+ * After these structural segments, the next segment has a known role.
33+ * This is what makes a property literally named "properties" safe —
44+ * it occupies the name position, never the structural position.
55+ */
66+const STRUCTURAL_ROLE: Record<string, 'name' | 'index'> = {
77+ items: 'index',
88+ patternProperties: 'name',
99+ properties: 'name',
1010+};
1111+1212+/**
1313+ * These structural segments have no following name/index —
1414+ * they are the terminal structural node. Append a suffix
1515+ * to disambiguate from the parent.
1616+ */
1717+const STRUCTURAL_SUFFIX: Record<string, string> = {
1818+ additionalProperties: 'Value',
1919+};
2020+2121+type RootContextConfig = {
2222+ /** How many consecutive semantic segments follow before structural walking begins */
2323+ names: number;
2424+ /** How many leading segments to skip (the root keyword + any category segment) */
2525+ skip: number;
2626+};
2727+2828+/**
2929+ * Root context configuration.
3030+ */
3131+const ROOT_CONTEXT: Record<string | number, RootContextConfig> = {
3232+ components: { names: 1, skip: 2 }, // components/schemas/{name}
3333+ definitions: { names: 1, skip: 1 }, // definitions/{name}
3434+ paths: { names: 2, skip: 1 }, // paths/{path}/{method}
3535+ webhooks: { names: 2, skip: 1 }, // webhooks/{name}/{method}
3636+};
3737+3838+/**
3939+ * Sanitizes a path segment for use in a derived name.
4040+ *
4141+ * Handles API path segments like `/api/v1/users/{id}` → `ApiV1UsersId`.
4242+ */
4343+function sanitizeSegment(segment: string | number): string {
4444+ const str = String(segment);
4545+ if (str.startsWith('/')) {
4646+ return str
4747+ .split('/')
4848+ .filter(Boolean)
4949+ .map((part) => {
5050+ const clean = part.replace(/[{}]/g, '');
5151+ return clean.charAt(0).toUpperCase() + clean.slice(1);
5252+ })
5353+ .join('');
5454+ }
5555+ return str;
5656+}
5757+5858+export interface PathToNameOptions {
5959+ /**
6060+ * When provided, replaces the root semantic segments with this anchor.
6161+ * Structural suffixes are still derived from path.
6262+ */
6363+ anchor?: string;
6464+}
6565+6666+/**
6767+ * Derives a composite name from a path.
6868+ *
6969+ * Examples:
7070+ * .../User → 'User'
7171+ * .../User/properties/address → 'UserAddress'
7272+ * .../User/properties/properties → 'UserProperties'
7373+ * .../User/properties/address/properties/city → 'UserAddressCity'
7474+ * .../Pet/additionalProperties → 'PetValue'
7575+ * .../Order/properties/items/items/0 → 'OrderItems'
7676+ * paths//event/get/properties/query → 'EventGetQuery'
7777+ *
7878+ * With anchor:
7979+ * paths//event/get/properties/query, { anchor: 'event.subscribe' }
8080+ * → 'event.subscribe-Query'
8181+ */
8282+export function pathToName(
8383+ path: ReadonlyArray<string | number>,
8484+ options?: PathToNameOptions,
8585+): string {
8686+ const names: Array<string> = [];
8787+ let index = 0;
8888+8989+ const rootContext = ROOT_CONTEXT[path[0]!];
9090+ if (rootContext) {
9191+ index = rootContext.skip;
9292+9393+ if (options?.anchor) {
9494+ // Use anchor as base name, skip past root semantic segments
9595+ names.push(options.anchor);
9696+ index += rootContext.names;
9797+ } else {
9898+ // Collect consecutive semantic name segments
9999+ for (let n = 0; n < rootContext.names && index < path.length; n++) {
100100+ names.push(sanitizeSegment(path[index]!));
101101+ index++;
102102+ }
103103+ }
104104+ } else {
105105+ // Unknown root
106106+ if (options?.anchor) {
107107+ names.push(options.anchor);
108108+ index++;
109109+ } else if (index < path.length) {
110110+ names.push(sanitizeSegment(path[index]!));
111111+ index++;
112112+ }
113113+ }
114114+115115+ while (index < path.length) {
116116+ const segment = String(path[index]);
117117+118118+ const role = STRUCTURAL_ROLE[segment];
119119+ if (role === 'name') {
120120+ // Next segment is a semantic name — collect it
121121+ index++;
122122+ if (index < path.length) {
123123+ names.push(sanitizeSegment(path[index]!));
124124+ }
125125+ } else if (role === 'index') {
126126+ // Next segment is a numeric index — skip it
127127+ index++;
128128+ if (index < path.length && typeof path[index] === 'number') {
129129+ index++;
130130+ }
131131+ continue;
132132+ } else if (STRUCTURAL_SUFFIX[segment]) {
133133+ names.push(STRUCTURAL_SUFFIX[segment]);
134134+ }
135135+136136+ index++;
137137+ }
138138+139139+ // refs using unicode characters become encoded, didn't investigate why
140140+ // but the suspicion is this comes from `@hey-api/json-schema-ref-parser`
141141+ return decodeURI(names.join('-'));
142142+}
+4-4
packages/shared/src/utils/ref.ts
···9292}
93939494/**
9595- * Checks if a $ref points to a top-level component (not a deep path reference).
9595+ * Checks if a $ref or path points to a top-level component (not a deep path reference).
9696 *
9797 * Top-level component references:
9898 * - OpenAPI 3.x: #/components/{type}/{name} (3 segments)
···101101 * Deep path references (4+ segments for 3.x, 3+ for 2.0) should be inlined
102102 * because they don't have corresponding registered symbols.
103103 *
104104- * @param $ref - The $ref string to check
104104+ * @param refOrPath - The $ref string or path array to check
105105 * @returns true if the ref points to a top-level component, false otherwise
106106 */
107107-export function isTopLevelComponentRef($ref: string): boolean {
108108- const path = jsonPointerToPath($ref);
107107+export function isTopLevelComponent(refOrPath: string | ReadonlyArray<string | number>): boolean {
108108+ const path = refOrPath instanceof Array ? refOrPath : jsonPointerToPath(refOrPath);
109109110110 // OpenAPI 3.x: #/components/{type}/{name} = 3 segments
111111 if (path[0] === 'components') {