@oh-my-pi/omptype 18.11.3 → 18.12.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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.12.0] - 2026-10-17
6
+
7
+ ### Fixed
8
+
9
+ - Fixed TypeBox numeric bounds written in exponent notation throwing during schema construction, while preserving validation and nested JSON Schema constraints ([#15925](https://github.com/can1357/oh-my-pi/pull/15925) by [@jchanghong023](https://github.com/jchanghong023)).
10
+ - Fixed importing local JSON Schema definition references containing URI percent encoding or JSON Pointer escapes ([#15926](https://github.com/can1357/oh-my-pi/pull/15926) by [@jchanghong023](https://github.com/jchanghong023)).
11
+
5
12
  ## [18.2.1] - 2026-09-15
6
13
 
7
14
  ### Added
@@ -12,6 +12,7 @@
12
12
  * omptype IR and passes the rest through.
13
13
  */
14
14
  import { OmpTypeError } from "./errors.js";
15
+ import { parseLocalRefPointer } from "./local-ref.js";
15
16
  import { IR_BRAND } from "./ir.js";
16
17
  import { keywordIR, patternIR } from "./keywords.js";
17
18
  import { type } from "./type.js";
@@ -40,11 +41,11 @@ class Importer {
40
41
  target = this.#root;
41
42
  }
42
43
  else {
43
- const defsMatch = /^#\/(\$defs|definitions)\/(.+)$/.exec(ref);
44
- if (defsMatch === null)
44
+ const pointer = parseLocalRefPointer(ref);
45
+ if (pointer === undefined || pointer.length !== 2 || (pointer[0] !== "$defs" && pointer[0] !== "definitions"))
45
46
  throw new OmpTypeError(`unsupported $ref: ${ref}`);
46
- const defs = this.#root[defsMatch[1]];
47
- target = typeof defs === "object" && defs !== null ? defs[defsMatch[2]] : undefined;
47
+ const defs = this.#root[pointer[0]];
48
+ target = typeof defs === "object" && defs !== null ? defs[pointer[1]] : undefined;
48
49
  if (target === undefined)
49
50
  throw new OmpTypeError(`unresolved $ref: ${ref}`);
50
51
  }
package/dist/js/index.js CHANGED
@@ -10,4 +10,5 @@ export * from "./from-json-schema.js";
10
10
  export * from "./infer.js";
11
11
  export * from "./ir.js";
12
12
  export * from "./json-schema.js";
13
+ export * from "./local-ref.js";
13
14
  export * from "./type.js";
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Shared parsing for local `$ref` URI fragments (RFC 6901 JSON Pointers).
3
+ * omptype's JSON Schema importer and pi-ai's schema resolvers decode refs
4
+ * through these helpers so percent-encoded, pointer-escaped, and empty tokens
5
+ * resolve identically everywhere.
6
+ */
7
+ /**
8
+ * Parse a local `$ref` URI fragment into decoded JSON Pointer tokens:
9
+ * percent-decode the fragment, split on `/`, then unescape `~1`/`~0` per
10
+ * token. Returns `[]` for the root ref `#`, and `undefined` for non-local
11
+ * refs or fragments that do not percent-decode.
12
+ */
13
+ export function parseLocalRefPointer(ref) {
14
+ if (!ref.startsWith("#"))
15
+ return undefined;
16
+ let pointer;
17
+ try {
18
+ pointer = decodeURIComponent(ref.slice(1));
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ if (pointer.length > 0 && !pointer.startsWith("/"))
24
+ return undefined;
25
+ return pointer
26
+ .split("/")
27
+ .slice(1)
28
+ .map(token => token.replace(/~1/g, "/").replace(/~0/g, "~"));
29
+ }
30
+ /**
31
+ * Encode JSON Pointer tokens back into a local `$ref` URI fragment — the
32
+ * inverse of {@link parseLocalRefPointer}.
33
+ */
34
+ export function formatLocalRefPointer(tokens) {
35
+ if (tokens.length === 0)
36
+ return "#";
37
+ // `$` and the other URI sub-delims `encodeURIComponent` escapes are legal
38
+ // in fragments, so keep `$defs` readable; `%` stays encoded to round-trip.
39
+ const encoded = tokens
40
+ .map(token => encodeURIComponent(token.replace(/~/g, "~0").replace(/\//g, "~1")))
41
+ .map(token => token.replace(/%24/g, "$"));
42
+ return `#/${encoded.join("/")}`;
43
+ }
@@ -129,22 +129,19 @@ function tNumber(opts, integer = false) {
129
129
  upper = { value: opts.exclusiveMaximum, exclusive: true };
130
130
  }
131
131
  const keyword = integer ? "number.integer" : "number";
132
- // The `LO <= TYPE <= HI` range spelling requires both bounds; a min-only
133
- // bound must use the postfix `TYPE >= LO` form (see parseBounded in ir.ts).
134
- let src;
135
- if (lower && upper) {
136
- src = `${lower.value} ${lower.exclusive ? "<" : "<="} ${keyword} ${upper.exclusive ? "<" : "<="} ${upper.value}`;
132
+ if (lower &&
133
+ upper &&
134
+ (lower.value > upper.value || (lower.value === upper.value && (lower.exclusive || upper.exclusive)))) {
135
+ throw new OmpTypeError("numeric range is unsatisfiable");
137
136
  }
138
- else if (lower) {
139
- src = `${keyword} ${lower.exclusive ? ">" : ">="} ${lower.value}`;
140
- }
141
- else if (upper) {
142
- src = `${keyword} ${upper.exclusive ? "<" : "<="} ${upper.value}`;
143
- }
144
- else {
145
- src = keyword;
146
- }
147
- let schema = asRuntime(type.raw(src));
137
+ // Fluent bounds write min/max straight into the IR, so decimal and
138
+ // exponent-form bounds share one path that renders the same structured
139
+ // errors; the DSL's decimal-only literals cannot spell the exponent form.
140
+ let schema = asRuntime(type.raw(keyword));
141
+ if (lower)
142
+ schema = lower.exclusive ? schema.moreThan(lower.value) : schema.atLeast(lower.value);
143
+ if (upper)
144
+ schema = upper.exclusive ? schema.lessThan(upper.value) : schema.atMost(upper.value);
148
145
  if (opts?.multipleOf !== undefined) {
149
146
  const divisor = opts.multipleOf;
150
147
  schema = schema.narrow((value, ctx) => {
@@ -10,4 +10,5 @@ export * from "./from-json-schema.js";
10
10
  export * from "./infer.js";
11
11
  export * from "./ir.js";
12
12
  export * from "./json-schema.js";
13
+ export * from "./local-ref.js";
13
14
  export * from "./type.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared parsing for local `$ref` URI fragments (RFC 6901 JSON Pointers).
3
+ * omptype's JSON Schema importer and pi-ai's schema resolvers decode refs
4
+ * through these helpers so percent-encoded, pointer-escaped, and empty tokens
5
+ * resolve identically everywhere.
6
+ */
7
+ /**
8
+ * Parse a local `$ref` URI fragment into decoded JSON Pointer tokens:
9
+ * percent-decode the fragment, split on `/`, then unescape `~1`/`~0` per
10
+ * token. Returns `[]` for the root ref `#`, and `undefined` for non-local
11
+ * refs or fragments that do not percent-decode.
12
+ */
13
+ export declare function parseLocalRefPointer(ref: string): string[] | undefined;
14
+ /**
15
+ * Encode JSON Pointer tokens back into a local `$ref` URI fragment — the
16
+ * inverse of {@link parseLocalRefPointer}.
17
+ */
18
+ export declare function formatLocalRefPointer(tokens: readonly string[]): string;
@@ -125,6 +125,8 @@ interface RuntimeType<T> extends OmpType<T> {
125
125
  atMostLength(bound: number): RuntimeType<T>;
126
126
  atLeast(bound: number): RuntimeType<T>;
127
127
  atMost(bound: number): RuntimeType<T>;
128
+ moreThan(bound: number): RuntimeType<T>;
129
+ lessThan(bound: number): RuntimeType<T>;
128
130
  narrow<N extends T>(predicate: (value: T, ctx: NarrowContext) => value is N): RuntimeType<N>;
129
131
  narrow(predicate: (value: T, ctx: NarrowContext) => boolean): RuntimeType<T>;
130
132
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omptype",
4
- "version": "18.11.3",
4
+ "version": "18.12.0",
5
5
  "description": "ArkType-compatible runtime schema validation with lazy JIT compilation",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -12,6 +12,7 @@
12
12
  * omptype IR and passes the rest through.
13
13
  */
14
14
  import { OmpTypeError } from "./errors";
15
+ import { parseLocalRefPointer } from "./local-ref";
15
16
  import { type EmbeddableSchema, type IR, IR_BRAND, type PropIR, type TupleItemIR } from "./ir";
16
17
  import { keywordIR, patternIR } from "./keywords";
17
18
  import { type BaseType, type } from "./type";
@@ -45,10 +46,11 @@ class Importer {
45
46
  if (ref === "#") {
46
47
  target = this.#root;
47
48
  } else {
48
- const defsMatch = /^#\/(\$defs|definitions)\/(.+)$/.exec(ref);
49
- if (defsMatch === null) throw new OmpTypeError(`unsupported $ref: ${ref}`);
50
- const defs = this.#root[defsMatch[1]];
51
- target = typeof defs === "object" && defs !== null ? (defs as JsonSchema)[defsMatch[2]] : undefined;
49
+ const pointer = parseLocalRefPointer(ref);
50
+ if (pointer === undefined || pointer.length !== 2 || (pointer[0] !== "$defs" && pointer[0] !== "definitions"))
51
+ throw new OmpTypeError(`unsupported $ref: ${ref}`);
52
+ const defs = this.#root[pointer[0]];
53
+ target = typeof defs === "object" && defs !== null ? (defs as JsonSchema)[pointer[1]!] : undefined;
52
54
  if (target === undefined) throw new OmpTypeError(`unresolved $ref: ${ref}`);
53
55
  }
54
56
 
package/src/index.ts CHANGED
@@ -10,4 +10,5 @@ export * from "./from-json-schema";
10
10
  export * from "./infer";
11
11
  export * from "./ir";
12
12
  export * from "./json-schema";
13
+ export * from "./local-ref";
13
14
  export * from "./type";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared parsing for local `$ref` URI fragments (RFC 6901 JSON Pointers).
3
+ * omptype's JSON Schema importer and pi-ai's schema resolvers decode refs
4
+ * through these helpers so percent-encoded, pointer-escaped, and empty tokens
5
+ * resolve identically everywhere.
6
+ */
7
+
8
+ /**
9
+ * Parse a local `$ref` URI fragment into decoded JSON Pointer tokens:
10
+ * percent-decode the fragment, split on `/`, then unescape `~1`/`~0` per
11
+ * token. Returns `[]` for the root ref `#`, and `undefined` for non-local
12
+ * refs or fragments that do not percent-decode.
13
+ */
14
+ export function parseLocalRefPointer(ref: string): string[] | undefined {
15
+ if (!ref.startsWith("#")) return undefined;
16
+ let pointer: string;
17
+ try {
18
+ pointer = decodeURIComponent(ref.slice(1));
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ if (pointer.length > 0 && !pointer.startsWith("/")) return undefined;
23
+ return pointer
24
+ .split("/")
25
+ .slice(1)
26
+ .map(token => token.replace(/~1/g, "/").replace(/~0/g, "~"));
27
+ }
28
+
29
+ /**
30
+ * Encode JSON Pointer tokens back into a local `$ref` URI fragment — the
31
+ * inverse of {@link parseLocalRefPointer}.
32
+ */
33
+ export function formatLocalRefPointer(tokens: readonly string[]): string {
34
+ if (tokens.length === 0) return "#";
35
+ // `$` and the other URI sub-delims `encodeURIComponent` escapes are legal
36
+ // in fragments, so keep `$defs` readable; `%` stays encoded to round-trip.
37
+ const encoded = tokens
38
+ .map(token => encodeURIComponent(token.replace(/~/g, "~0").replace(/\//g, "~1")))
39
+ .map(token => token.replace(/%24/g, "$"));
40
+ return `#/${encoded.join("/")}`;
41
+ }
package/src/typebox.ts CHANGED
@@ -135,6 +135,8 @@ interface RuntimeType<T> extends OmpType<T> {
135
135
  atMostLength(bound: number): RuntimeType<T>;
136
136
  atLeast(bound: number): RuntimeType<T>;
137
137
  atMost(bound: number): RuntimeType<T>;
138
+ moreThan(bound: number): RuntimeType<T>;
139
+ lessThan(bound: number): RuntimeType<T>;
138
140
  narrow<N extends T>(predicate: (value: T, ctx: NarrowContext) => value is N): RuntimeType<N>;
139
141
  narrow(predicate: (value: T, ctx: NarrowContext) => boolean): RuntimeType<T>;
140
142
  }
@@ -271,19 +273,19 @@ function tNumber(opts?: NumberOpts, integer = false): TNumber {
271
273
  upper = { value: opts.exclusiveMaximum, exclusive: true };
272
274
  }
273
275
  const keyword = integer ? "number.integer" : "number";
274
- // The `LO <= TYPE <= HI` range spelling requires both bounds; a min-only
275
- // bound must use the postfix `TYPE >= LO` form (see parseBounded in ir.ts).
276
- let src: string;
277
- if (lower && upper) {
278
- src = `${lower.value} ${lower.exclusive ? "<" : "<="} ${keyword} ${upper.exclusive ? "<" : "<="} ${upper.value}`;
279
- } else if (lower) {
280
- src = `${keyword} ${lower.exclusive ? ">" : ">="} ${lower.value}`;
281
- } else if (upper) {
282
- src = `${keyword} ${upper.exclusive ? "<" : "<="} ${upper.value}`;
283
- } else {
284
- src = keyword;
276
+ if (
277
+ lower &&
278
+ upper &&
279
+ (lower.value > upper.value || (lower.value === upper.value && (lower.exclusive || upper.exclusive)))
280
+ ) {
281
+ throw new OmpTypeError("numeric range is unsatisfiable");
285
282
  }
286
- let schema = asRuntime<number>(type.raw(src));
283
+ // Fluent bounds write min/max straight into the IR, so decimal and
284
+ // exponent-form bounds share one path that renders the same structured
285
+ // errors; the DSL's decimal-only literals cannot spell the exponent form.
286
+ let schema = asRuntime<number>(type.raw(keyword));
287
+ if (lower) schema = lower.exclusive ? schema.moreThan(lower.value) : schema.atLeast(lower.value);
288
+ if (upper) schema = upper.exclusive ? schema.lessThan(upper.value) : schema.atMost(upper.value);
287
289
  if (opts?.multipleOf !== undefined) {
288
290
  const divisor = opts.multipleOf;
289
291
  schema = schema.narrow((value, ctx) => {