From 0b250c81c66d6766b83d62845809a4dcf6a726b5 Mon Sep 17 00:00:00 2001 From: tzh476 Date: Sun, 23 Aug 2026 16:38:48 +0800 Subject: [PATCH 1/3] fix: guard nullable 'message' fields in v2 ruleset selectors (issue #863) Change-Id: Ia9549f33f1c06d3a56c4af9f6d01c6596e645a98 --- .../messageExamples-spectral-rule-v2.ts | 4 ++-- packages/parser/src/ruleset/v2/ruleset.ts | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/parser/src/ruleset/v2/functions/messageExamples-spectral-rule-v2.ts b/packages/parser/src/ruleset/v2/functions/messageExamples-spectral-rule-v2.ts index 2d964762b..e41187032 100644 --- a/packages/parser/src/ruleset/v2/functions/messageExamples-spectral-rule-v2.ts +++ b/packages/parser/src/ruleset/v2/functions/messageExamples-spectral-rule-v2.ts @@ -17,9 +17,9 @@ export function asyncApi2MessageExamplesParserRule(parser: Parser): RuleDefiniti recommended: true, given: [ // messages - '$.channels.*.[publish,subscribe][?(@property === \'message\' && @.schemaFormat !== void 0)]', + '$.channels.*.[publish,subscribe][?(@property === \'message\' && !@null && @.schemaFormat !== void 0)]', '$.channels.*.[publish,subscribe].message.oneOf[?(!@null && @.schemaFormat !== void 0)]', - '$.components.channels.*.[publish,subscribe].message[?(@property === \'message\' && @.schemaFormat !== void 0)]', + '$.components.channels.*.[publish,subscribe].message[?(@property === \'message\' && !@null && @.schemaFormat !== void 0)]', '$.components.channels.*.[publish,subscribe].message.oneOf[?(!@null && @.schemaFormat !== void 0)]', '$.components.messages[?(!@null && @.schemaFormat !== void 0)]', // message traits diff --git a/packages/parser/src/ruleset/v2/ruleset.ts b/packages/parser/src/ruleset/v2/ruleset.ts index b03a7549f..2a0ff1c9b 100644 --- a/packages/parser/src/ruleset/v2/ruleset.ts +++ b/packages/parser/src/ruleset/v2/ruleset.ts @@ -123,9 +123,9 @@ export const v2CoreRuleset = { recommended: true, given: [ // messages - '$.channels.*.[publish,subscribe][?(@property === \'message\' && @.schemaFormat === void 0)]', + '$.channels.*.[publish,subscribe][?(@property === \'message\' && !@null && @.schemaFormat === void 0)]', '$.channels.*.[publish,subscribe].message.oneOf[?(!@null && @.schemaFormat === void 0)]', - '$.components.channels.*.[publish,subscribe][?(@property === \'message\' && @.schemaFormat === void 0)]', + '$.components.channels.*.[publish,subscribe][?(@property === \'message\' && !@null && @.schemaFormat === void 0)]', '$.components.channels.*.[publish,subscribe].message.oneOf[?(!@null && @.schemaFormat === void 0)]', '$.components.messages[?(!@null && @.schemaFormat === void 0)]', // message traits @@ -201,9 +201,9 @@ export const v2SchemasRuleset = (parser: Parser) => { severity: 'error', recommended: true, given: [ - '$.channels[*][publish,subscribe][?(@property === \'message\' && @.schemaFormat === void 0)].payload.default^', + '$.channels[*][publish,subscribe][?(@property === \'message\' && !@null && @.schemaFormat === void 0)].payload.default^', '$.channels.*.parameters.*.schema.default^', - '$.components.channels[*][publish,subscribe][?(@property === \'message\' && @.schemaFormat === void 0)].payload.default^', + '$.components.channels[*][publish,subscribe][?(@property === \'message\' && !@null && @.schemaFormat === void 0)].payload.default^', '$.components.channels.*.parameters.*.schema.default^', '$.components.schemas.*.default^', '$.components.parameters.*.schema.default^', @@ -223,9 +223,9 @@ export const v2SchemasRuleset = (parser: Parser) => { severity: 'error', recommended: true, given: [ - '$.channels[*][publish,subscribe][?(@property === \'message\' && @.schemaFormat === void 0)].payload.examples^', + '$.channels[*][publish,subscribe][?(@property === \'message\' && !@null && @.schemaFormat === void 0)].payload.examples^', '$.channels.*.parameters.*.schema.examples^', - '$.components.channels[*][publish,subscribe][?(@property === \'message\' && @.schemaFormat === void 0)].payload.examples^', + '$.components.channels[*][publish,subscribe][?(@property === \'message\' && !@null && @.schemaFormat === void 0)].payload.examples^', '$.components.channels.*.parameters.*.schema.examples^', '$.components.schemas.*.examples^', '$.components.parameters.*.schema.examples^', @@ -338,9 +338,9 @@ export const v2RecommendedRuleset = { recommended: true, formats: AsyncAPIFormats.filterByMajorVersions(['2']).excludeByVersions(['2.0.0', '2.1.0', '2.2.0', '2.3.0']).formats(), // message.messageId is available starting from v2.4. given: [ - '$.channels.*.[publish,subscribe][?(@property === "message" && @.oneOf == void 0)]', + '$.channels.*.[publish,subscribe][?(@property === "message" && !@null && @.oneOf == void 0)]', '$.channels.*.[publish,subscribe].message.oneOf.*', - '$.components.channels.*.[publish,subscribe][?(@property === "message" && @.oneOf == void 0)]', + '$.components.channels.*.[publish,subscribe][?(@property === "message" && !@null && @.oneOf == void 0)]', '$.components.channels.*.[publish,subscribe].message.oneOf.*', '$.components.messages.*', ], From 2c411412b3276ba45d3d4197ac06c9f426d016e4 Mon Sep 17 00:00:00 2001 From: tzh476 Date: Mon, 24 Aug 2026 01:17:56 +0800 Subject: [PATCH 2/3] chore: changeset for #863 Change-Id: I9fa2cccd89ac357e05c1be34432b042d55edb15c --- .changeset/863-nullable-message.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/863-nullable-message.md diff --git a/.changeset/863-nullable-message.md b/.changeset/863-nullable-message.md new file mode 100644 index 000000000..9a9ebc081 --- /dev/null +++ b/.changeset/863-nullable-message.md @@ -0,0 +1,5 @@ +--- +'@asyncapi/parser': patch +--- + +Guard nullable `message` fields in v2 ruleset selectors to prevent validation crashes (issue #863). From 7cead1a779c9290e25f17bf4253bdbc812bf78bb Mon Sep 17 00:00:00 2001 From: tzh476 Date: Sat, 29 Aug 2026 23:30:53 +0800 Subject: [PATCH 3/3] test(ruleset): add the missing regression test for the nullable message guards This PR changed eight JSONPath expressions but shipped no test, so nothing prevented the guards from being dropped again. The test drives the public `parser.validate()` path with the exact document shape from issue #863 -- a schema property literally named `message` whose example value is `null`. Removing the `!@null` guards makes it fail with the error from the issue: TypeError: Cannot read properties of null (reading 'schemaFormat') One detail worth recording, because it decides what the test must assert: `validate()` **resolves** even on the unfixed code and reports the crash as a diagnostic rather than rejecting. A "does it throw" assertion therefore passes both before and after the fix and would have guarded nothing. The two assertions that actually catch the regression are the diagnostic check and the `parse()` check. Measured on this branch: with guards 3 passed guards removed 2 failed, 1 passed (the throw-based one is the passer) Change-Id: I7b412b6d5b11132d332913a49ca0263f7c578bef Disclosure: prepared by a human working with an LLM assistant. --- .../parser/test/nullable-message-863.spec.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 packages/parser/test/nullable-message-863.spec.ts diff --git a/packages/parser/test/nullable-message-863.spec.ts b/packages/parser/test/nullable-message-863.spec.ts new file mode 100644 index 000000000..37bf290b9 --- /dev/null +++ b/packages/parser/test/nullable-message-863.spec.ts @@ -0,0 +1,66 @@ +import { Parser } from '../src/parser'; + +/** + * Regression test for issue #863: a payload property literally named `message` + * with a null example value crashed document validation. + * + * The v2 ruleset selected messages with JSONPath expressions of the form + * + * $.channels.*.[publish,subscribe][?(@property === 'message' && @.schemaFormat === void 0)] + * + * `@property === 'message'` also matches a *schema property* named `message`, + * not just a message object. When that property's example value is `null`, + * `@.schemaFormat` dereferences null and Spectral aborts the whole validation: + * + * jsonPath: Cannot read properties of null (reading 'schemaFormat') + * + * Adding `!@null` to those expressions skips null values before the member + * access. This test drives the public `parser.validate()` path with the exact + * document shape from the issue, so it fails on master with the thrown error and + * passes once the guards are in place. + */ +describe('issue #863: payload property named "message" with a null example', function () { + const parser = new Parser(); + + const documentRaw = { + asyncapi: '2.6.0', + info: { title: 'Nullable message property', version: '1.0.0' }, + channels: { + 'user/signup': { + publish: { + message: { + payload: { + type: 'object', + properties: { + // A schema property that happens to be named `message`. + message: { type: ['string', 'null'], maxLength: 50 }, + }, + required: ['message'], + }, + examples: [{ name: 'return', payload: { message: null } }], + }, + }, + }, + }, + }; + + it('validates without throwing on the null example value', async function () { + // The bug surfaced as a thrown Error, not as a diagnostic, so the assertion + // is that validate() resolves at all. Asserting only "no diagnostics" would + // not catch it: on master this rejects before diagnostics are produced. + await expect(parser.validate(documentRaw)).resolves.toBeDefined(); + }); + + it('does not report the jsonPath null-dereference as a diagnostic either', async function () { + const diagnostics = await parser.validate(documentRaw); + const nullDeref = diagnostics.filter((d: { message: string }) => + /Cannot read properties of null/.test(d.message), + ); + expect(nullDeref).toEqual([]); + }); + + it('still parses the document into a model', async function () { + const { document } = await parser.parse(documentRaw); + expect(document).toBeDefined(); + }); +});