diff --git a/conformance/blueprint/v1/cases.json b/conformance/blueprint/v1/cases.json index 9315415..0613a80 100644 --- a/conformance/blueprint/v1/cases.json +++ b/conformance/blueprint/v1/cases.json @@ -26,6 +26,11 @@ "id": "structural-004-unsupported-spec-version", "phase": "structural", "path": "structural/004-unsupported-spec-version" + }, + { + "id": "structural-005-nested-unknown-field", + "phase": "structural", + "path": "structural/005-nested-unknown-field" } ] } diff --git a/conformance/blueprint/v1/structural/005-nested-unknown-field/case.yaml b/conformance/blueprint/v1/structural/005-nested-unknown-field/case.yaml new file mode 100644 index 0000000..e0ab8c2 --- /dev/null +++ b/conformance/blueprint/v1/structural/005-nested-unknown-field/case.yaml @@ -0,0 +1,22 @@ +# A misspelled optional field below the envelope. `lable` is a typo for +# `label`, three levels down inside a parameter's presentation block. +# Rejection must not depend on how deeply the typo is nested. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: web-and-database + version: 1 +spec: + components: + db: + component: postgres + size: general.standard.small + connections: {} + parameters: + siteTitle: + schema: + type: STRING + default: My Application + isRequired: false + ui: + lable: Site title diff --git a/conformance/blueprint/v1/structural/005-nested-unknown-field/diagnostics.json b/conformance/blueprint/v1/structural/005-nested-unknown-field/diagnostics.json new file mode 100644 index 0000000..cd475ff --- /dev/null +++ b/conformance/blueprint/v1/structural/005-nested-unknown-field/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNKNOWN_FIELD", + "path": "/spec/parameters/siteTitle/ui" + } +] diff --git a/conformance/blueprint/v1/structural/005-nested-unknown-field/metadata.json b/conformance/blueprint/v1/structural/005-nested-unknown-field/metadata.json new file mode 100644 index 0000000..07075ed --- /dev/null +++ b/conformance/blueprint/v1/structural/005-nested-unknown-field/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-005-nested-unknown-field", + "phase": "structural", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#envelope", + "summary": "An undefined property nested below the envelope is rejected, not silently dropped." +} diff --git a/conformance/component/v1/cases.json b/conformance/component/v1/cases.json index 92e9802..f872981 100644 --- a/conformance/component/v1/cases.json +++ b/conformance/component/v1/cases.json @@ -26,6 +26,11 @@ "id": "structural-004-unsupported-spec-version", "phase": "structural", "path": "structural/004-unsupported-spec-version" + }, + { + "id": "structural-005-nested-unknown-field", + "phase": "structural", + "path": "structural/005-nested-unknown-field" } ] } diff --git a/conformance/component/v1/structural/005-nested-unknown-field/case.yaml b/conformance/component/v1/structural/005-nested-unknown-field/case.yaml new file mode 100644 index 0000000..955337a --- /dev/null +++ b/conformance/component/v1/structural/005-nested-unknown-field/case.yaml @@ -0,0 +1,17 @@ +# A misspelled optional field below the envelope. `initialDelaySecond` is +# singular; the defined property is `initialDelaySeconds`. Rejection must not +# depend on how deeply the typo is nested. +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: nginx:1.29.4-alpine + health: + readiness: + path: /healthz + initialDelaySecond: 30 diff --git a/conformance/component/v1/structural/005-nested-unknown-field/diagnostics.json b/conformance/component/v1/structural/005-nested-unknown-field/diagnostics.json new file mode 100644 index 0000000..fdda1e6 --- /dev/null +++ b/conformance/component/v1/structural/005-nested-unknown-field/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNKNOWN_FIELD", + "path": "/spec/workload/health/readiness" + } +] diff --git a/conformance/component/v1/structural/005-nested-unknown-field/metadata.json b/conformance/component/v1/structural/005-nested-unknown-field/metadata.json new file mode 100644 index 0000000..53dfc6f --- /dev/null +++ b/conformance/component/v1/structural/005-nested-unknown-field/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-005-nested-unknown-field", + "phase": "structural", + "expected": "fail", + "clause": "specifications/component/v1/spec.md#envelope", + "summary": "An undefined property nested below the envelope is rejected, not silently dropped." +} diff --git a/conformance/listing/v1/cases.json b/conformance/listing/v1/cases.json index 209cfda..890c422 100644 --- a/conformance/listing/v1/cases.json +++ b/conformance/listing/v1/cases.json @@ -26,6 +26,11 @@ "id": "structural-004-unsupported-spec-version", "phase": "structural", "path": "structural/004-unsupported-spec-version" + }, + { + "id": "structural-005-nested-unknown-field", + "phase": "structural", + "path": "structural/005-nested-unknown-field" } ] } diff --git a/conformance/listing/v1/structural/005-nested-unknown-field/case.yaml b/conformance/listing/v1/structural/005-nested-unknown-field/case.yaml new file mode 100644 index 0000000..6668a07 --- /dev/null +++ b/conformance/listing/v1/structural/005-nested-unknown-field/case.yaml @@ -0,0 +1,17 @@ +# A misspelled optional field below the envelope. `captions` is a typo for +# `caption`, inside an array element. Rejection must not depend on how deeply +# the typo is nested, nor on whether it sits inside an array. +specVersion: v1 +kind: LISTING +metadata: + slug: postgres + version: 1 +spec: + listingKind: COMPONENT + displayName: PostgreSQL + summary: The open-source relational database + category: INFRASTRUCTURE + lifecycleStage: STABLE + screenshots: + - file: media/overview.png + captions: Connection details after deployment diff --git a/conformance/listing/v1/structural/005-nested-unknown-field/diagnostics.json b/conformance/listing/v1/structural/005-nested-unknown-field/diagnostics.json new file mode 100644 index 0000000..5000b3b --- /dev/null +++ b/conformance/listing/v1/structural/005-nested-unknown-field/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNKNOWN_FIELD", + "path": "/spec/screenshots/0" + } +] diff --git a/conformance/listing/v1/structural/005-nested-unknown-field/metadata.json b/conformance/listing/v1/structural/005-nested-unknown-field/metadata.json new file mode 100644 index 0000000..7735374 --- /dev/null +++ b/conformance/listing/v1/structural/005-nested-unknown-field/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-005-nested-unknown-field", + "phase": "structural", + "expected": "fail", + "clause": "specifications/listing/v1/spec.md#envelope", + "summary": "An undefined property nested below the envelope is rejected, not silently dropped." +} diff --git a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json index bb6d918..f9722f0 100644 --- a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json +++ b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json @@ -38,6 +38,7 @@ "BlueprintComputeConstraintsRequest": { "title": "BlueprintComputeConstraintsRequest", "description": "Advanced, optional compute pins (ADR 0041) honored by Placement (ADR 0095).", + "additionalProperties": false, "properties": { "acceleratorInterconnect": { "title": "Acceleratorinterconnect", @@ -154,6 +155,7 @@ "BlueprintConnectionRefRequest": { "title": "BlueprintConnectionRefRequest", "description": "A consumer-anchored wire (map value; keyed by the consumer input key).\n\nThe value names only the producer end (``fromRole`` / ``fromOutput``); the\nconsumer end is derived from the enclosing node (``toRole`` = the node name)\nand the map key (``toInput``). One wire per consumer input (inbound-uniqueness,\nenforced by the publish gate).", + "additionalProperties": false, "properties": { "fromOutput": { "title": "Fromoutput", @@ -175,6 +177,7 @@ }, "BlueprintParameterGeneratorRequest": { "title": "BlueprintParameterGeneratorRequest", + "additionalProperties": false, "properties": { "byteLength": { "title": "Bytelength", @@ -213,6 +216,7 @@ "BlueprintParameterRequest": { "title": "BlueprintParameterRequest", "description": "An install-form parameter composed at the graph layer (map value; keyed by key).", + "additionalProperties": false, "properties": { "description": { "title": "Description", @@ -264,6 +268,7 @@ "BlueprintParameterUiRequest": { "title": "BlueprintParameterUiRequest", "description": "Presentation metadata for an install-form parameter.", + "additionalProperties": false, "properties": { "label": { "title": "Label", @@ -279,6 +284,7 @@ "BlueprintParameterValueSchemaRequest": { "title": "BlueprintParameterValueSchemaRequest", "description": "Data contract of an install-form parameter value (JSON-Schema subset).", + "additionalProperties": false, "properties": { "default": { "title": "Default", diff --git a/specifications/blueprint/v1/schemas/src/blueprint.schema.json b/specifications/blueprint/v1/schemas/src/blueprint.schema.json index 66b1416..25d1dd9 100644 --- a/specifications/blueprint/v1/schemas/src/blueprint.schema.json +++ b/specifications/blueprint/v1/schemas/src/blueprint.schema.json @@ -36,6 +36,7 @@ "type": "object", "$defs": { "BlueprintComputeConstraintsRequest": { + "additionalProperties": false, "description": "Advanced, optional compute pins (ADR 0041) honored by Placement (ADR 0095).", "properties": { "acceleratorInterconnect": { @@ -152,6 +153,7 @@ "type": "object" }, "BlueprintConnectionRefRequest": { + "additionalProperties": false, "description": "A consumer-anchored wire (map value; keyed by the consumer input key).\n\nThe value names only the producer end (``fromRole`` / ``fromOutput``); the\nconsumer end is derived from the enclosing node (``toRole`` = the node name)\nand the map key (``toInput``). One wire per consumer input (inbound-uniqueness,\nenforced by the publish gate).", "properties": { "fromOutput": { @@ -174,6 +176,7 @@ "x-additionalPropertiesName": "inputKey" }, "BlueprintParameterGeneratorRequest": { + "additionalProperties": false, "properties": { "byteLength": { "default": 32, @@ -211,6 +214,7 @@ "type": "object" }, "BlueprintParameterRequest": { + "additionalProperties": false, "description": "An install-form parameter composed at the graph layer (map value; keyed by key).", "properties": { "description": { @@ -262,6 +266,7 @@ "x-additionalPropertiesName": "parameterKey" }, "BlueprintParameterUiRequest": { + "additionalProperties": false, "description": "Presentation metadata for an install-form parameter.", "properties": { "label": { @@ -277,6 +282,7 @@ "type": "object" }, "BlueprintParameterValueSchemaRequest": { + "additionalProperties": false, "description": "Data contract of an install-form parameter value (JSON-Schema subset).", "properties": { "default": { diff --git a/specifications/component/v1/schemas/dist/component.schema.json b/specifications/component/v1/schemas/dist/component.schema.json index 9d3a93c..c3a8a28 100644 --- a/specifications/component/v1/schemas/dist/component.schema.json +++ b/specifications/component/v1/schemas/dist/component.schema.json @@ -37,6 +37,7 @@ "$defs": { "ComponentBuildRequest": { "title": "ComponentBuildRequest", + "additionalProperties": false, "properties": { "arguments": { "title": "Arguments", @@ -89,6 +90,7 @@ }, "ComponentConfigRefEnvVarValueRequest": { "title": "ComponentConfigRefEnvVarValueRequest", + "additionalProperties": false, "properties": { "configKey": { "title": "Configkey", @@ -113,6 +115,7 @@ }, "ComponentContractRequest": { "title": "ComponentContractRequest", + "additionalProperties": false, "properties": { "inputs": { "title": "Inputs", @@ -135,6 +138,7 @@ }, "ComponentEndpointRequest": { "title": "ComponentEndpointRequest", + "additionalProperties": false, "properties": { "containerPort": { "title": "Containerport", @@ -175,6 +179,7 @@ "ComponentEnvVarRequest": { "title": "ComponentEnvVarRequest", "description": "One environment-variable entry (Kubernetes-style named entry).", + "additionalProperties": false, "properties": { "key": { "title": "Key", @@ -212,6 +217,7 @@ }, "ComponentGitRefRequest": { "title": "ComponentGitRefRequest", + "additionalProperties": false, "properties": { "name": { "title": "Name", @@ -236,6 +242,7 @@ }, "ComponentGitSourceRequest": { "title": "ComponentGitSourceRequest", + "additionalProperties": false, "properties": { "build": { "description": "How the repository is built into an image, or null when defaults apply.", @@ -282,6 +289,7 @@ "ComponentHealthProbesRequest": { "title": "ComponentHealthProbesRequest", "description": "Staged health probes (ADR 0093): startup, readiness, liveness.", + "additionalProperties": false, "properties": { "liveness": { "description": "Aliveness gate; failure restarts the container. Optional.", @@ -324,6 +332,7 @@ }, "ComponentImageSourceRequest": { "title": "ComponentImageSourceRequest", + "additionalProperties": false, "properties": { "ref": { "title": "Ref", @@ -345,6 +354,7 @@ }, "ComponentInputRequest": { "title": "ComponentInputRequest", + "additionalProperties": false, "properties": { "description": { "title": "Description", @@ -441,6 +451,7 @@ }, "ComponentInputTargetRequest": { "title": "ComponentInputTargetRequest", + "additionalProperties": false, "properties": { "envVarKey": { "title": "Envvarkey", @@ -459,6 +470,7 @@ "ComponentInputUiRequest": { "title": "ComponentInputUiRequest", "description": "Presentation metadata for a USER-supplied input.\n\nThe install-form control is derived from the input's schema (enum →\nselect, BOOLEAN → toggle, EMAIL format → email field, sensitive →\nmasked field) — only the label needs declaring.", + "additionalProperties": false, "properties": { "label": { "title": "Label", @@ -473,6 +485,7 @@ }, "ComponentLiteralEnvVarValueRequest": { "title": "ComponentLiteralEnvVarValueRequest", + "additionalProperties": false, "properties": { "isSensitive": { "title": "Issensitive", @@ -500,6 +513,7 @@ }, "ComponentOutputRequest": { "title": "ComponentOutputRequest", + "additionalProperties": false, "properties": { "description": { "title": "Description", @@ -550,6 +564,7 @@ }, "ComponentProbeRequest": { "title": "ComponentProbeRequest", + "additionalProperties": false, "properties": { "endpoint": { "title": "Endpoint", @@ -607,6 +622,7 @@ }, "ComponentScheduleRequest": { "title": "ComponentScheduleRequest", + "additionalProperties": false, "properties": { "cron": { "title": "Cron", @@ -621,6 +637,7 @@ }, "ComponentSpecRequest": { "title": "ComponentSpecRequest", + "additionalProperties": false, "properties": { "contract": { "description": "The composition contract: typed inputs the component needs and outputs it publishes.", @@ -638,6 +655,7 @@ }, "ComponentValueGeneratorRequest": { "title": "ComponentValueGeneratorRequest", + "additionalProperties": false, "properties": { "byteLength": { "title": "Bytelength", @@ -676,6 +694,7 @@ "ComponentValueSchemaRequest": { "title": "ComponentValueSchemaRequest", "description": "Data contract of an input or output value (JSON-Schema subset).", + "additionalProperties": false, "properties": { "default": { "title": "Default", @@ -776,6 +795,7 @@ }, "ComponentVolumeRequest": { "title": "ComponentVolumeRequest", + "additionalProperties": false, "properties": { "accessMode": { "title": "Accessmode", @@ -813,6 +833,7 @@ }, "ComponentWorkloadRequest": { "title": "ComponentWorkloadRequest", + "additionalProperties": false, "properties": { "command": { "title": "Command", diff --git a/specifications/component/v1/schemas/src/component.schema.json b/specifications/component/v1/schemas/src/component.schema.json index 2e7cefc..f47f4e5 100644 --- a/specifications/component/v1/schemas/src/component.schema.json +++ b/specifications/component/v1/schemas/src/component.schema.json @@ -36,6 +36,7 @@ "type": "object", "$defs": { "ComponentBuildRequest": { + "additionalProperties": false, "properties": { "arguments": { "additionalProperties": { @@ -88,6 +89,7 @@ "type": "object" }, "ComponentConfigRefEnvVarValueRequest": { + "additionalProperties": false, "properties": { "configKey": { "description": "Key of the organization's saved configuration value, resolved server-side at snapshot compute. The component never holds plaintext; the entry's own key may alias it.", @@ -112,6 +114,7 @@ "type": "object" }, "ComponentContractRequest": { + "additionalProperties": false, "properties": { "inputs": { "additionalProperties": { @@ -134,6 +137,7 @@ "type": "object" }, "ComponentEndpointRequest": { + "additionalProperties": false, "properties": { "containerPort": { "description": "Port the workload listens on inside the container.", @@ -173,6 +177,7 @@ "x-additionalPropertiesName": "endpointName" }, "ComponentEnvVarRequest": { + "additionalProperties": false, "description": "One environment-variable entry (Kubernetes-style named entry).", "properties": { "key": { @@ -211,6 +216,7 @@ "type": "object" }, "ComponentGitRefRequest": { + "additionalProperties": false, "properties": { "name": { "description": "The branch name or commit SHA to build from.", @@ -235,6 +241,7 @@ "type": "object" }, "ComponentGitSourceRequest": { + "additionalProperties": false, "properties": { "build": { "anyOf": [ @@ -280,6 +287,7 @@ "type": "object" }, "ComponentHealthProbesRequest": { + "additionalProperties": false, "description": "Staged health probes (ADR 0093): startup, readiness, liveness.", "properties": { "liveness": { @@ -323,6 +331,7 @@ "type": "object" }, "ComponentImageSourceRequest": { + "additionalProperties": false, "properties": { "ref": { "description": "OCI image reference the workload runs from.", @@ -344,6 +353,7 @@ "type": "object" }, "ComponentInputRequest": { + "additionalProperties": false, "properties": { "description": { "anyOf": [ @@ -440,6 +450,7 @@ "x-additionalPropertiesName": "inputName" }, "ComponentInputTargetRequest": { + "additionalProperties": false, "properties": { "envVarKey": { "description": "Environment variable the resolved value is bound into at runtime materialisation (e.g. ADMIN_EMAIL). Uppercase letters, digits, and underscores; must not start with a digit.", @@ -457,6 +468,7 @@ "type": "object" }, "ComponentInputUiRequest": { + "additionalProperties": false, "description": "Presentation metadata for a USER-supplied input.\n\nThe install-form control is derived from the input's schema (enum →\nselect, BOOLEAN → toggle, EMAIL format → email field, sensitive →\nmasked field) — only the label needs declaring.", "properties": { "label": { @@ -472,6 +484,7 @@ "type": "object" }, "ComponentLiteralEnvVarValueRequest": { + "additionalProperties": false, "properties": { "isSensitive": { "default": false, @@ -499,6 +512,7 @@ "type": "object" }, "ComponentOutputRequest": { + "additionalProperties": false, "properties": { "description": { "anyOf": [ @@ -549,6 +563,7 @@ "x-additionalPropertiesName": "outputName" }, "ComponentProbeRequest": { + "additionalProperties": false, "properties": { "endpoint": { "anyOf": [ @@ -606,6 +621,7 @@ "type": "object" }, "ComponentScheduleRequest": { + "additionalProperties": false, "properties": { "cron": { "description": "Cron expression on which a CRON workload runs.", @@ -620,6 +636,7 @@ "type": "object" }, "ComponentSpecRequest": { + "additionalProperties": false, "properties": { "contract": { "$ref": "#/$defs/ComponentContractRequest", @@ -637,6 +654,7 @@ "type": "object" }, "ComponentValueGeneratorRequest": { + "additionalProperties": false, "properties": { "byteLength": { "default": 32, @@ -674,6 +692,7 @@ "type": "object" }, "ComponentValueSchemaRequest": { + "additionalProperties": false, "description": "Data contract of an input or output value (JSON-Schema subset).", "properties": { "default": { @@ -775,6 +794,7 @@ "type": "object" }, "ComponentVolumeRequest": { + "additionalProperties": false, "properties": { "accessMode": { "default": "READ_WRITE_ONCE", @@ -812,6 +832,7 @@ "x-additionalPropertiesName": "volumeName" }, "ComponentWorkloadRequest": { + "additionalProperties": false, "properties": { "command": { "anyOf": [ diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md index 9310af0..697cee5 100644 --- a/specifications/component/v1/spec.md +++ b/specifications/component/v1/spec.md @@ -46,8 +46,10 @@ spec: { … } | `metadata` | REQUIRED. Identity. | | `spec` | REQUIRED. The definition itself. | -Unknown properties MUST be rejected at every level. A misspelled field is an -error, never a silently ignored one. +Unknown properties MUST be rejected with `ERR_UNKNOWN_FIELD` at every level, not +only at the root of the envelope. A misspelled field is an error, never a +silently ignored one — including when the misspelled field is optional, where +ignoring it would silently substitute the default. A validator encountering a `specVersion` it does not support MUST reject the document with `ERR_UNSUPPORTED_SPEC_VERSION` and MUST NOT attempt a best-effort diff --git a/tools/src/bundle.ts b/tools/src/bundle.ts index b495da2..026ef92 100644 --- a/tools/src/bundle.ts +++ b/tools/src/bundle.ts @@ -99,7 +99,7 @@ function conceptToDefName(concept: string): string { /** Refuse to emit a bundle that would make a validator reach over the network. */ function assertSelfContained(bundle: { [k: string]: Json }, family: Family): void { const defs = isObject(bundle.$defs) ? bundle.$defs : {} - for (const node of walkObjects(bundle)) { + for (const { node } of walkObjects(bundle)) { const ref = node.$ref if (typeof ref !== 'string') continue if (!ref.startsWith('#/$defs/')) { diff --git a/tools/src/lint.ts b/tools/src/lint.ts index 9bb1033..c23e032 100644 --- a/tools/src/lint.ts +++ b/tools/src/lint.ts @@ -67,6 +67,7 @@ function main(): void { checkDialect(doc, rel, failures) checkId(doc, rel, family.name, family.major, seenIds, failures) checkRefs(doc, rel, failures) + checkClosedObjects(doc, rel, failures) checkDefsNames(doc, rel, failures) checkMetaValid(doc, rel, failures) } @@ -122,7 +123,7 @@ function checkId( */ function checkRefs(doc: { [k: string]: Json }, rel: string, failures: Failures): void { const defs = isObject(doc.$defs) ? doc.$defs : {} - for (const node of walkObjects(doc)) { + for (const { node } of walkObjects(doc)) { for (const keyword of ['$ref', '$dynamicRef'] as const) { const ref = node[keyword] if (typeof ref !== 'string') continue @@ -143,6 +144,28 @@ function checkRefs(doc: { [k: string]: Json }, rel: string, failures: Failures): } } +/** + * Every object schema must close itself. `spec.md` §2 requires unknown + * properties to be rejected "at every level", and JSON Schema only does that + * where `additionalProperties: false` is written down — an omission validates a + * typo'd optional key clean and silently falls back to the default. + * + * Only schemas that declare `properties` are records. A schema using + * `additionalProperties` as a map value schema (`inputs`, `endpoints`, + * `arguments`) declares no `properties` and is deliberately open, because its + * keys are chosen by the document author. + */ +function checkClosedObjects(doc: { [k: string]: Json }, rel: string, failures: Failures): void { + for (const { node, pointer } of walkObjects(doc)) { + if (node.type !== 'object' || !isObject(node.properties)) continue + if (node.additionalProperties === false) continue + failures.add( + `${rel}: ${pointer || ''} declares properties but not ` + + '"additionalProperties": false — unknown properties MUST be rejected at every level', + ) + } +} + function checkDefsNames(doc: { [k: string]: Json }, rel: string, failures: Failures): void { if (!isObject(doc.$defs)) return for (const name of Object.keys(doc.$defs)) { diff --git a/tools/src/spec.ts b/tools/src/spec.ts index 5ddd99f..5d29ae9 100644 --- a/tools/src/spec.ts +++ b/tools/src/spec.ts @@ -143,15 +143,29 @@ export function canonicalJson(value: Json): string { return `${JSON.stringify(canonicalize(value), null, 2)}\n` } -/** Walk every nested object in a schema document, root included. */ -export function* walkObjects(node: Json): Generator<{ [k: string]: Json }> { +/** Escape a single JSON Pointer reference token (RFC 6901 §3). */ +function escapePointerToken(token: string): string { + return token.replace(/~/g, '~0').replace(/\//g, '~1') +} + +/** + * Walk every nested object in a schema document, root included, pairing each + * with the JSON Pointer that locates it. The pointer is what lets a diagnostic + * name the offending subschema instead of describing it. + */ +export function* walkObjects( + node: Json, + pointer = '', +): Generator<{ node: { [k: string]: Json }; pointer: string }> { if (Array.isArray(node)) { - for (const item of node) yield* walkObjects(item) + for (const [index, item] of node.entries()) yield* walkObjects(item, `${pointer}/${index}`) return } if (!isObject(node)) return - yield node - for (const key of Object.keys(node)) yield* walkObjects(node[key] as Json) + yield { node, pointer } + for (const key of Object.keys(node)) { + yield* walkObjects(node[key] as Json, `${pointer}/${escapePointerToken(key)}`) + } } export function relativeToRepo(path: string): string {