openapi-typesafe 1.0.0-rc.5 → 1.0.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/adapters/redocly-loader/query-document-names.cjs +50 -0
  3. package/dist/adapters/redocly-loader/query-document-names.cjs.map +1 -0
  4. package/dist/adapters/redocly-loader/query-document-names.mjs +48 -0
  5. package/dist/adapters/redocly-loader/query-document-names.mjs.map +1 -0
  6. package/dist/adapters/redocly-loader/query-named-components.cjs +29 -37
  7. package/dist/adapters/redocly-loader/query-named-components.cjs.map +1 -1
  8. package/dist/adapters/redocly-loader/query-named-components.mjs +29 -37
  9. package/dist/adapters/redocly-loader/query-named-components.mjs.map +1 -1
  10. package/dist/adapters/redocly-loader/redocly-config-source.cjs +3 -0
  11. package/dist/adapters/redocly-loader/redocly-config-source.cjs.map +1 -1
  12. package/dist/adapters/redocly-loader/redocly-config-source.mjs +4 -1
  13. package/dist/adapters/redocly-loader/redocly-config-source.mjs.map +1 -1
  14. package/dist/adapters/redocly-loader/redocly-schema-loader.cjs +16 -7
  15. package/dist/adapters/redocly-loader/redocly-schema-loader.cjs.map +1 -1
  16. package/dist/adapters/redocly-loader/redocly-schema-loader.mjs +17 -8
  17. package/dist/adapters/redocly-loader/redocly-schema-loader.mjs.map +1 -1
  18. package/dist/adapters/redocly-loader/ref-access.cjs +9 -1
  19. package/dist/adapters/redocly-loader/ref-access.cjs.map +1 -1
  20. package/dist/adapters/redocly-loader/ref-access.mjs +10 -2
  21. package/dist/adapters/redocly-loader/ref-access.mjs.map +1 -1
  22. package/dist/adapters/redocly-loader/remote-fetch.cjs +16 -1
  23. package/dist/adapters/redocly-loader/remote-fetch.cjs.map +1 -1
  24. package/dist/adapters/redocly-loader/remote-fetch.mjs +16 -2
  25. package/dist/adapters/redocly-loader/remote-fetch.mjs.map +1 -1
  26. package/dist/api/cwd-option.cjs +58 -0
  27. package/dist/api/cwd-option.cjs.map +1 -0
  28. package/dist/api/cwd-option.mjs +52 -0
  29. package/dist/api/cwd-option.mjs.map +1 -0
  30. package/dist/api/default-ports.cjs +15 -7
  31. package/dist/api/default-ports.cjs.map +1 -1
  32. package/dist/api/default-ports.mjs +14 -8
  33. package/dist/api/default-ports.mjs.map +1 -1
  34. package/dist/api/document-run.cjs +2 -16
  35. package/dist/api/document-run.cjs.map +1 -1
  36. package/dist/api/document-run.mjs +2 -12
  37. package/dist/api/document-run.mjs.map +1 -1
  38. package/dist/api/legacy-utils.cjs +1 -1
  39. package/dist/api/legacy-utils.cjs.map +1 -1
  40. package/dist/api/legacy-utils.mjs +1 -1
  41. package/dist/api/legacy-utils.mjs.map +1 -1
  42. package/dist/api/openapi-ts.cjs.map +1 -1
  43. package/dist/api/openapi-ts.mjs.map +1 -1
  44. package/dist/api/option-errors.cjs +24 -0
  45. package/dist/api/option-errors.cjs.map +1 -0
  46. package/dist/api/option-errors.mjs +21 -0
  47. package/dist/api/option-errors.mjs.map +1 -0
  48. package/dist/api/ref-limit-options.cjs +19 -24
  49. package/dist/api/ref-limit-options.cjs.map +1 -1
  50. package/dist/api/ref-limit-options.mjs +15 -20
  51. package/dist/api/ref-limit-options.mjs.map +1 -1
  52. package/dist/application/transform/document/path-template-refs.cjs +12 -2
  53. package/dist/application/transform/document/path-template-refs.cjs.map +1 -1
  54. package/dist/application/transform/document/path-template-refs.mjs +12 -2
  55. package/dist/application/transform/document/path-template-refs.mjs.map +1 -1
  56. package/dist/application/transform/ref-pointer/generated-shape.cjs +28 -10
  57. package/dist/application/transform/ref-pointer/generated-shape.cjs.map +1 -1
  58. package/dist/application/transform/ref-pointer/generated-shape.mjs +29 -11
  59. package/dist/application/transform/ref-pointer/generated-shape.mjs.map +1 -1
  60. package/dist/application/transform/ref-pointer/original-index.cjs +1 -1
  61. package/dist/application/transform/ref-pointer/original-index.cjs.map +1 -1
  62. package/dist/application/transform/ref-pointer/original-index.mjs +1 -1
  63. package/dist/application/transform/ref-pointer/original-index.mjs.map +1 -1
  64. package/dist/application/transform/ref-pointer/property-shape.cjs +5 -0
  65. package/dist/application/transform/ref-pointer/property-shape.cjs.map +1 -1
  66. package/dist/application/transform/ref-pointer/property-shape.mjs +5 -1
  67. package/dist/application/transform/ref-pointer/property-shape.mjs.map +1 -1
  68. package/dist/application/transform/ref-pointer/typed-walk.cjs +11 -1
  69. package/dist/application/transform/ref-pointer/typed-walk.cjs.map +1 -1
  70. package/dist/application/transform/ref-pointer/typed-walk.mjs +12 -2
  71. package/dist/application/transform/ref-pointer/typed-walk.mjs.map +1 -1
  72. package/dist/cli/api-settings.cjs +3 -2
  73. package/dist/cli/api-settings.cjs.map +1 -1
  74. package/dist/cli/api-settings.mjs +2 -1
  75. package/dist/cli/api-settings.mjs.map +1 -1
  76. package/dist/cli/api-writes.cjs +7 -1
  77. package/dist/cli/api-writes.cjs.map +1 -1
  78. package/dist/cli/api-writes.mjs +7 -1
  79. package/dist/cli/api-writes.mjs.map +1 -1
  80. package/dist/cli/cli-arguments.cjs +25 -107
  81. package/dist/cli/cli-arguments.cjs.map +1 -1
  82. package/dist/cli/cli-arguments.mjs +18 -100
  83. package/dist/cli/cli-arguments.mjs.map +1 -1
  84. package/dist/cli/cli-errors.cjs +1 -0
  85. package/dist/cli/cli-errors.cjs.map +1 -1
  86. package/dist/cli/cli-errors.mjs +1 -0
  87. package/dist/cli/cli-errors.mjs.map +1 -1
  88. package/dist/cli/cli-flag-names.cjs +77 -0
  89. package/dist/cli/cli-flag-names.cjs.map +1 -0
  90. package/dist/cli/cli-flag-names.mjs +64 -0
  91. package/dist/cli/cli-flag-names.mjs.map +1 -0
  92. package/dist/cli/cli-output.cjs +7 -2
  93. package/dist/cli/cli-output.cjs.map +1 -1
  94. package/dist/cli/cli-output.mjs +7 -2
  95. package/dist/cli/cli-output.mjs.map +1 -1
  96. package/dist/cli/main.cjs +1 -1
  97. package/dist/cli/main.cjs.map +1 -1
  98. package/dist/cli/main.mjs +1 -1
  99. package/dist/cli/main.mjs.map +1 -1
  100. package/dist/cli/redocly-apis.cjs +14 -4
  101. package/dist/cli/redocly-apis.cjs.map +1 -1
  102. package/dist/cli/redocly-apis.mjs +14 -4
  103. package/dist/cli/redocly-apis.mjs.map +1 -1
  104. package/dist/cli/written-flags.cjs +83 -0
  105. package/dist/cli/written-flags.cjs.map +1 -0
  106. package/dist/cli/written-flags.mjs +77 -0
  107. package/dist/cli/written-flags.mjs.map +1 -0
  108. package/dist/domain/errors.cjs +1 -0
  109. package/dist/domain/errors.cjs.map +1 -1
  110. package/dist/domain/errors.mjs +1 -0
  111. package/dist/domain/errors.mjs.map +1 -1
  112. package/dist/domain/location-text.cjs +7 -1
  113. package/dist/domain/location-text.cjs.map +1 -1
  114. package/dist/domain/location-text.mjs +7 -1
  115. package/dist/domain/location-text.mjs.map +1 -1
  116. package/dist/index.d.cts +3 -0
  117. package/dist/index.d.mts +3 -0
  118. package/dist/index.d.ts +3 -0
  119. package/package.json +1 -1
  120. package/src/adapters/redocly-loader/query-document-names.ts +79 -0
  121. package/src/adapters/redocly-loader/query-named-components.ts +48 -49
  122. package/src/adapters/redocly-loader/redocly-config-source.ts +11 -1
  123. package/src/adapters/redocly-loader/redocly-schema-loader.ts +20 -6
  124. package/src/adapters/redocly-loader/ref-access.ts +16 -2
  125. package/src/adapters/redocly-loader/remote-fetch.ts +26 -0
  126. package/src/api/cwd-option.ts +76 -0
  127. package/src/api/default-ports.ts +33 -10
  128. package/src/api/document-run.ts +2 -17
  129. package/src/api/legacy-utils.ts +1 -1
  130. package/src/api/openapi-ts.ts +14 -1
  131. package/src/api/option-errors.ts +22 -0
  132. package/src/api/ref-limit-options.ts +17 -24
  133. package/src/application/ports/redocly-config-source.ts +5 -0
  134. package/src/application/transform/document/path-template-refs.ts +28 -7
  135. package/src/application/transform/ref-pointer/generated-shape.ts +57 -14
  136. package/src/application/transform/ref-pointer/original-index.ts +6 -3
  137. package/src/application/transform/ref-pointer/property-shape.ts +9 -0
  138. package/src/application/transform/ref-pointer/typed-walk.ts +26 -4
  139. package/src/cli/api-writes.ts +11 -3
  140. package/src/cli/cli-arguments.ts +42 -153
  141. package/src/cli/cli-errors.ts +1 -0
  142. package/src/cli/cli-flag-names.ts +99 -0
  143. package/src/cli/cli-output.ts +20 -6
  144. package/src/cli/main.ts +2 -1
  145. package/src/cli/redocly-apis.ts +21 -5
  146. package/src/cli/written-flags.ts +131 -0
  147. package/src/domain/errors.ts +1 -0
  148. package/src/domain/location-text.ts +17 -2
