diff --git a/docs/documentation/schema-authoring.md b/docs/documentation/schema-authoring.md index 2413409dd..e48d22229 100644 --- a/docs/documentation/schema-authoring.md +++ b/docs/documentation/schema-authoring.md @@ -704,6 +704,12 @@ comment. Unannotated blocks fail CI. | `direction` | no | `response` | `request` or `response` | | `extract` | no | `$` | JSONPath inside the displayed block; selected subtree becomes the example | | `target` | no | `$` | JSONPath into the schema/scaffold; example replaces a sub-tree | + +JSONPath here is a small subset: dot-separated bare names with an optional +array index (`$.a.b[0]`), plus bracket-quoted names for keys that are not +bare-legal (`$.a['dev.ucp.common.identity_linking'][0]`). Use the quoted form +for reverse-domain keys and scope tokens — their dots and colons are otherwise +read as path separators. | `def` | no | — | Pull `$defs/` out of the schema and validate against that | | `skip reason` | yes (with skip) | — | Free-form prose explaining why this block can't be validated | diff --git a/scripts/test_validate_examples.py b/scripts/test_validate_examples.py index f8338916a..3d8d1afc5 100755 --- a/scripts/test_validate_examples.py +++ b/scripts/test_validate_examples.py @@ -626,6 +626,101 @@ def fake_run(*args: object, **kwargs: object) -> object: v._schema_cache.clear() +# ----------------------------------------------------------- +# JSONPath subset: bare, indexed and bracket-quoted segments +# ----------------------------------------------------------- + + +def test_jsonpath_quoted_keys() -> None: + """Keys that are not bare-legal are reachable in bracket-quoted form. + + UCP uses reverse-domain identifiers and scope tokens as object keys + (`dev.ucp.common.identity_linking`, `dev.ucp.shopping.order:read`). + Their dots and colons cannot appear in a bare segment, so extract= + and target= must accept the quoted form to address them at all. + """ + doc = { + "capabilities": { + "dev.ucp.common.identity_linking": [{"config": {"scopes": {}}}] + }, + "config": { + "scopes": {"dev.ucp.shopping.order:read": {"description": "d"}}, + "providers": {"com.example.attestor": [{"type": "wallet_attestation"}]}, + }, + "messages": [{"code": "identity_optional"}], + } + + # Bare and indexed paths are unchanged. + _check( + "jsonpath_bare_unchanged", + v.jsonpath_get(doc, "$.messages")[0]["code"] == "identity_optional", + ) + _check( + "jsonpath_index_unchanged", + v.jsonpath_get(doc, "$.messages[0]")["code"] == "identity_optional", + ) + _check("jsonpath_root_unchanged", v.jsonpath_get(doc, "$") is doc) + + # Dotted key, with and without a trailing index. + _check( + "jsonpath_quoted_dotted_key", + v.jsonpath_get(doc, "$.config.providers['com.example.attestor'][0]")["type"] + == "wallet_attestation", + ) + _check( + "jsonpath_quoted_capability_name", + v.jsonpath_get(doc, "$.capabilities['dev.ucp.common.identity_linking'][0]")[ + "config" + ] + == {"scopes": {}}, + ) + + # Colon-bearing scope token, and the double-quoted spelling. + _check( + "jsonpath_quoted_scope_token", + v.jsonpath_get(doc, "$.config.scopes['dev.ucp.shopping.order:read']")[ + "description" + ] + == "d", + ) + _check( + "jsonpath_quoted_double_quotes", + v.jsonpath_get(doc, '$.config.scopes["dev.ucp.shopping.order:read"]')[ + "description" + ] + == "d", + ) + + # target= writes through the same parser. + target = { + "config": {"providers": {"com.example.attestor": [{"type": "oauth2"}]}} + } + v.jsonpath_set( + target, + "$.config.providers['com.example.attestor'][0]", + {"type": "wallet_attestation"}, + ) + _check( + "jsonpath_set_quoted_key", + target["config"]["providers"]["com.example.attestor"][0]["type"] + == "wallet_attestation", + ) + + # Elision paths are reported as JSON Pointers against the same subset. + _check( + "jsonpath_pointer_quoted_key", + v.jsonpath_to_pointer("$.config.providers['com.example.attestor'][0]") + == "/config/providers/com.example.attestor/0", + ) + + # Unparsable segments still raise rather than silently mis-navigating. + try: + v.jsonpath_get(doc, "$.config.providers.com.example.attestor") + _check("jsonpath_unquoted_dotted_key_raises", False, "expected KeyError") + except KeyError: + _check("jsonpath_unquoted_dotted_key_raises", True) + + # ----------------------------------------------------------- # Main # ----------------------------------------------------------- @@ -642,6 +737,7 @@ def main() -> int: test_annotation_parsing() test_extract_blocks() test_scaffold_resolution() + test_jsonpath_quoted_keys() test_resolve_schema_cache_key() test_process_block_integration() return _report() diff --git a/scripts/validate_examples.py b/scripts/validate_examples.py index 739d82b56..1a5781e45 100755 --- a/scripts/validate_examples.py +++ b/scripts/validate_examples.py @@ -75,6 +75,12 @@ - If extract= is present, the indicated subtree is selected from the parsed displayed example before semantic validation. + + JSONPath subset used by extract= and target=: dot-separated bare + names with an optional array index (`$.a.b[0]`), plus bracket-quoted + names for keys that are not bare-legal (`$.a['dev.ucp.common.x'][0]`). + The quoted form is required for UCP's reverse-domain keys and scope + tokens, whose dots and colons are otherwise read as separators. - Ellipsis sentinels are recorded as elided JSON Pointer paths, then removed from the tree. - The example is deep-merged into a scaffold (a known-valid @@ -424,19 +430,59 @@ def strip_ellipsis(obj, _path="", _paths=None): # JSONPath navigation (minimal subset) # ----------------------------------------------------------- -_SEGMENT_RE = re.compile(r"^(\w+)(?:\[(\d+)\])?$") +_BARE_SEGMENT_RE = re.compile(r"\w+") +_QUOTED_SEGMENT_RE = re.compile(r"\[(['\"])(.*?)\1\]") +_INDEX_RE = re.compile(r"\[(\d+)\]") + + +def split_path(path: str) -> list[tuple[str, str | None]]: + """Tokenize the supported JSONPath subset into (name, index) pairs. + + Bare names are dot-separated (`$.a.b`) and may carry a single array + index (`$.a[0]`). Names that are not bare-legal are written in + bracket-quoted form (`$.a['x.y'][0]`) — required for the + reverse-domain identifiers and scope tokens UCP uses as object keys, + such as `dev.ucp.common.identity_linking` and + `dev.ucp.shopping.order:read`, whose dots and colons would otherwise + be read as path separators or fail to match. + + Raises KeyError on an unparsable segment. + """ + segments: list[tuple[str, str | None]] = [] + pos = 1 if path.startswith("$") else 0 + while pos < len(path): + if path[pos] == ".": + pos += 1 + continue + quoted = _QUOTED_SEGMENT_RE.match(path, pos) + if quoted: + name = quoted.group(2) + pos = quoted.end() + else: + bare = _BARE_SEGMENT_RE.match(path, pos) + if not bare: + raise KeyError(path[pos:]) + name = bare.group(0) + pos = bare.end() + index = None + indexed = _INDEX_RE.match(path, pos) + if indexed: + index = indexed.group(1) + pos = indexed.end() + segments.append((name, index)) + return segments def jsonpath_to_pointer(path: str) -> str: """Convert the supported JSONPath subset to a JSON Pointer prefix.""" if path == "$": return "" + try: + segments = split_path(path) + except KeyError: + return "" pointer_parts: list[str] = [] - for seg in path.lstrip("$").lstrip(".").split("."): - m = _SEGMENT_RE.match(seg) - if not m: - return "" - name, idx = m.group(1), m.group(2) + for name, idx in segments: pointer_parts.append(name) if idx is not None: pointer_parts.append(idx) @@ -448,11 +494,7 @@ def jsonpath_get(obj, path: str): if path == "$": return obj current = obj - for seg in path.lstrip("$").lstrip(".").split("."): - m = _SEGMENT_RE.match(seg) - if not m: - raise KeyError(seg) - name, idx = m.group(1), m.group(2) + for name, idx in split_path(path): current = current[name] if idx is not None: current = current[int(idx)] @@ -461,16 +503,13 @@ def jsonpath_get(obj, path: str): def jsonpath_set(obj: dict, path: str, value): """Set a value at a JSONPath. Mutates obj.""" - segments = path.lstrip("$").lstrip(".").split(".") + segments = split_path(path) current = obj - for seg in segments[:-1]: - m = _SEGMENT_RE.match(seg) - name, idx = m.group(1), m.group(2) + for name, idx in segments[:-1]: current = current[name] if idx is not None: current = current[int(idx)] - last = _SEGMENT_RE.match(segments[-1]) - name, idx = last.group(1), last.group(2) + name, idx = segments[-1] if idx is not None: current[name][int(idx)] = value else: @@ -479,11 +518,8 @@ def jsonpath_set(obj: dict, path: str, value): def jsonpath_get_schema(schema: dict, path: str) -> dict: """Navigate a JSON Schema to the sub-schema at path.""" - segments = path.lstrip("$").lstrip(".").split(".") current = schema - for seg in segments: - m = _SEGMENT_RE.match(seg) - name, idx = m.group(1), m.group(2) + for name, idx in split_path(path): # Resolve through allOf to find properties current = _get_property_schema(current, name) if current is None: