@feniix/bridgekit 0.9.1 → 0.9.2

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
@@ -4,6 +4,26 @@ All notable changes to `@feniix/bridgekit` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.9.2] - 2026-05-26
8
+
9
+ ### Fixed
10
+
11
+ - `PortableValidationError.field` now disambiguates slash-named properties
12
+ (`"a/b": Type.String()`) from nested-object paths (`a: Type.Object({ b: ... })`)
13
+ that produce identical `instancePath` values. The field-derivation walker
14
+ reads `error.schemaPath` instead, which carries explicit `/properties/`
15
+ markers per nesting level. Behavior change limited to the overlapping-prefix
16
+ case from PR #42's adversarial review; the common slash-named-property case
17
+ from 0.8.3 and the `Type.Intersect` `allOf` descent from 0.9.0 are unchanged.
18
+ The walker treats `anyOf` and `oneOf` as branch-descent commands (parallel
19
+ to the 0.9.0 `allOf` handler), so slash-named properties holding
20
+ `Type.Union(...)` values preserve their prefix through per-branch errors.
21
+ Pre-fix, the walker would match the slash-name at the `properties` level,
22
+ then bail on `anyOf` with no handler, and the fallback would strip the
23
+ prefix to the trailing segment. The walker now handles all three
24
+ JSON-Schema combinators (`allOf`, `anyOf`, `oneOf`) symmetrically.
25
+ Resolves [#43](https://github.com/feniix/bridgekit/issues/43).
26
+
7
27
  ## [0.9.1] - 2026-05-26
8
28
 
9
29
  ### Fixed
@@ -4,93 +4,106 @@ function fieldFromPath(instancePath) {
4
4
  return instancePath.split("/").filter(Boolean).at(-1) ?? ROOT_FIELD;
5
5
  }
6
6
  /**
7
- * Resolve the leaf field name of an instancePath against the actual schema.
7
+ * Resolve the leaf property name from a TypeBox error by walking
8
+ * `error.schemaPath` rather than `error.instancePath`.
8
9
  *
9
- * TypeBox does not escape `/` inside property names when building
10
- * `instancePath` (it does not follow JSON Pointer RFC 6901's `~1` encoding),
11
- * so a property literally named `a/b` produces `instancePath: "/a/b"`. The
12
- * string-split fallback (`fieldFromPath`) would then surface `"b"` and lose
13
- * the prefix. This helper walks the schema greedily — at each object node it
14
- * matches the longest prefix of remaining path segments that names a real
15
- * property key, descending into the matched sub-schema — and returns the
16
- * actual leaf key. Array nodes consume a single numeric segment via `items`.
10
+ * `instancePath` does not escape `/` inside property names (TypeBox does not
11
+ * follow JSON Pointer RFC 6901's `~1` encoding), so a schema with BOTH a
12
+ * slash-named property `"a/b"` AND a nested path `a.b` produces the same
13
+ * `instancePath: "/a/b"` for either failure — the two cases are
14
+ * indistinguishable from the data path alone. `schemaPath` carries explicit
15
+ * `/properties/` markers per nesting level, so the two cases become
16
+ * structurally distinct:
17
17
  *
18
- * `allOf` nodes (TypeBox's `Intersect` lowering) probe each branch in turn
19
- * with the same remaining path and return the first successful match. This
20
- * is what lets `Type.Intersect([Object({"a/b": String()}), Object({c: ...})])`
21
- * resolve a `/a/b` instancePath to the `"a/b"` key in the first branch.
18
+ * slash-named `"a/b": Type.String()` wrong type → `#/properties/a/b`
19
+ * nested `a: Object({ b: Number() })` wrong type → `#/properties/a/properties/b`
22
20
  *
23
- * Returns `undefined` if the walk gets stuck (the schema does not model the
24
- * data path — e.g. `additionalProperties` content). The caller then falls
25
- * back to the string-split `fieldFromPath`, preserving prior behavior.
21
+ * TypeBox's schemaPath terminates at the violated property's value-schema —
22
+ * it does NOT append the keyword as a trailing segment (the keyword lives in
23
+ * `error.keyword` instead). The walker therefore only needs to consume
24
+ * structural commands (`properties`, `items`, `allOf`) and the property /
25
+ * branch tokens that follow them.
26
26
  *
27
- * The `required` and `additionalProperties` callers already read structured
28
- * `params` and are unaffected; this helper covers the `const` / `enum` /
29
- * default keyword branches.
27
+ * `allOf` descent (the 0.9.0 Intersect support) is preserved: an
28
+ * `#/allOf/<i>/properties/<key>` path descends into the i-th branch before
29
+ * resolving the property. `anyOf` / `oneOf` descent is symmetric: TypeBox
30
+ * emits per-branch errors with schemaPath `.../anyOf/<i>/...` (or `.../oneOf/<i>/...`)
31
+ * when a Union (or hand-authored oneOf) sits under a property; the walker
32
+ * descends into the indexed branch carrying `lastField` through so a
33
+ * slash-named property holding a `Type.Union(...)` value preserves its
34
+ * prefix.
35
+ *
36
+ * Returns `undefined` if the walk cannot model the schemaPath (e.g.
37
+ * `additionalProperties` content whose schemaPath stops at `#`). The caller
38
+ * then falls back to the string-split `fieldFromPath` on `instancePath`,
39
+ * preserving prior behavior. The `required` and `additionalProperties`
40
+ * callers already read structured `params` and are unaffected; this helper
41
+ * covers the `type` / `const` / `enum` / numeric-keyword branches.
30
42
  */
31
- function fieldFromSchemaWalk(schema, instancePath) {
32
- if (instancePath === "")
33
- return undefined;
34
- const segments = instancePath.split("/").filter(Boolean);
43
+ function fieldFromSchemaWalk(schema, schemaPath) {
44
+ const raw = schemaPath.startsWith("#") ? schemaPath.slice(1) : schemaPath;
45
+ const segments = raw.split("/").filter(Boolean);
35
46
  if (segments.length === 0)
36
47
  return undefined;
37
- return walkSegments(schema, segments, 0);
48
+ return walkSegments(schema, segments, 0, undefined);
38
49
  }
39
- function walkSegments(node, segments, i) {
40
- if (i >= segments.length)
41
- return undefined;
50
+ function walkSegments(node, segments, i, lastField) {
42
51
  if (!node || typeof node !== "object")
43
52
  return undefined;
53
+ if (i >= segments.length)
54
+ return lastField;
44
55
  const obj = node;
45
- // Array descent: numeric segment against an `items` schema. Tuple-form
46
- // `items` (array) indexes positionally; schema-form descends uniformly.
47
- if (obj.items !== undefined && /^\d+$/.test(segments[i])) {
48
- const idx = Number(segments[i]);
49
- let next;
50
- if (Array.isArray(obj.items)) {
51
- if (idx >= obj.items.length)
52
- return undefined;
53
- next = obj.items[idx];
54
- }
55
- else {
56
- next = obj.items;
57
- }
58
- if (i + 1 === segments.length)
59
- return segments[i];
60
- return walkSegments(next, segments, i + 1);
56
+ const head = segments[i];
57
+ // `allOf` / `anyOf` / `oneOf` commands: next segment is the branch index.
58
+ // Descend into the chosen branch with the rest of the path. `allOf` covers
59
+ // `Type.Intersect` lowering (0.9.0); `anyOf` covers `Type.Union` (and the
60
+ // hand-authored `oneOf` shape). `lastField` carries through unchanged — a
61
+ // slash-named property whose value is a Union must keep its prefix when
62
+ // the walker descends into a per-branch error.
63
+ if (head === "allOf" && Array.isArray(obj.allOf) && i + 1 < segments.length) {
64
+ const idx = Number(segments[i + 1]);
65
+ if (!Number.isInteger(idx) || idx < 0 || idx >= obj.allOf.length)
66
+ return undefined;
67
+ return walkSegments(obj.allOf[idx], segments, i + 2, lastField);
68
+ }
69
+ if (head === "anyOf" && Array.isArray(obj.anyOf) && i + 1 < segments.length) {
70
+ const idx = Number(segments[i + 1]);
71
+ if (!Number.isInteger(idx) || idx < 0 || idx >= obj.anyOf.length)
72
+ return undefined;
73
+ return walkSegments(obj.anyOf[idx], segments, i + 2, lastField);
74
+ }
75
+ if (head === "oneOf" && Array.isArray(obj.oneOf) && i + 1 < segments.length) {
76
+ const idx = Number(segments[i + 1]);
77
+ if (!Number.isInteger(idx) || idx < 0 || idx >= obj.oneOf.length)
78
+ return undefined;
79
+ return walkSegments(obj.oneOf[idx], segments, i + 2, lastField);
61
80
  }
62
- // Object descent: greedy longest-prefix match against `properties` keys.
63
- // This is what lets `a/b` resolve to a single key when the schema
64
- // declares it, even though the instancePath segments it as ["a", "b"].
65
- if (obj.properties && typeof obj.properties === "object") {
81
+ // `items` command: array element schema. TypeBox emits a single `items`
82
+ // marker regardless of element index (the data-side index is in
83
+ // instancePath, not schemaPath). Tuple-form `items` (array) is not
84
+ // expected here from TypeBox lowering — schema-form is uniform.
85
+ if (head === "items" && obj.items !== undefined) {
86
+ return walkSegments(obj.items, segments, i + 1, lastField);
87
+ }
88
+ // `properties` command: subsequent segments form the property name (joined
89
+ // by `/` to recover slash-named keys). Consume by greedy longest-prefix
90
+ // match against the parent's actual `properties` keys, then descend.
91
+ if (head === "properties" && obj.properties && typeof obj.properties === "object") {
66
92
  const props = obj.properties;
67
- for (let j = segments.length; j > i; j--) {
68
- const candidate = segments.slice(i, j).join("/");
93
+ for (let j = segments.length; j > i + 1; j--) {
94
+ const candidate = segments.slice(i + 1, j).join("/");
69
95
  if (Object.hasOwn(props, candidate)) {
70
- if (j === segments.length)
71
- return candidate;
72
- const child = walkSegments(props[candidate], segments, j);
96
+ const child = walkSegments(props[candidate], segments, j, candidate);
73
97
  if (child !== undefined)
74
98
  return child;
75
99
  }
76
100
  }
77
101
  return undefined;
78
102
  }
79
- // allOf descent: TypeBox's Intersect lowering. Each branch sees the full
80
- // remaining path; return the first branch that resolves. This is the
81
- // schema-side mirror of how Intersect merges property contributions.
82
- if (Array.isArray(obj.allOf)) {
83
- for (const branch of obj.allOf) {
84
- const matched = walkSegments(branch, segments, i);
85
- if (matched !== undefined)
86
- return matched;
87
- }
88
- return undefined;
89
- }
90
103
  return undefined;
91
104
  }
92
105
  function fieldFromError(schema, error) {
93
- return fieldFromSchemaWalk(schema, error.instancePath) ?? fieldFromPath(error.instancePath);
106
+ return fieldFromSchemaWalk(schema, error.schemaPath) ?? fieldFromPath(error.instancePath);
94
107
  }
95
108
  function readDiscriminatorValues(propSchema) {
96
109
  // Extract the allowed values from a discriminator-eligible prop schema.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feniix/bridgekit",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "BridgeKit defines TypeBox-backed tools once and adapts them to pi, MCP, and other hosts.",
5
5
  "keywords": [
6
6
  "pi",