@@ -2,7 +2,7 @@ import { createRedoclySchemaLoader } from "../adapters/redocly-loader/redocly-sc
2
2
  import type { Logger } from "../application/ports/logger.js";
3
3
  import { withStackOverflowReported } from "../application/stack-overflow.js";
4
4
  import type { IrDeclaration } from "../domain/ir/nodes.js";
5
- import type { GeneratorOptions, SchemaSource } from "../types.js";
5
+ import type { GeneratorOptions, OpenAPITSOptions, SchemaSource } from "../types.js";
6
6
  import {
7
7
  DEFAULT_REMOTE_DOCUMENT_LIMITS,
8
8
  defaultDeprecationReporter,
@@ -78,6 +78,19 @@ export async function defaultGeneratorPorts(
78
78
  * - URL: URL to a YAML or JSON file (local or remote)
79
79
  * - Readable: Readable stream of YAML or JSON
80
80
  */
81
+ export default function openapiTS(
82
+ source: SchemaSource,
83
+ options?: OpenAPITSOptions,
84
+ ): Promise<IrDeclaration[]>;
85
+ // The options without `irCallbacks` get their own signature: TypeScript does not pick a member of
86
+ // `GeneratorOptions | undefined` (an optional parameter) by a key that the object leaves out, so
87
+ // inline callbacks had implicit `any` parameters (TS7006). `irCallbacks: true` picks the member
88
+ // here. This signature is last, so `Parameters<typeof openapiTS>` reads it.
89
+ /** Convert an OpenAPI schema to IR declarations, with either form of the options. */
90
+ export default function openapiTS(
91
+ source: SchemaSource,
92
+ options?: GeneratorOptions,
93
+ ): Promise<IrDeclaration[]>;
81
94
  export default async function openapiTS(
82
95
  source: SchemaSource,
83
96
  options: GeneratorOptions = {},
@@ -0,0 +1,22 @@
1
+ import { ERROR_CODES, OpenAPITypesafeError } from "../domain/errors.js";
2
+
3
+ /** The `OPTION_INVALID` errors of the Node API's options, checked before anything loads. */
4
+
5
+ /** What a value is, for the error; a URL is named by its protocol only, as it may hold secrets. */
6
+ export function kindOf(value: unknown): string {
7
+ if (value instanceof URL) {
8
+ return `a ${value.protocol} URL`;
9
+ }
10
+ if (Array.isArray(value)) {
11
+ return "a list";
12
+ }
13
+ return value === null ? "null" : `a ${typeof value}`;
14
+ }
15
+
16
+ export function optionInvalid(option: string, expected: string, got: string): OpenAPITypesafeError {
17
+ return new OpenAPITypesafeError({
18
+ code: ERROR_CODES.OPTION_INVALID,
19
+ message: `The ${option} option must be ${expected}; got ${got}.`,
20
+ details: { option },
21
+ });
22
+ }
@@ -1,9 +1,9 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath, pathToFileURL } from "node:url";
3
3
  import type { RefAccess } from "../application/ports/schema-loader.js";
4
- import { ERROR_CODES, OpenAPITypesafeError } from "../domain/errors.js";
5
4
  import { hasUrlScheme } from "../domain/location-text.js";
6
5
  import type { GeneratorOptions } from "../types.js";
6
+ import { kindOf, optionInvalid } from "./option-errors.js";
7
7
 
8
8
  /**
9
9
  * The `$ref` limits of the options (threat model R1, R2, ADR-017), checked before anything loads.
@@ -13,36 +13,29 @@ import type { GeneratorOptions } from "../types.js";
13
13
 
14
14
  const FILE_PROTOCOL = "file:";
15
15
 
16
- /** What a value is, for the error; a URL is named by its protocol only, as it may hold secrets. */
17
- function kindOf(value: unknown): string {
18
- if (value instanceof URL) {
19
- return `a ${value.protocol} URL`;
20
- }
21
- if (Array.isArray(value)) {
22
- return "a list";
23
- }
24
- return value === null ? "null" : `a ${typeof value}`;
25
- }
26
-
27
- function optionInvalid(option: string, expected: string, got: string): OpenAPITypesafeError {
28
- return new OpenAPITypesafeError({
29
- code: ERROR_CODES.OPTION_INVALID,
30
- message: `The ${option} option must be ${expected}; got ${got}.`,
31
- details: { option },
32
- });
33
- }
34
-
35
16
  const LOCAL_ROOT_EXPECTED = "a directory path or a file: URL";
17
+ const RELATIVE_ROOT_EXPECTED = "an absolute path or a file: URL when cwd is no file: URL";
36
18
 
37
19
  /**
38
20
  * A string as the CLI reads `--local-ref-root`: a URL when it has a scheme (`file:///specs/`),
39
- * else a path that resolves against the cwd. Undefined when it does not parse as a URL.
21
+ * else a path that resolves against the cwd. Undefined when it does not parse as a URL. A
22
+ * relative path needs a local cwd: with an `http:` cwd it names no local directory.
40
23
  */
41
24
  function localRootUrlOf(value: string, cwd: URL): URL | undefined {
42
- if (!hasUrlScheme(value)) {
43
- return pathToFileURL(path.resolve(fileURLToPath(cwd), value));
25
+ if (hasUrlScheme(value)) {
26
+ return URL.parse(value, cwd.href) ?? undefined;
27
+ }
28
+ if (path.isAbsolute(value)) {
29
+ return pathToFileURL(value);
30
+ }
31
+ if (cwd.protocol !== FILE_PROTOCOL) {
32
+ throw optionInvalid(
33
+ "localRefRoot",
34
+ RELATIVE_ROOT_EXPECTED,
35
+ `a relative path, and cwd is ${kindOf(cwd)}`,
36
+ );
44
37
  }
45
- return URL.parse(value, cwd.href) ?? undefined;
38
+ return pathToFileURL(path.resolve(fileURLToPath(cwd), value));
46
39
  }
47
40
 
48
41
  /** The local root as a `file:` URL; the option is set, so it is not undefined. */
@@ -17,4 +17,9 @@ export interface RedoclyConfigSource {
17
17
  * when the ignore file of the working directory is bad.
18
18
  */
19
19
  createDefault(): Promise<RedoclyConfig>;
20
+ /**
21
+ * The config one API of `config` is linted with: the API's own `rules` (and other lint
22
+ * settings) under `apis.<name>` in place of the root's, as `redocly lint <name>` reads them.
23
+ */
24
+ forApi(config: RedoclyConfig, apiName: string): RedoclyConfig;
20
25
  }
@@ -2,6 +2,7 @@ import { irRaw } from "../../../domain/ir/constructors.js";
2
2
  import type { IrDeclaration } from "../../../domain/ir/nodes.js";
3
3
  import type { PathsObject } from "../../../domain/openapi/path-item-object.js";
4
4
  import { documentEntries } from "../document-entries.js";
5
+ import { HELPER_TYPES } from "../helper-types.js";
5
6
  import type { TransformContext } from "../transform-context.js";
6
7
  import type { PathKeyPart } from "./path-key-overlaps.js";
7
8
  import { booleanExpandedKeys, pathTemplateParts, templateText } from "./path-template-key.js";
@@ -11,7 +12,8 @@ import { isUntakenKey, pathKeyOf } from "./paths.js";
11
12
  * `--path-params-as-types` and `$ref`s into a templated path. Such a path is an index signature
12
13
  * of `paths` (`` [path: `/pets/${number}`] ``), so `paths["/pets/{id}"]`, the type of a `$ref`
13
14
  * to it, names no key: `{id}` is no number (TS2339). The reference indexes `paths` by the
14
- * template instead, which reaches the signature. With only `${string}` placeholders the URL
15
+ * template instead, which reaches the signature; so does the type of an `--enum-values` array,
16
+ * which indexes `FlattenedDeepRequired<paths>`. With only `${string}` placeholders the URL
15
17
  * itself matches the template and the output compiles, so it stays as it is.
16
18
  */
17
19
 
@@ -64,16 +66,35 @@ function isNode(value: unknown): value is Readonly<Record<string, unknown>> {
64
66
  return typeof value === "object" && value !== null;
65
67
  }
66
68
 
69
+ function isBarePathsReference(node: unknown): boolean {
70
+ const reference = node as Readonly<Record<string, unknown>> | undefined;
71
+ return (
72
+ reference?.kind === "reference" &&
73
+ reference.name === PATHS_TYPE_NAME &&
74
+ (reference.typeArguments as readonly unknown[] | undefined)?.length === 0
75
+ );
76
+ }
77
+
78
+ /**
79
+ * The `paths` root as a `$ref` or an `--enum-values` array indexes it: `paths`, or
80
+ * `FlattenedDeepRequired<paths>` (`rootType`), whose mapped keys are those of `paths`.
81
+ */
82
+ function isPathsRoot(node: unknown): boolean {
83
+ const reference = node as Readonly<Record<string, unknown>> | undefined;
84
+ const typeArguments = reference?.typeArguments as readonly unknown[] | undefined;
85
+ const isDeepRoot =
86
+ reference?.kind === "reference" &&
87
+ reference.name === HELPER_TYPES.flattenedDeepRequired.name &&
88
+ typeArguments?.length === 1 &&
89
+ isBarePathsReference(typeArguments[0]);
90
+ return isDeepRoot || isBarePathsReference(node);
91
+ }
92
+
67
93
  /** `paths["<url>"]`: an index of the `paths` root by a string literal. */
68
94
  function pathsIndexUrl(node: Readonly<Record<string, unknown>>): string | undefined {
69
- const object = node.object as Readonly<Record<string, unknown>> | undefined;
70
95
  const index = node.index as Readonly<Record<string, unknown>> | undefined;
71
96
  const isPathsIndex =
72
- node.kind === "indexedAccess" &&
73
- object?.kind === "reference" &&
74
- object.name === PATHS_TYPE_NAME &&
75
- (object.typeArguments as readonly unknown[] | undefined)?.length === 0 &&
76
- index?.kind === "stringLiteral";
97
+ node.kind === "indexedAccess" && isPathsRoot(node.object) && index?.kind === "stringLiteral";
77
98
  return isPathsIndex ? (index.value as string) : undefined;
78
99
  }
79
100
 
@@ -8,6 +8,7 @@ import {
8
8
  type LocatedNode,
9
9
  } from "./located-node.js";
10
10
  import {
11
+ hasKeptProperties,
11
12
  isExcludedProperty,
12
13
  isMarkedProperty,
13
14
  isNullableSchema,
@@ -29,8 +30,16 @@ export type GeneratedMember =
29
30
  /** `$Read<T>` or `$Write<T>` (`isMarkedProperty`): no member but the marker's own. */
30
31
  readonly isMarked: boolean;
31
32
  }
32
- /** An index signature; `node` is undefined when its value type is `unknown`. */
33
- | { readonly kind: "index"; readonly node: unknown; readonly location: string | undefined }
33
+ /**
34
+ * An index signature; `node` is undefined when its value type is `unknown`. `isOtherType`: the
35
+ * type read is not the type of the node the key names (`indexMember`).
36
+ */
37
+ | {
38
+ readonly kind: "index";
39
+ readonly node: unknown;
40
+ readonly location: string | undefined;
41
+ readonly isOtherType: boolean;
42
+ }
34
43
  | { readonly kind: "missing" }
35
44
  | { readonly kind: "opaque" };
36
45
 
@@ -91,19 +100,51 @@ function isBuiltInMember(key: string, prototype: object): boolean {
91
100
  return key in prototype;
92
101
  }
93
102
 
94
- function indexMember(owner: LocatedNode & { node: JsonObject }, options: SchemaOptions) {
103
+ /** `additionalProperties: true` or `{}` (also as a pattern's schema): a value of `unknown`. */
104
+ function isUnknownValue(value: unknown): boolean {
105
+ return value === true || (isJsonObject(value) && Object.keys(value).length === 0);
106
+ }
107
+
108
+ /** The value schemas of an index signature none of whose values is `unknown`, located. */
109
+ function typedIndexValues(owner: LocatedNode & { node: JsonObject }): LocatedNode[] {
95
110
  const { additionalProperties: values, patternProperties: patterns } = owner.node;
96
- if (isJsonObject(patterns) && Object.keys(patterns).length > 0) {
97
- return OPAQUE;
111
+ const located: LocatedNode[] = [];
112
+ if (isJsonObject(values)) {
113
+ located.push({ node: values, location: childLocation(owner.location, "additionalProperties") });
98
114
  }
99
- if (isJsonObject(values) && Object.keys(values).length > 0) {
100
- const location = childLocation(owner.location, "additionalProperties");
101
- return { kind: "index", node: values, location } satisfies GeneratedMember;
115
+ for (const [pattern, node] of isJsonObject(patterns) ? Object.entries(patterns) : []) {
116
+ located.push({ node, location: childLocation(owner.location, "patternProperties", pattern) });
117
+ }
118
+ return located;
119
+ }
120
+
121
+ /**
122
+ * The index signature of an object schema, or null without one. Its type is the union of its
123
+ * value schemas' types (`unknown` if one is `unknown`); a union of several counts as opaque. It
124
+ * also holds the kept property types (`withPropertyTypes`), and a key that names a property
125
+ * `--exclude-deprecated` left out reads it instead of that property: both are `isOtherType`.
126
+ */
127
+ function indexMember(
128
+ owner: LocatedNode & { node: JsonObject },
129
+ isExcludedKey: boolean,
130
+ input: MemberInput,
131
+ ): GeneratedMember | null {
132
+ const { additionalProperties: values, patternProperties: patterns } = owner.node;
133
+ const patternValues = isJsonObject(patterns) ? Object.values(patterns) : [];
134
+ const isUnknown =
135
+ isUnknownValue(values) ||
136
+ patternValues.some(isUnknownValue) ||
137
+ (!values && input.options.additionalProperties);
138
+ if (isUnknown) {
139
+ return { kind: "index", node: undefined, location: undefined, isOtherType: isExcludedKey };
140
+ }
141
+ const typed = typedIndexValues(owner);
142
+ const [only] = typed;
143
+ if (only === undefined || typed.length > 1) {
144
+ return only === undefined ? null : OPAQUE;
102
145
  }
103
- const isImplicit = values === true || (isJsonObject(values) && Object.keys(values).length === 0);
104
- return isImplicit || (!values && options.additionalProperties)
105
- ? ({ kind: "index", node: undefined, location: undefined } satisfies GeneratedMember)
106
- : null;
146
+ const isOtherType = isExcludedKey || hasKeptProperties(owner.node, input.options, input.refs);
147
+ return { kind: "index", ...only, isOtherType };
107
148
  }
108
149
 
109
150
  function objectMember(
@@ -114,7 +155,9 @@ function objectMember(
114
155
  const { options } = input;
115
156
  const { node } = owner;
116
157
  const properties = isJsonObject(node.properties) ? node.properties : {};
117
- if (Object.hasOwn(properties, key) && !isExcludedProperty(properties[key], options, input.refs)) {
158
+ const isExcludedKey =
159
+ Object.hasOwn(properties, key) && isExcludedProperty(properties[key], options, input.refs);
160
+ if (Object.hasOwn(properties, key) && !isExcludedKey) {
118
161
  if (isJsonObject(node.discriminator)) {
119
162
  return OPAQUE;
120
163
  }
@@ -130,7 +173,7 @@ function objectMember(
130
173
  return OPAQUE;
131
174
  }
132
175
  return hasObjectKeywords(node)
133
- ? (indexMember(owner, options) ?? MISSING)
176
+ ? (indexMember(owner, isExcludedKey, input) ?? MISSING)
134
177
  : emptyObjectMember(node, options);
135
178
  }
136
179
 
@@ -18,8 +18,10 @@ import {
18
18
  /**
19
19
  * Whether the type the original wrote for a `$ref` (`refType`: every segment indexed by name,
20
20
  * `properties` skipped) surely fails to compile. It walks the document as TypeScript walks the
21
- * generated types and answers true only where TypeScript reports an error for sure; where the
22
- * answer depends on a shape it does not model, it answers false, so that output stays as it was.
21
+ * generated types and answers true only where TypeScript reports an error for sure, or where the
22
+ * type compiles but is read through an index signature that is not the target's type alone
23
+ * (`isOtherType`); where the answer depends on a shape it does not model, it answers false, so
24
+ * that output stays as it was.
23
25
  */
24
26
 
25
27
  type Step = "fails" | "compiles" | "continue";
@@ -42,7 +44,8 @@ function schemaStep(walk: OriginalWalk, segment: string, isLast: boolean, input:
42
44
  return { step: "continue" as Step, walk };
43
45
  }
44
46
  const member = generatedMember(walk, segment, input.options, input.refs);
45
- if (member.kind === "missing") {
47
+ // Such an index signature compiles, but its type is not the target's alone.
48
+ if (member.kind === "missing" || (member.kind === "index" && member.isOtherType)) {
46
49
  return { step: "fails" as Step, walk };
47
50
  }
48
51
  if (member.kind === "opaque") {
@@ -58,6 +58,15 @@ export function isExcludedProperty(
58
58
  return options.excludeDeprecated && isJsonObject(resolved) && Boolean(resolved.deprecated);
59
59
  }
60
60
 
61
+ /**
62
+ * Whether the generated type keeps one of the schema's properties. The index signature next to
63
+ * them then holds their types too (`withPropertyTypes`), so it is not its value schema's type.
64
+ */
65
+ export function hasKeptProperties(node: JsonObject, options: SchemaOptions, refs: RefResolver) {
66
+ const properties = isJsonObject(node.properties) ? node.properties : {};
67
+ return Object.values(properties).some((value) => !isExcludedProperty(value, options, refs));
68
+ }
69
+
61
70
  /**
62
71
  * Whether `--read-write-markers` wraps the type of a property or `$defs` member: one that is only
63
72
  * `readOnly` or only `writeOnly` is `$Read<T>` or `$Write<T>` (object-type.ts).
@@ -24,6 +24,7 @@ import {
24
24
  startsInSchema,
25
25
  } from "./pointer-walk.js";
26
26
  import {
27
+ hasKeptProperties,
27
28
  isExcludedProperty,
28
29
  isMarkedProperty,
29
30
  type Presence,
@@ -34,9 +35,10 @@ import {
34
35
  /**
35
36
  * The type of a `$ref` pointer as the generated types have it: the `schema` of a media type,
36
37
  * header or component parameter is its type, `items` is indexed by `number`,
37
- * `additionalProperties` by `string`, and a value that may be `undefined` or `null` is wrapped in
38
- * `NonNullable` before it is indexed. The walk stops at a segment with no type of its own
39
- * (`allOf/0`, `definitions/A`, operation `parameters/0`): the caller types the target itself.
38
+ * `additionalProperties` by `string` where the index signature is its type alone, and a value
39
+ * that may be `undefined` or `null` is wrapped in `NonNullable` before it is indexed. The walk
40
+ * stops at a segment with no type of its own (`allOf/0`, `definitions/A`, operation
41
+ * `parameters/0`): the caller types the target itself.
40
42
  */
41
43
 
42
44
  const DEFS_KEYWORD = "$defs";
@@ -133,6 +135,8 @@ function defsStep(walk: TypedWalk, owner: LocatedNode, isLast: boolean): TypedWa
133
135
  };
134
136
  }
135
137
 
138
+ const COMPOSITION_KEYWORDS = ["allOf", "anyOf", "oneOf"] as const;
139
+
136
140
  const SHAPED_KEYWORDS: ReadonlySet<string> = new Set([
137
141
  "properties",
138
142
  "items",
@@ -151,6 +155,21 @@ function hasKeywordMember(owner: unknown, segment: string): boolean {
151
155
  return segment === "items" ? isArrayShape(owner) : isObjectShape(owner);
152
156
  }
153
157
 
158
+ /**
159
+ * Whether `[string]` of the owner's type is its `additionalProperties` type. Not where the index
160
+ * signature also holds pattern or property types (`withPropertyTypes`), nor next to `allOf` /
161
+ * `anyOf` / `oneOf`, whose members may add an index signature or an inherited discriminator.
162
+ */
163
+ function isIndexedByValue(owner: unknown, input: TypedWalkInput): boolean {
164
+ if (!isJsonObject(owner)) {
165
+ return true;
166
+ }
167
+ const patterns = owner.patternProperties;
168
+ const hasPatterns = isJsonObject(patterns) && Object.keys(patterns).length > 0;
169
+ const isComposed = COMPOSITION_KEYWORDS.some((keyword) => keyword in owner);
170
+ return !hasPatterns && !isComposed && !hasKeptProperties(owner, input.options, input.refs);
171
+ }
172
+
154
173
  function keywordStep(walk: TypedWalk, segment: string, isLast: boolean, input: TypedWalkInput) {
155
174
  const owner = followLocated(walk, input.refs);
156
175
  if (!hasKeywordMember(owner.node, segment)) {
@@ -162,7 +181,10 @@ function keywordStep(walk: TypedWalk, segment: string, isLast: boolean, input: T
162
181
  case "items":
163
182
  return indexEntries(walk, "number", "items", input.refs);
164
183
  case "additionalProperties":
165
- return indexEntries(walk, "string", "additionalProperties", input.refs);
184
+ // Otherwise the walk stops, and the target is typed in place.
185
+ return isIndexedByValue(owner.node, input)
186
+ ? indexEntries(walk, "string", "additionalProperties", input.refs)
187
+ : null;
166
188
  case DEFS_KEYWORD:
167
189
  return defsStep(walk, owner, isLast);
168
190
  default:
@@ -1,5 +1,6 @@
1
1
  import type { ClaimedFile } from "../application/ports/file-system.js";
2
2
  import type { GeneratedTypes } from "./cli-dependencies.js";
3
+ import { ApiFailure } from "./cli-errors.js";
3
4
  import {
4
5
  type CliRun,
5
6
  duplicateOutputError,
@@ -84,10 +85,17 @@ function writeClaimed(run: CliRun, { api, claim }: ClaimedApi): unknown[] {
84
85
  : failures;
85
86
  }
86
87
 
88
+ /** The failures with the name of their API, which the CLI prints before each message. */
89
+ function namedFailures(failures: ApiFailures): ApiFailures {
90
+ return new Map(
91
+ [...failures].map(([name, list]) => [name, list.map((cause) => new ApiFailure(name, cause))]),
92
+ );
93
+ }
94
+
87
95
  /**
88
96
  * Write (or `--check`) every API unless its output is a file the run read or the file of another
89
- * API. Returns the failures by API name. Claims are released in reverse order, so a directory
90
- * one claim created is empty by the time it is removed.
97
+ * API. Returns the failures by API name, each an `ApiFailure`. Claims are released in reverse
98
+ * order, so a directory one claim created is empty by the time it is removed.
91
99
  */
92
100
  export function writeGeneratedApis(
93
101
  run: CliRun,
@@ -113,5 +121,5 @@ export function writeGeneratedApis(
113
121
  for (const entry of claimed.filter(({ api }) => !shared.has(api.name))) {
114
122
  failures.set(entry.api.name, writeClaimed(run, entry));
115
123
  }
116
- return failures;
124
+ return namedFailures(failures);
117
125
  }