@maroonedog/luq-codegen 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Luq Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,258 @@
1
+ # @maroonedog/luq-codegen
2
+
3
+ Turns a JSON Schema Draft-07 schema **object** into the **source** of a
4
+ TypeScript module that exports a Luq validator. An OpenAPI 3.0 component schema
5
+ is that shape, so a caller that has already parsed the document can hand one
6
+ straight in.
7
+
8
+ It writes source and returns it as a string. It does not read a file, parse
9
+ YAML, walk `paths` or `components`, or write anything to disk — the caller does
10
+ all of that. See [the worked example](#a-worked-example).
11
+
12
+ ## Status
13
+
14
+ - **Not published.** `npm install @maroonedog/luq-codegen` does not resolve.
15
+ The only way to use it today is from this checkout, with a `file:` specifier —
16
+ which is what [`examples/openapi`](../examples/openapi) does.
17
+ - **It is not a plugin for `@hey-api/openapi-ts`.** The name says otherwise;
18
+ nothing in `src/` refers to that project, and there is no `defineConfig` or
19
+ plugin handler here. The name is unfinished business, not a feature.
20
+ - **There is no CLI.** One exported function, called from your own script.
21
+ - It imports `@maroonedog/luq/schema-tooling` — the library's own schema
22
+ flattener — so that the paths it writes and the paths the run-time converter
23
+ produces come from the same code. `peerDependencies` therefore names
24
+ `@maroonedog/luq` `>=2.4.0`: no earlier release carries that subpath. If the
25
+ subpath ships under a different number, that range moves with it.
26
+
27
+ ## The API
28
+
29
+ ```ts
30
+ import { generateValidatorModule } from "@maroonedog/luq-codegen";
31
+ import type { GenerateOptions, GeneratedModule } from "@maroonedog/luq-codegen";
32
+
33
+ generateValidatorModule(schema: Draft07Schema, options: GenerateOptions): GeneratedModule;
34
+ ```
35
+
36
+ `schema` is the Draft-07 document, already an object in memory.
37
+
38
+ ### `GenerateOptions`
39
+
40
+ | Field | | What it is |
41
+ |---|---|---|
42
+ | `validatorName` | required | The name of the const to declare, e.g. `"orderValidator"`. |
43
+ | `typeExpression` | required | Emitted verbatim inside `.for<…>()`, e.g. `'components["schemas"]["Order"]'`. |
44
+ | `typeImport` | optional | One block of source emitted after `import { Builder }`. Omitted, the type named by `typeExpression` is assumed to be in scope already. |
45
+
46
+ ### `GeneratedModule`
47
+
48
+ | Field | What it is |
49
+ |---|---|
50
+ | `source` | The whole module text: the header comment, the skipped-keyword notice, the imports, the builder chain, a trailing newline. |
51
+ | `pluginExports` | The plugin export names used, deduplicated and sorted. Every one of them is both imported and `.use()`d in `source`, and nothing else is. |
52
+ | `skipped` | `{ path, keyword, reason }` for every keyword that produced no rule. `path` is `""` for the root. |
53
+
54
+ `ChainCall`, `FieldChain` and `SkippedKeyword` are exported as types as well.
55
+
56
+ Two things are normalised, and both are pinned by tests: the keywords within one
57
+ field are walked in sorted key order, and the plugin imports are sorted by export
58
+ name. So the same schema object always produces the same source.
59
+
60
+ **Field order is not normalised.** It follows the document's `properties` order,
61
+ so writing the same two properties the other way round produces the same rules in
62
+ the other order. If you diff generated output across regenerations, reorder a
63
+ property upstream and the diff will show it.
64
+
65
+ ## A worked example
66
+
67
+ [`examples/openapi`](../examples/openapi) is a running one — one
68
+ `openapi.yaml`, types from `openapi-typescript`, rules from this generator,
69
+ `npm run verify` to generate, typecheck and run it. Its README explains the
70
+ setup; the part that concerns this package is the call:
71
+
72
+ ```js
73
+ const document = parse(readFileSync(SPEC, "utf8"));
74
+ const schema = document.components.schemas.Order;
75
+
76
+ const { source, skipped } = generateValidatorModule(schema, {
77
+ validatorName: "orderValidator",
78
+ typeExpression: 'components["schemas"]["Order"]',
79
+ });
80
+ ```
81
+
82
+ The script picks the component out of the parsed document, prepends its own
83
+ `import type { components } from "./api.generated";` line, and writes `source`
84
+ to a file. What comes out is ordinary Luq:
85
+
86
+ ```ts
87
+ export const orderValidator = Builder()
88
+ .use(arrayMinLengthPlugin)
89
+ .use(numberMinPlugin)
90
+ // …
91
+ .for<components["schemas"]["Order"]>()
92
+ .v("id", (b) => b.string.required().uuid())
93
+ .v("customer.email", (b) => b.string.required().email())
94
+ .v("lines", (b) => b.array.required().minLength(1))
95
+ .v("lines[*].quantity", (b) => b.number.required().min(1))
96
+ .build();
97
+ ```
98
+
99
+ ## What a keyword becomes
100
+
101
+ | Keyword | Method | | Keyword | Method |
102
+ |---|---|---|---|---|
103
+ | `minLength` | `.min()` | | `maxItems` | `.maxLength()` |
104
+ | `maxLength` | `.max()` | | `uniqueItems: true` | `.unique()` |
105
+ | `pattern` | `.pattern()` | | `minProperties` | `.minProperties()` |
106
+ | `minimum` | `.min()` | | `maxProperties` | `.maxProperties()` |
107
+ | `maximum` | `.max()` | | `const` | `.literal()` |
108
+ | `multipleOf` | `.multipleOf()` | | `enum` | `.oneOf()` |
109
+ | `minItems` | `.minLength()` | | | |
110
+
111
+ `format` values that become a method: `email`, `uuid`, `uri` (`.url()`),
112
+ `hostname`, `ipv4`, `ipv6`, `date`, `date-time` (`.datetime()`), `time`,
113
+ `duration`, `json-pointer` (`.jsonPointer()`), `iri`. Every other `format` is
114
+ skipped by name.
115
+
116
+ `type` chooses the slot after `b.`: `string`, `number` (also for `integer`),
117
+ `boolean`, `array`, `object`. Anything else — no `type`, several types, `null`
118
+ alone — falls to `any`, which checks nothing.
119
+
120
+ Only the plugins actually used are imported, each from its own subpath
121
+ (`@maroonedog/luq/plugins/uuid`, never the barrel), so the generated module
122
+ pays for what it declares and nothing more.
123
+
124
+ ## Nothing is dropped in silence
125
+
126
+ A keyword that cannot become a rule is returned in `skipped` **and** named in a
127
+ comment at the top of the generated file:
128
+
129
+ ```
130
+ // Keywords in this schema that did not become rules:
131
+ // id: type — selects the slot; it is not a method
132
+ // customer: properties — expands into declarations for the child fields
133
+ // lines: items — expands into the declaration for array elements
134
+ // … one line per keyword; the real block for examples/openapi has twelve
135
+ ```
136
+
137
+ Most entries are structural rather than omissions: `type` chooses the slot, and
138
+ `properties`, `items` and `required` become declarations rather than rules. The
139
+ ones worth reading are the rest — a keyword with **no chain method** is a
140
+ constraint the document states and the generated validator does not check.
141
+
142
+ Three different things share that list, and a reader has to separate them:
143
+
144
+ - **Structural** — `type`, `properties`, `items`, `required`, `$ref`. Nothing
145
+ was lost; these decide the slot and the shape of the declarations instead of
146
+ becoming rules of their own.
147
+ - **Deliberate** — annotations (`title`, `description`, `default`, `example`,
148
+ `deprecated`, `readOnly`, `writeOnly`), and `uniqueItems: false`, which in
149
+ Draft-07 is the absence of a constraint.
150
+ - **Unexpressed** — the reason reads `no chain method corresponds to it` or
151
+ `no plugin corresponds to format "…"`. This is the set to read before
152
+ trusting the output: the document says something the validator does not
153
+ check.
154
+
155
+ `examples/openapi/scripts/generate.mjs` filters the first group out and prints
156
+ what is left, which is the shape to copy in your own script.
157
+
158
+ ## What it does not handle
159
+
160
+ A validator that quietly checks less than the document says is the failure
161
+ worth naming, so here is everything known to be missing.
162
+
163
+ **Keywords that never become a rule.** `exclusiveMinimum`, `exclusiveMaximum`,
164
+ `oneOf`, `anyOf`, `not`, `if`/`then`/`else`, `contains`, `dependencies`,
165
+ `propertyNames`, `patternProperties`, `additionalProperties`,
166
+ `additionalItems`, `contentEncoding`, `contentMediaType`. Each is reported in
167
+ `skipped`; none is checked.
168
+
169
+ **`format` values outside the twelve above** — `byte`, `int64`,
170
+ `uri-reference`, `regex`, `idn-email` and the rest. Reported by name, not
171
+ checked.
172
+
173
+ **A child-level `allOf` is dropped whole.** Only an `allOf` at the root is
174
+ folded into the schema before flattening. On a nested schema, both its
175
+ constraints *and* the properties it contributes disappear: the field gets a
176
+ bare `b.any.optional()` and no declarations for the subtree. The keyword is
177
+ listed in `skipped`, but its reason text says "folded away during flattening",
178
+ which is true only at the root.
179
+
180
+ **Tuple `items: [A, B]`** produces the array rule and no element declarations.
181
+
182
+ **`nullable: true` produces no rule.** On a required field the emitted
183
+ `.required()` rejects `null`, so the generated validator is stricter than the
184
+ document.
185
+
186
+ **`$ref` resolves only inside the object you pass.** A component schema
187
+ containing `$ref: "#/components/schemas/Line"` makes the function **throw**,
188
+ because there is no `components` under the root it was given. Passing the whole
189
+ document as the root does resolve it, at the cost of a root-level declaration —
190
+ see below. `#/definitions/…` inside a self-contained schema works.
191
+
192
+ **A constraint on a slotless field emits a method that slot does not have.**
193
+ `{ "a": { "minLength": 2 } }` — no `type` — becomes
194
+ `.v("a", (b) => b.any.optional().min(2))`, and `min` is not on the `any` slot.
195
+ The same happens for `enum` anywhere but a `string`, `number` or `boolean`
196
+ field, and for any rule-bearing keyword at the **root** — an array-rooted
197
+ schema with `minItems`, for instance, or a whole OpenAPI document handed in as
198
+ the root — which becomes `.v("", (b) => b.any.…)`.
199
+
200
+ This fails loudly rather than silently: the method is not in the slot's type,
201
+ so the generated file does not compile.
202
+
203
+ ```
204
+ error TS2339: Property 'min' does not exist on type 'FieldChain<…, "any", …>'
205
+ ```
206
+
207
+ That is still a broken generation. Give the field a `type` in the document, or
208
+ write that rule by hand.
209
+
210
+ **A nested `required` is lowered only when every ancestor object is itself
211
+ required.** In Draft-07 a subschema applies only to a value that exists, so
212
+ `{ properties: { a: { required: ["b"] } } }` accepts `{}`. Emitting
213
+ `.required()` on `"a.b"` would reject it and disagree with the run-time
214
+ converter. When an ancestor is not required the generator emits `.optional()`
215
+ instead and reports the omission in `skipped` with that reason. An array in the
216
+ path does not break the chain: an absent array has no elements, so no element
217
+ rule runs.
218
+
219
+ ## Generating, or converting at run time
220
+
221
+ This package writes source you commit and read. The library can also convert a
222
+ Draft-07 document at run time, with `fromJsonSchema` — that path handles the
223
+ whole of Draft-07 rather than the subset above, and is the right answer when
224
+ the document is only known at run time. See
225
+ [docs/guide/json-schema.md](../docs/guide/json-schema.md).
226
+
227
+ Generating is the right answer when the document is known now: the rules are
228
+ plain code you can diff and step through, and they are declared against the
229
+ generated type, so a spec change that renames a field breaks the compiler
230
+ instead of the validator.
231
+
232
+ ## Working on it
233
+
234
+ ```bash
235
+ npm install --ignore-scripts # links @maroonedog/luq from the repository root
236
+ npm run verify # typecheck, build, dist gates, tests
237
+ ```
238
+
239
+ `npm run build` emits `dist/` with the same shape as the library: `.js` is
240
+ CommonJS, `.mjs` is ESM, `.d.ts` beside them, one emitted module per source
241
+ module, nothing bundled. It reuses the root's own emit steps rather than
242
+ running a second build system.
243
+
244
+ The library must be built first (`npm run build` at the repository root),
245
+ because this package imports it by package specifier and resolves it through
246
+ its `dist`. CI runs both in that order.
247
+
248
+ `check:dist` runs the same two gates as the root package: no dynamic code in
249
+ anything shipped, and no private artefact or unresolvable **relative**
250
+ specifier in `dist`. It does not resolve bare ones, so the import of
251
+ `@maroonedog/luq/schema-tooling` that every emitted module carries is not its
252
+ business — that is covered by loading the built entries for real, which
253
+ `test/built-package.test.ts` does under both `require` and ESM.
254
+
255
+ The tests cover the generator, the keyword table against the library's, the
256
+ built CommonJS and ESM entries, and one end-to-end case that writes a generated
257
+ module to a temp directory, compiles it with `tsc --strict`, and runs it
258
+ against a good and a bad document.
@@ -0,0 +1,40 @@
1
+ /** One call of one method on the chain. */
2
+ export interface ChainCall {
3
+ /** The method to call, e.g. "min". */
4
+ readonly method: string;
5
+ /** The argument source, emitted verbatim, e.g. ["3"]. Empty gives `.min()`. */
6
+ readonly args: readonly string[];
7
+ /**
8
+ * The export name of the plugin carrying this method, e.g.
9
+ * "stringMinPlugin". The import statements are built by collecting these.
10
+ */
11
+ readonly pluginExport: string;
12
+ /** The subpath to import it from, e.g. "@maroonedog/luq/plugins/stringMin". */
13
+ readonly pluginSubpath: string;
14
+ }
15
+ /** One field's declaration, becoming `.v(path, b => b.<slot>....)`. */
16
+ export interface FieldChain {
17
+ /** The first argument of `.v()`, e.g. "items[*].sku". */
18
+ readonly path: string;
19
+ /** The slot name following `b.`, e.g. "string". */
20
+ readonly slot: string;
21
+ readonly calls: readonly ChainCall[];
22
+ /**
23
+ * The keywords not emitted, with reasons. Carried through so that dropping
24
+ * one is never silent, and printed in a comment in the output.
25
+ */
26
+ readonly skipped: readonly SkippedKeyword[];
27
+ }
28
+ export interface SkippedKeyword {
29
+ readonly keyword: string;
30
+ readonly reason: string;
31
+ }
32
+ export interface GeneratedModule {
33
+ readonly source: string;
34
+ /** The export names of the plugins used, deduplicated and sorted. */
35
+ readonly pluginExports: readonly string[];
36
+ /** Everything skipped, across all fields, for the caller to report. */
37
+ readonly skipped: readonly (SkippedKeyword & {
38
+ readonly path: string;
39
+ })[];
40
+ }
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ // ===========================================================================
3
+ // luq-codegen/src/generate/chain-call.types.ts
4
+ //
5
+ // The intermediate form of one generated chain. Going through it instead of
6
+ // concatenating strings is what makes the set of plugins to import countable
7
+ // afterwards. Built by concatenation, a missing import first shows up in the
8
+ // user's compiler rather than here.
9
+ // ===========================================================================
10
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,9 @@
1
+ // ===========================================================================
2
+ // luq-codegen/src/generate/chain-call.types.ts
3
+ //
4
+ // The intermediate form of one generated chain. Going through it instead of
5
+ // concatenating strings is what makes the set of plugins to import countable
6
+ // afterwards. Built by concatenation, a missing import first shows up in the
7
+ // user's compiler rather than here.
8
+ // ===========================================================================
9
+ export {};
@@ -0,0 +1,11 @@
1
+ import type { Draft07Schema } from "@maroonedog/luq/schema-tooling";
2
+ import type { GeneratedModule } from "./chain-call.types";
3
+ export interface GenerateOptions {
4
+ /** The name of the const to generate, e.g. "validateOrder". */
5
+ readonly validatorName: string;
6
+ /** The type name to put in `.for<T>()`, e.g. 'components["schemas"]["Order"]'. */
7
+ readonly typeExpression: string;
8
+ /** The line importing that type. Omitted, the type is assumed in scope. */
9
+ readonly typeImport?: string;
10
+ }
11
+ export declare function generateValidatorModule(schema: Draft07Schema, options: GenerateOptions): GeneratedModule;
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.generateValidatorModule = generateValidatorModule;
4
+ // ===========================================================================
5
+ // luq-codegen/src/generate/generate-validator-module.ts
6
+ //
7
+ // One schema to the source of a module exporting one validator.
8
+ //
9
+ // Flattening reuses the library's own. The generated code and the run-time
10
+ // conversion decide paths with the same function, which makes it structurally
11
+ // impossible for the two to disagree about what a path looks like.
12
+ // ===========================================================================
13
+ const schema_tooling_1 = require("@maroonedog/luq/schema-tooling");
14
+ const keyword_to_chain_call_1 = require("./keyword-to-chain-call");
15
+ const resolve_required_path_1 = require("./resolve-required-path");
16
+ const slot_for_schema_1 = require("./slot-for-schema");
17
+ const PRESENCE_REQUIRED = {
18
+ method: "required",
19
+ args: [],
20
+ pluginExport: "requiredPlugin",
21
+ pluginSubpath: "@maroonedog/luq/plugins/required",
22
+ };
23
+ const PRESENCE_OPTIONAL = {
24
+ method: "optional",
25
+ args: [],
26
+ pluginExport: "optionalPlugin",
27
+ pluginSubpath: "@maroonedog/luq/plugins/optional",
28
+ };
29
+ function toFieldChain(path, schema, isRequired) {
30
+ const calls = [isRequired ? PRESENCE_REQUIRED : PRESENCE_OPTIONAL];
31
+ const skipped = [];
32
+ // Walked in key order rather than order of appearance. Letting how the
33
+ // schema was written decide the chain order fills every diff with noise.
34
+ for (const keyword of Object.keys(schema).sort()) {
35
+ const outcome = (0, keyword_to_chain_call_1.keywordToChainCall)(keyword, schema[keyword]);
36
+ if (outcome.call !== undefined)
37
+ calls.push(outcome.call);
38
+ if (outcome.skipped !== undefined)
39
+ skipped.push(outcome.skipped);
40
+ }
41
+ return { path, slot: (0, slot_for_schema_1.slotForSchema)(schema), calls, skipped };
42
+ }
43
+ function renderChain(field) {
44
+ const body = field.calls
45
+ .map((call) => `.${call.method}(${call.args.join(", ")})`)
46
+ .join("");
47
+ return ` .v(${JSON.stringify(field.path)}, (b) => b.${field.slot}${body})`;
48
+ }
49
+ function renderImports(pluginExports, typeImport) {
50
+ const lines = ['import { Builder } from "@maroonedog/luq";'];
51
+ if (typeImport !== undefined)
52
+ lines.push(typeImport);
53
+ for (const entry of pluginExports) {
54
+ lines.push(`import { ${entry.name} } from ${JSON.stringify(entry.subpath)};`);
55
+ }
56
+ return lines.join("\n");
57
+ }
58
+ /**
59
+ * Skipped keywords are listed in a comment at the top of the output, so a
60
+ * reader can see what the generator dropped without leaving the file.
61
+ */
62
+ function renderSkippedNotice(skipped) {
63
+ if (skipped.length === 0)
64
+ return "";
65
+ const lines = skipped.map((entry) => `// ${entry.path || "(root)"}: ${entry.keyword} — ${entry.reason}`);
66
+ return [
67
+ "//",
68
+ "// Keywords in this schema that did not become rules:",
69
+ ...lines,
70
+ "",
71
+ ].join("\n");
72
+ }
73
+ function generateValidatorModule(schema, options) {
74
+ const declarations = (0, schema_tooling_1.flattenSchema)(schema, schema_tooling_1.readChildSchemas);
75
+ const fields = declarations.map((declaration) => {
76
+ // Flattening reports isRequired for the root's own keys only. A nested
77
+ // required is a rule on the object at run time, and the chain has nowhere
78
+ // to put that. See resolve-required-path.ts for when it can be lowered.
79
+ const safelyRequired = declaration.isRequired || (0, resolve_required_path_1.isSafelyRequired)(schema, declaration.path);
80
+ const field = toFieldChain(declaration.path, declaration.schema, safelyRequired);
81
+ if (safelyRequired || !(0, resolve_required_path_1.isListedByParent)(schema, declaration.path)) {
82
+ return field;
83
+ }
84
+ return {
85
+ ...field,
86
+ skipped: [
87
+ ...field.skipped,
88
+ {
89
+ keyword: "required",
90
+ reason: "the parent schema marks it required, but an ancestor object is " +
91
+ "not, so .required() would wrongly reject a document missing that " +
92
+ "ancestor; Draft-07 applies a subschema only to a value that " +
93
+ "exists, so this was emitted as optional",
94
+ },
95
+ ],
96
+ };
97
+ });
98
+ const byExport = new Map();
99
+ for (const field of fields) {
100
+ for (const call of field.calls)
101
+ byExport.set(call.pluginExport, call.pluginSubpath);
102
+ }
103
+ const pluginExports = [...byExport.entries()]
104
+ .map(([name, subpath]) => ({ name, subpath }))
105
+ .sort((left, right) => left.name.localeCompare(right.name));
106
+ const skipped = fields.flatMap((field) => field.skipped.map((entry) => ({ ...entry, path: field.path })));
107
+ const uses = pluginExports.map((entry) => ` .use(${entry.name})`).join("\n");
108
+ const chains = fields.map(renderChain).join("\n");
109
+ const source = [
110
+ "// Generated by @maroonedog/luq-codegen. Do not edit.",
111
+ "// Rules are declared against the generated type, so a spec change that",
112
+ "// renames a field makes this file stop compiling instead of drifting.",
113
+ renderSkippedNotice(skipped),
114
+ renderImports(pluginExports, options.typeImport),
115
+ "",
116
+ `export const ${options.validatorName} = Builder()`,
117
+ uses,
118
+ ` .for<${options.typeExpression}>()`,
119
+ chains,
120
+ " .build();",
121
+ "",
122
+ ]
123
+ .filter((part) => part !== "")
124
+ .join("\n");
125
+ return {
126
+ source,
127
+ pluginExports: pluginExports.map((entry) => entry.name),
128
+ skipped,
129
+ };
130
+ }
@@ -0,0 +1,127 @@
1
+ // ===========================================================================
2
+ // luq-codegen/src/generate/generate-validator-module.ts
3
+ //
4
+ // One schema to the source of a module exporting one validator.
5
+ //
6
+ // Flattening reuses the library's own. The generated code and the run-time
7
+ // conversion decide paths with the same function, which makes it structurally
8
+ // impossible for the two to disagree about what a path looks like.
9
+ // ===========================================================================
10
+ import { flattenSchema, readChildSchemas } from "@maroonedog/luq/schema-tooling";
11
+ import { keywordToChainCall } from "./keyword-to-chain-call.mjs";
12
+ import { isListedByParent, isSafelyRequired } from "./resolve-required-path.mjs";
13
+ import { slotForSchema } from "./slot-for-schema.mjs";
14
+ const PRESENCE_REQUIRED = {
15
+ method: "required",
16
+ args: [],
17
+ pluginExport: "requiredPlugin",
18
+ pluginSubpath: "@maroonedog/luq/plugins/required",
19
+ };
20
+ const PRESENCE_OPTIONAL = {
21
+ method: "optional",
22
+ args: [],
23
+ pluginExport: "optionalPlugin",
24
+ pluginSubpath: "@maroonedog/luq/plugins/optional",
25
+ };
26
+ function toFieldChain(path, schema, isRequired) {
27
+ const calls = [isRequired ? PRESENCE_REQUIRED : PRESENCE_OPTIONAL];
28
+ const skipped = [];
29
+ // Walked in key order rather than order of appearance. Letting how the
30
+ // schema was written decide the chain order fills every diff with noise.
31
+ for (const keyword of Object.keys(schema).sort()) {
32
+ const outcome = keywordToChainCall(keyword, schema[keyword]);
33
+ if (outcome.call !== undefined)
34
+ calls.push(outcome.call);
35
+ if (outcome.skipped !== undefined)
36
+ skipped.push(outcome.skipped);
37
+ }
38
+ return { path, slot: slotForSchema(schema), calls, skipped };
39
+ }
40
+ function renderChain(field) {
41
+ const body = field.calls
42
+ .map((call) => `.${call.method}(${call.args.join(", ")})`)
43
+ .join("");
44
+ return ` .v(${JSON.stringify(field.path)}, (b) => b.${field.slot}${body})`;
45
+ }
46
+ function renderImports(pluginExports, typeImport) {
47
+ const lines = ['import { Builder } from "@maroonedog/luq";'];
48
+ if (typeImport !== undefined)
49
+ lines.push(typeImport);
50
+ for (const entry of pluginExports) {
51
+ lines.push(`import { ${entry.name} } from ${JSON.stringify(entry.subpath)};`);
52
+ }
53
+ return lines.join("\n");
54
+ }
55
+ /**
56
+ * Skipped keywords are listed in a comment at the top of the output, so a
57
+ * reader can see what the generator dropped without leaving the file.
58
+ */
59
+ function renderSkippedNotice(skipped) {
60
+ if (skipped.length === 0)
61
+ return "";
62
+ const lines = skipped.map((entry) => `// ${entry.path || "(root)"}: ${entry.keyword} — ${entry.reason}`);
63
+ return [
64
+ "//",
65
+ "// Keywords in this schema that did not become rules:",
66
+ ...lines,
67
+ "",
68
+ ].join("\n");
69
+ }
70
+ export function generateValidatorModule(schema, options) {
71
+ const declarations = flattenSchema(schema, readChildSchemas);
72
+ const fields = declarations.map((declaration) => {
73
+ // Flattening reports isRequired for the root's own keys only. A nested
74
+ // required is a rule on the object at run time, and the chain has nowhere
75
+ // to put that. See resolve-required-path.ts for when it can be lowered.
76
+ const safelyRequired = declaration.isRequired || isSafelyRequired(schema, declaration.path);
77
+ const field = toFieldChain(declaration.path, declaration.schema, safelyRequired);
78
+ if (safelyRequired || !isListedByParent(schema, declaration.path)) {
79
+ return field;
80
+ }
81
+ return {
82
+ ...field,
83
+ skipped: [
84
+ ...field.skipped,
85
+ {
86
+ keyword: "required",
87
+ reason: "the parent schema marks it required, but an ancestor object is " +
88
+ "not, so .required() would wrongly reject a document missing that " +
89
+ "ancestor; Draft-07 applies a subschema only to a value that " +
90
+ "exists, so this was emitted as optional",
91
+ },
92
+ ],
93
+ };
94
+ });
95
+ const byExport = new Map();
96
+ for (const field of fields) {
97
+ for (const call of field.calls)
98
+ byExport.set(call.pluginExport, call.pluginSubpath);
99
+ }
100
+ const pluginExports = [...byExport.entries()]
101
+ .map(([name, subpath]) => ({ name, subpath }))
102
+ .sort((left, right) => left.name.localeCompare(right.name));
103
+ const skipped = fields.flatMap((field) => field.skipped.map((entry) => ({ ...entry, path: field.path })));
104
+ const uses = pluginExports.map((entry) => ` .use(${entry.name})`).join("\n");
105
+ const chains = fields.map(renderChain).join("\n");
106
+ const source = [
107
+ "// Generated by @maroonedog/luq-codegen. Do not edit.",
108
+ "// Rules are declared against the generated type, so a spec change that",
109
+ "// renames a field makes this file stop compiling instead of drifting.",
110
+ renderSkippedNotice(skipped),
111
+ renderImports(pluginExports, options.typeImport),
112
+ "",
113
+ `export const ${options.validatorName} = Builder()`,
114
+ uses,
115
+ ` .for<${options.typeExpression}>()`,
116
+ chains,
117
+ " .build();",
118
+ "",
119
+ ]
120
+ .filter((part) => part !== "")
121
+ .join("\n");
122
+ return {
123
+ source,
124
+ pluginExports: pluginExports.map((entry) => entry.name),
125
+ skipped,
126
+ };
127
+ }
@@ -0,0 +1,10 @@
1
+ import type { ChainCall, SkippedKeyword } from "./chain-call.types";
2
+ export interface KeywordOutcome {
3
+ readonly call?: ChainCall;
4
+ readonly skipped?: SkippedKeyword;
5
+ }
6
+ export declare function keywordToChainCall(keyword: string, value: unknown): KeywordOutcome;
7
+ /** Exported so a test can inspect the table itself. */
8
+ export declare const BOUND_KEYWORDS: readonly string[];
9
+ export declare const BOUND_FORMATS: readonly string[];
10
+ export declare const STRUCTURAL_KEYWORDS: readonly string[];
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.STRUCTURAL_KEYWORDS = exports.BOUND_FORMATS = exports.BOUND_KEYWORDS = void 0;
4
+ exports.keywordToChainCall = keywordToChainCall;
5
+ /** Keyword to the method it becomes, and the plugin carrying that method. */
6
+ const BINDINGS = {
7
+ minLength: { method: "min", pluginExport: "stringMinPlugin", subpathName: "stringMin" },
8
+ maxLength: { method: "max", pluginExport: "stringMaxPlugin", subpathName: "stringMax" },
9
+ pattern: { method: "pattern", pluginExport: "stringPatternPlugin", subpathName: "stringPattern" },
10
+ minimum: { method: "min", pluginExport: "numberMinPlugin", subpathName: "numberMin" },
11
+ maximum: { method: "max", pluginExport: "numberMaxPlugin", subpathName: "numberMax" },
12
+ multipleOf: { method: "multipleOf", pluginExport: "numberMultipleOfPlugin", subpathName: "numberMultipleOf" },
13
+ minItems: { method: "minLength", pluginExport: "arrayMinLengthPlugin", subpathName: "arrayMinLength" },
14
+ maxItems: { method: "maxLength", pluginExport: "arrayMaxLengthPlugin", subpathName: "arrayMaxLength" },
15
+ uniqueItems: { method: "unique", pluginExport: "arrayUniquePlugin", subpathName: "arrayUnique" },
16
+ minProperties: { method: "minProperties", pluginExport: "objectMinPropertiesPlugin", subpathName: "objectMinProperties" },
17
+ maxProperties: { method: "maxProperties", pluginExport: "objectMaxPropertiesPlugin", subpathName: "objectMaxProperties" },
18
+ const: { method: "literal", pluginExport: "literalPlugin", subpathName: "literal" },
19
+ enum: { method: "oneOf", pluginExport: "oneOfPlugin", subpathName: "oneOf" },
20
+ };
21
+ /** A format value to its method and plugin, paired with the library's format map. */
22
+ const FORMAT_BINDINGS = {
23
+ email: { method: "email", pluginExport: "stringEmailPlugin", subpathName: "stringEmail" },
24
+ uuid: { method: "uuid", pluginExport: "uuidPlugin", subpathName: "uuid" },
25
+ uri: { method: "url", pluginExport: "stringUrlPlugin", subpathName: "stringUrl" },
26
+ hostname: { method: "hostname", pluginExport: "stringHostnamePlugin", subpathName: "stringHostname" },
27
+ ipv4: { method: "ipv4", pluginExport: "stringIpv4Plugin", subpathName: "stringIpv4" },
28
+ ipv6: { method: "ipv6", pluginExport: "stringIpv6Plugin", subpathName: "stringIpv6" },
29
+ date: { method: "date", pluginExport: "stringDatePlugin", subpathName: "stringDate" },
30
+ "date-time": { method: "datetime", pluginExport: "stringDatetimePlugin", subpathName: "stringDatetime" },
31
+ time: { method: "time", pluginExport: "stringTimePlugin", subpathName: "stringTime" },
32
+ duration: { method: "duration", pluginExport: "stringDurationPlugin", subpathName: "stringDuration" },
33
+ "json-pointer": { method: "jsonPointer", pluginExport: "stringJsonPointerPlugin", subpathName: "stringJsonPointer" },
34
+ iri: { method: "iri", pluginExport: "stringIriPlugin", subpathName: "stringIri" },
35
+ };
36
+ /** Handled structurally, so never turned into a chain call here. */
37
+ const STRUCTURAL = {
38
+ type: "selects the slot; it is not a method",
39
+ properties: "expands into declarations for the child fields",
40
+ items: "expands into the declaration for array elements",
41
+ required: "held by the parent, as presence",
42
+ allOf: "folded away during flattening",
43
+ $ref: "resolved before flattening",
44
+ title: "annotation; not validated",
45
+ description: "annotation; not validated",
46
+ default: "annotation; not validated",
47
+ example: "annotation; not validated",
48
+ examples: "annotation; not validated",
49
+ deprecated: "annotation; not validated",
50
+ readOnly: "an OpenAPI direction marker; not lowered into validation",
51
+ writeOnly: "an OpenAPI direction marker; not lowered into validation",
52
+ nullable: "handled as presence",
53
+ };
54
+ function subpathOf(name) {
55
+ return `@maroonedog/luq/plugins/${name}`;
56
+ }
57
+ /**
58
+ * Values are embedded as source, so they go through JSON.stringify. Never as
59
+ * a regular-expression literal: a pattern is passed as a string, and making it
60
+ * a literal would have the escapes interpreted twice.
61
+ */
62
+ function renderValue(value) {
63
+ return JSON.stringify(value);
64
+ }
65
+ function keywordToChainCall(keyword, value) {
66
+ const structural = STRUCTURAL[keyword];
67
+ if (structural !== undefined) {
68
+ return { skipped: { keyword, reason: structural } };
69
+ }
70
+ if (keyword === "format") {
71
+ if (typeof value !== "string") {
72
+ return { skipped: { keyword, reason: "the format value is not a string" } };
73
+ }
74
+ const binding = FORMAT_BINDINGS[value];
75
+ if (binding === undefined) {
76
+ return {
77
+ skipped: { keyword, reason: `no plugin corresponds to format "${value}"` },
78
+ };
79
+ }
80
+ return {
81
+ call: {
82
+ method: binding.method,
83
+ args: [],
84
+ pluginExport: binding.pluginExport,
85
+ pluginSubpath: subpathOf(binding.subpathName),
86
+ },
87
+ };
88
+ }
89
+ // In Draft-07 uniqueItems: false disables the constraint, so emit no rule.
90
+ if (keyword === "uniqueItems" && value !== true) {
91
+ return { skipped: { keyword, reason: "uniqueItems: false is not a constraint" } };
92
+ }
93
+ const binding = BINDINGS[keyword];
94
+ if (binding === undefined) {
95
+ return {
96
+ skipped: { keyword, reason: "no chain method corresponds to it" },
97
+ };
98
+ }
99
+ return {
100
+ call: {
101
+ method: binding.method,
102
+ args: keyword === "uniqueItems" ? [] : [renderValue(value)],
103
+ pluginExport: binding.pluginExport,
104
+ pluginSubpath: subpathOf(binding.subpathName),
105
+ },
106
+ };
107
+ }
108
+ /** Exported so a test can inspect the table itself. */
109
+ exports.BOUND_KEYWORDS = Object.keys(BINDINGS);
110
+ exports.BOUND_FORMATS = Object.keys(FORMAT_BINDINGS);
111
+ exports.STRUCTURAL_KEYWORDS = Object.keys(STRUCTURAL);
@@ -0,0 +1,107 @@
1
+ /** Keyword to the method it becomes, and the plugin carrying that method. */
2
+ const BINDINGS = {
3
+ minLength: { method: "min", pluginExport: "stringMinPlugin", subpathName: "stringMin" },
4
+ maxLength: { method: "max", pluginExport: "stringMaxPlugin", subpathName: "stringMax" },
5
+ pattern: { method: "pattern", pluginExport: "stringPatternPlugin", subpathName: "stringPattern" },
6
+ minimum: { method: "min", pluginExport: "numberMinPlugin", subpathName: "numberMin" },
7
+ maximum: { method: "max", pluginExport: "numberMaxPlugin", subpathName: "numberMax" },
8
+ multipleOf: { method: "multipleOf", pluginExport: "numberMultipleOfPlugin", subpathName: "numberMultipleOf" },
9
+ minItems: { method: "minLength", pluginExport: "arrayMinLengthPlugin", subpathName: "arrayMinLength" },
10
+ maxItems: { method: "maxLength", pluginExport: "arrayMaxLengthPlugin", subpathName: "arrayMaxLength" },
11
+ uniqueItems: { method: "unique", pluginExport: "arrayUniquePlugin", subpathName: "arrayUnique" },
12
+ minProperties: { method: "minProperties", pluginExport: "objectMinPropertiesPlugin", subpathName: "objectMinProperties" },
13
+ maxProperties: { method: "maxProperties", pluginExport: "objectMaxPropertiesPlugin", subpathName: "objectMaxProperties" },
14
+ const: { method: "literal", pluginExport: "literalPlugin", subpathName: "literal" },
15
+ enum: { method: "oneOf", pluginExport: "oneOfPlugin", subpathName: "oneOf" },
16
+ };
17
+ /** A format value to its method and plugin, paired with the library's format map. */
18
+ const FORMAT_BINDINGS = {
19
+ email: { method: "email", pluginExport: "stringEmailPlugin", subpathName: "stringEmail" },
20
+ uuid: { method: "uuid", pluginExport: "uuidPlugin", subpathName: "uuid" },
21
+ uri: { method: "url", pluginExport: "stringUrlPlugin", subpathName: "stringUrl" },
22
+ hostname: { method: "hostname", pluginExport: "stringHostnamePlugin", subpathName: "stringHostname" },
23
+ ipv4: { method: "ipv4", pluginExport: "stringIpv4Plugin", subpathName: "stringIpv4" },
24
+ ipv6: { method: "ipv6", pluginExport: "stringIpv6Plugin", subpathName: "stringIpv6" },
25
+ date: { method: "date", pluginExport: "stringDatePlugin", subpathName: "stringDate" },
26
+ "date-time": { method: "datetime", pluginExport: "stringDatetimePlugin", subpathName: "stringDatetime" },
27
+ time: { method: "time", pluginExport: "stringTimePlugin", subpathName: "stringTime" },
28
+ duration: { method: "duration", pluginExport: "stringDurationPlugin", subpathName: "stringDuration" },
29
+ "json-pointer": { method: "jsonPointer", pluginExport: "stringJsonPointerPlugin", subpathName: "stringJsonPointer" },
30
+ iri: { method: "iri", pluginExport: "stringIriPlugin", subpathName: "stringIri" },
31
+ };
32
+ /** Handled structurally, so never turned into a chain call here. */
33
+ const STRUCTURAL = {
34
+ type: "selects the slot; it is not a method",
35
+ properties: "expands into declarations for the child fields",
36
+ items: "expands into the declaration for array elements",
37
+ required: "held by the parent, as presence",
38
+ allOf: "folded away during flattening",
39
+ $ref: "resolved before flattening",
40
+ title: "annotation; not validated",
41
+ description: "annotation; not validated",
42
+ default: "annotation; not validated",
43
+ example: "annotation; not validated",
44
+ examples: "annotation; not validated",
45
+ deprecated: "annotation; not validated",
46
+ readOnly: "an OpenAPI direction marker; not lowered into validation",
47
+ writeOnly: "an OpenAPI direction marker; not lowered into validation",
48
+ nullable: "handled as presence",
49
+ };
50
+ function subpathOf(name) {
51
+ return `@maroonedog/luq/plugins/${name}`;
52
+ }
53
+ /**
54
+ * Values are embedded as source, so they go through JSON.stringify. Never as
55
+ * a regular-expression literal: a pattern is passed as a string, and making it
56
+ * a literal would have the escapes interpreted twice.
57
+ */
58
+ function renderValue(value) {
59
+ return JSON.stringify(value);
60
+ }
61
+ export function keywordToChainCall(keyword, value) {
62
+ const structural = STRUCTURAL[keyword];
63
+ if (structural !== undefined) {
64
+ return { skipped: { keyword, reason: structural } };
65
+ }
66
+ if (keyword === "format") {
67
+ if (typeof value !== "string") {
68
+ return { skipped: { keyword, reason: "the format value is not a string" } };
69
+ }
70
+ const binding = FORMAT_BINDINGS[value];
71
+ if (binding === undefined) {
72
+ return {
73
+ skipped: { keyword, reason: `no plugin corresponds to format "${value}"` },
74
+ };
75
+ }
76
+ return {
77
+ call: {
78
+ method: binding.method,
79
+ args: [],
80
+ pluginExport: binding.pluginExport,
81
+ pluginSubpath: subpathOf(binding.subpathName),
82
+ },
83
+ };
84
+ }
85
+ // In Draft-07 uniqueItems: false disables the constraint, so emit no rule.
86
+ if (keyword === "uniqueItems" && value !== true) {
87
+ return { skipped: { keyword, reason: "uniqueItems: false is not a constraint" } };
88
+ }
89
+ const binding = BINDINGS[keyword];
90
+ if (binding === undefined) {
91
+ return {
92
+ skipped: { keyword, reason: "no chain method corresponds to it" },
93
+ };
94
+ }
95
+ return {
96
+ call: {
97
+ method: binding.method,
98
+ args: keyword === "uniqueItems" ? [] : [renderValue(value)],
99
+ pluginExport: binding.pluginExport,
100
+ pluginSubpath: subpathOf(binding.subpathName),
101
+ },
102
+ };
103
+ }
104
+ /** Exported so a test can inspect the table itself. */
105
+ export const BOUND_KEYWORDS = Object.keys(BINDINGS);
106
+ export const BOUND_FORMATS = Object.keys(FORMAT_BINDINGS);
107
+ export const STRUCTURAL_KEYWORDS = Object.keys(STRUCTURAL);
@@ -0,0 +1,18 @@
1
+ import type { Draft07Schema } from "@maroonedog/luq/schema-tooling";
2
+ /** "items[*].sku" -> ["items[*]", "sku"]; "[*]" stays attached to its key. */
3
+ export declare function splitDeclaredPath(path: string): readonly string[];
4
+ /**
5
+ * Whether `.required()` may be emitted for that path.
6
+ *
7
+ * False as soon as one ancestor object along the way is not itself required.
8
+ * On false the caller emits `.optional()` instead and reports the omission.
9
+ */
10
+ export declare function isSafelyRequired(root: Draft07Schema, path: string): boolean;
11
+ /**
12
+ * Whether the immediate parent schema lists that key in its required set.
13
+ *
14
+ * When the path is not safely required, this separates "it was never written
15
+ * as required" from "it was, but an ancestor makes it unlowerable". Only the
16
+ * second is reported as skipped.
17
+ */
18
+ export declare function isListedByParent(root: Draft07Schema, path: string): boolean;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.splitDeclaredPath = splitDeclaredPath;
4
+ exports.isSafelyRequired = isSafelyRequired;
5
+ exports.isListedByParent = isListedByParent;
6
+ const schema_tooling_1 = require("@maroonedog/luq/schema-tooling");
7
+ /** "items[*].sku" -> ["items[*]", "sku"]; "[*]" stays attached to its key. */
8
+ function splitDeclaredPath(path) {
9
+ return path === "" ? [] : path.split(".");
10
+ }
11
+ function propertyOf(schema, key) {
12
+ const properties = schema.properties;
13
+ if (properties === null || typeof properties !== "object")
14
+ return undefined;
15
+ const child = properties[key];
16
+ if (!(0, schema_tooling_1.isDraft07Schema)(child))
17
+ return undefined;
18
+ return (0, schema_tooling_1.isSchemaObject)(child) ? child : undefined;
19
+ }
20
+ function itemsOf(schema) {
21
+ const items = schema.items;
22
+ if (!(0, schema_tooling_1.isDraft07Schema)(items))
23
+ return undefined;
24
+ return (0, schema_tooling_1.isSchemaObject)(items) ? items : undefined;
25
+ }
26
+ function listsAsRequired(schema, key) {
27
+ const required = schema.required;
28
+ return Array.isArray(required) && required.includes(key);
29
+ }
30
+ /**
31
+ * Whether `.required()` may be emitted for that path.
32
+ *
33
+ * False as soon as one ancestor object along the way is not itself required.
34
+ * On false the caller emits `.optional()` instead and reports the omission.
35
+ */
36
+ function isSafelyRequired(root, path) {
37
+ if (!(0, schema_tooling_1.isSchemaObject)(root))
38
+ return false;
39
+ const steps = splitDeclaredPath(path);
40
+ if (steps.length === 0)
41
+ return false;
42
+ let current = root;
43
+ for (let index = 0; index < steps.length; index += 1) {
44
+ const step = steps[index] ?? "";
45
+ const wildcards = (step.match(/\[\*\]/g) ?? []).length;
46
+ const key = step.slice(0, step.length - wildcards * 3);
47
+ if (!listsAsRequired(current, key))
48
+ return false;
49
+ const child = propertyOf(current, key);
50
+ if (child === undefined)
51
+ return index === steps.length - 1;
52
+ let descended = child;
53
+ for (let level = 0; level < wildcards; level += 1) {
54
+ const element = itemsOf(descended);
55
+ // With no element schema there is nowhere further to walk. Every
56
+ // ancestor so far was required, so the last segment may be required.
57
+ if (element === undefined)
58
+ return index === steps.length - 1;
59
+ descended = element;
60
+ }
61
+ current = descended;
62
+ }
63
+ return true;
64
+ }
65
+ /**
66
+ * Whether the immediate parent schema lists that key in its required set.
67
+ *
68
+ * When the path is not safely required, this separates "it was never written
69
+ * as required" from "it was, but an ancestor makes it unlowerable". Only the
70
+ * second is reported as skipped.
71
+ */
72
+ function isListedByParent(root, path) {
73
+ if (!(0, schema_tooling_1.isSchemaObject)(root))
74
+ return false;
75
+ const steps = splitDeclaredPath(path);
76
+ if (steps.length === 0)
77
+ return false;
78
+ let current = root;
79
+ for (let index = 0; index < steps.length - 1; index += 1) {
80
+ const step = steps[index] ?? "";
81
+ const wildcards = (step.match(/\[\*\]/g) ?? []).length;
82
+ const key = step.slice(0, step.length - wildcards * 3);
83
+ const child = propertyOf(current, key);
84
+ if (child === undefined)
85
+ return false;
86
+ let descended = child;
87
+ for (let level = 0; level < wildcards; level += 1) {
88
+ const element = itemsOf(descended);
89
+ if (element === undefined)
90
+ return false;
91
+ descended = element;
92
+ }
93
+ current = descended;
94
+ }
95
+ const last = steps[steps.length - 1] ?? "";
96
+ const wildcards = (last.match(/\[\*\]/g) ?? []).length;
97
+ return listsAsRequired(current, last.slice(0, last.length - wildcards * 3));
98
+ }
@@ -0,0 +1,93 @@
1
+ import { isDraft07Schema, isSchemaObject, } from "@maroonedog/luq/schema-tooling";
2
+ /** "items[*].sku" -> ["items[*]", "sku"]; "[*]" stays attached to its key. */
3
+ export function splitDeclaredPath(path) {
4
+ return path === "" ? [] : path.split(".");
5
+ }
6
+ function propertyOf(schema, key) {
7
+ const properties = schema.properties;
8
+ if (properties === null || typeof properties !== "object")
9
+ return undefined;
10
+ const child = properties[key];
11
+ if (!isDraft07Schema(child))
12
+ return undefined;
13
+ return isSchemaObject(child) ? child : undefined;
14
+ }
15
+ function itemsOf(schema) {
16
+ const items = schema.items;
17
+ if (!isDraft07Schema(items))
18
+ return undefined;
19
+ return isSchemaObject(items) ? items : undefined;
20
+ }
21
+ function listsAsRequired(schema, key) {
22
+ const required = schema.required;
23
+ return Array.isArray(required) && required.includes(key);
24
+ }
25
+ /**
26
+ * Whether `.required()` may be emitted for that path.
27
+ *
28
+ * False as soon as one ancestor object along the way is not itself required.
29
+ * On false the caller emits `.optional()` instead and reports the omission.
30
+ */
31
+ export function isSafelyRequired(root, path) {
32
+ if (!isSchemaObject(root))
33
+ return false;
34
+ const steps = splitDeclaredPath(path);
35
+ if (steps.length === 0)
36
+ return false;
37
+ let current = root;
38
+ for (let index = 0; index < steps.length; index += 1) {
39
+ const step = steps[index] ?? "";
40
+ const wildcards = (step.match(/\[\*\]/g) ?? []).length;
41
+ const key = step.slice(0, step.length - wildcards * 3);
42
+ if (!listsAsRequired(current, key))
43
+ return false;
44
+ const child = propertyOf(current, key);
45
+ if (child === undefined)
46
+ return index === steps.length - 1;
47
+ let descended = child;
48
+ for (let level = 0; level < wildcards; level += 1) {
49
+ const element = itemsOf(descended);
50
+ // With no element schema there is nowhere further to walk. Every
51
+ // ancestor so far was required, so the last segment may be required.
52
+ if (element === undefined)
53
+ return index === steps.length - 1;
54
+ descended = element;
55
+ }
56
+ current = descended;
57
+ }
58
+ return true;
59
+ }
60
+ /**
61
+ * Whether the immediate parent schema lists that key in its required set.
62
+ *
63
+ * When the path is not safely required, this separates "it was never written
64
+ * as required" from "it was, but an ancestor makes it unlowerable". Only the
65
+ * second is reported as skipped.
66
+ */
67
+ export function isListedByParent(root, path) {
68
+ if (!isSchemaObject(root))
69
+ return false;
70
+ const steps = splitDeclaredPath(path);
71
+ if (steps.length === 0)
72
+ return false;
73
+ let current = root;
74
+ for (let index = 0; index < steps.length - 1; index += 1) {
75
+ const step = steps[index] ?? "";
76
+ const wildcards = (step.match(/\[\*\]/g) ?? []).length;
77
+ const key = step.slice(0, step.length - wildcards * 3);
78
+ const child = propertyOf(current, key);
79
+ if (child === undefined)
80
+ return false;
81
+ let descended = child;
82
+ for (let level = 0; level < wildcards; level += 1) {
83
+ const element = itemsOf(descended);
84
+ if (element === undefined)
85
+ return false;
86
+ descended = element;
87
+ }
88
+ current = descended;
89
+ }
90
+ const last = steps[steps.length - 1] ?? "";
91
+ const wildcards = (last.match(/\[\*\]/g) ?? []).length;
92
+ return listsAsRequired(current, last.slice(0, last.length - wildcards * 3));
93
+ }
@@ -0,0 +1,13 @@
1
+ import type { Draft07SchemaObject } from "@maroonedog/luq/schema-tooling";
2
+ /**
3
+ * A schema with no type, with several types, or with null alone belongs to no
4
+ * slot, so it falls to "any".
5
+ *
6
+ * That narrows what can be written on it. A plugin serves the slots it
7
+ * declares, and the ones declaring "any" are the type-agnostic rules —
8
+ * presence and the value comparisons. A rule that belongs to one type does
9
+ * not appear: `oneOf` serves string, number and boolean, so a schema whose
10
+ * type could not be decided gets no `oneOf` call and the keyword is reported
11
+ * skipped rather than emitted onto a slot that would not compile.
12
+ */
13
+ export declare function slotForSchema(schema: Draft07SchemaObject): string;
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.slotForSchema = slotForSchema;
4
+ const SLOT_BY_TYPE = {
5
+ string: "string",
6
+ number: "number",
7
+ integer: "number",
8
+ boolean: "boolean",
9
+ array: "array",
10
+ object: "object",
11
+ };
12
+ /**
13
+ * A schema with no type, with several types, or with null alone belongs to no
14
+ * slot, so it falls to "any".
15
+ *
16
+ * That narrows what can be written on it. A plugin serves the slots it
17
+ * declares, and the ones declaring "any" are the type-agnostic rules —
18
+ * presence and the value comparisons. A rule that belongs to one type does
19
+ * not appear: `oneOf` serves string, number and boolean, so a schema whose
20
+ * type could not be decided gets no `oneOf` call and the keyword is reported
21
+ * skipped rather than emitted onto a slot that would not compile.
22
+ */
23
+ function slotForSchema(schema) {
24
+ const type = schema.type;
25
+ if (typeof type !== "string")
26
+ return "any";
27
+ return SLOT_BY_TYPE[type] ?? "any";
28
+ }
@@ -0,0 +1,25 @@
1
+ const SLOT_BY_TYPE = {
2
+ string: "string",
3
+ number: "number",
4
+ integer: "number",
5
+ boolean: "boolean",
6
+ array: "array",
7
+ object: "object",
8
+ };
9
+ /**
10
+ * A schema with no type, with several types, or with null alone belongs to no
11
+ * slot, so it falls to "any".
12
+ *
13
+ * That narrows what can be written on it. A plugin serves the slots it
14
+ * declares, and the ones declaring "any" are the type-agnostic rules —
15
+ * presence and the value comparisons. A rule that belongs to one type does
16
+ * not appear: `oneOf` serves string, number and boolean, so a schema whose
17
+ * type could not be decided gets no `oneOf` call and the keyword is reported
18
+ * skipped rather than emitted onto a slot that would not compile.
19
+ */
20
+ export function slotForSchema(schema) {
21
+ const type = schema.type;
22
+ if (typeof type !== "string")
23
+ return "any";
24
+ return SLOT_BY_TYPE[type] ?? "any";
25
+ }
@@ -0,0 +1,3 @@
1
+ export { generateValidatorModule } from "./generate/generate-validator-module";
2
+ export type { GenerateOptions } from "./generate/generate-validator-module";
3
+ export type { ChainCall, FieldChain, GeneratedModule, SkippedKeyword, } from "./generate/chain-call.types";
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.generateValidatorModule = void 0;
4
+ // ===========================================================================
5
+ // luq-codegen/src/index.ts — re-exports only. Nothing is defined here.
6
+ //
7
+ // The one entry point this package publishes. Every module beside it is
8
+ // reachable only through what is named below, so moving or splitting one of
9
+ // them is not a breaking change for anybody.
10
+ // ===========================================================================
11
+ var generate_validator_module_1 = require("./generate/generate-validator-module");
12
+ Object.defineProperty(exports, "generateValidatorModule", { enumerable: true, get: function () { return generate_validator_module_1.generateValidatorModule; } });
package/dist/index.mjs ADDED
@@ -0,0 +1,8 @@
1
+ // ===========================================================================
2
+ // luq-codegen/src/index.ts — re-exports only. Nothing is defined here.
3
+ //
4
+ // The one entry point this package publishes. Every module beside it is
5
+ // reachable only through what is named below, so moving or splitting one of
6
+ // them is not a breaking change for anybody.
7
+ // ===========================================================================
8
+ export { generateValidatorModule } from "./generate/generate-validator-module.mjs";
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@maroonedog/luq-codegen",
3
+ "version": "0.1.0",
4
+ "description": "Generates the source of a Luq validator module from a JSON Schema Draft-07 object, such as an OpenAPI 3.0 component schema.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/maroonedog/luq.git",
9
+ "directory": "luq-codegen"
10
+ },
11
+ "main": "dist/index.js",
12
+ "module": "dist/index.mjs",
13
+ "types": "dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.mjs",
18
+ "require": "./dist/index.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": ["dist"],
23
+ "sideEffects": false,
24
+ "scripts": {
25
+ "build": "npx ts-node --project ../scripts/tsconfig.json ../scripts/build-luq-codegen.ts",
26
+ "typecheck": "tsc --noEmit -p tsconfig.json",
27
+ "test": "jest --no-watch --no-watchman -c jest.config.cjs",
28
+ "check:no-dynamic-code": "npx ts-node --project ../scripts/tsconfig.json ../scripts/check-no-dynamic-code.ts luq-codegen",
29
+ "check:dist-layout": "npx ts-node --project ../scripts/tsconfig.json ../scripts/check-dist-layout.ts luq-codegen",
30
+ "check:dist": "npm run check:no-dynamic-code && npm run check:dist-layout",
31
+ "verify": "npm run typecheck && npm run build && npm run check:dist && npm test",
32
+ "prepack": "npm run build",
33
+ "prepublishOnly": "npm run verify"
34
+ },
35
+ "peerDependencies": {
36
+ "@maroonedog/luq": ">=2.4.0"
37
+ },
38
+ "devDependencies": {
39
+ "@maroonedog/luq": "file:.."
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }