From fb92d5eb74e2b8d1421ea11c1dc0eba3bb87a76a Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Mon, 31 Aug 2026 14:18:10 +0200 Subject: [PATCH 01/11] linter: add check ref-best-practice Signed-off-by: Jan Kowalleck --- tools/src/main/js/linter/checks/index.js | 1 + .../linter/checks/ref-best-practice.check.js | 110 ++++++++++++++++++ .../schema-v2/json-schema-semantic-tests.js | 59 ---------- 3 files changed, 111 insertions(+), 59 deletions(-) create mode 100644 tools/src/main/js/linter/checks/ref-best-practice.check.js diff --git a/tools/src/main/js/linter/checks/index.js b/tools/src/main/js/linter/checks/index.js index dc12726cc..922f85e63 100644 --- a/tools/src/main/js/linter/checks/index.js +++ b/tools/src/main/js/linter/checks/index.js @@ -39,6 +39,7 @@ export * from './schema-id-pattern.check.js'; export * from './schema-id-filepath.check.js'; export * from './schema-comment.check.js'; export * from './schema-draft.check.js'; +export * from './ref-best-practice.check.js'; export * from './model-property-order.check.js'; export * from './model-structure.check.js'; export * from './formatting-indent.check.js'; diff --git a/tools/src/main/js/linter/checks/ref-best-practice.check.js b/tools/src/main/js/linter/checks/ref-best-practice.check.js new file mode 100644 index 000000000..8f3205ebc --- /dev/null +++ b/tools/src/main/js/linter/checks/ref-best-practice.check.js @@ -0,0 +1,110 @@ +/** + * CycloneDX Schema Linter - $ref Best Practice Check + * + * Validates that `$ref` usage follows JSON Schema best practice: + * - `$ref` value is a string + * - no absolute `$ref` paths + * - same-file `$ref` starts with "#" + * - only documentational siblings are allowed alongside `$ref` + * + * @license Apache-2.0 + */ + +import {dirname, join} from 'path'; + +import {LintCheck, registerCheck, Severity, traverseSchema} from '../index.js'; + +/** + * Keys allowed as siblings of `$ref`. + */ +const REF_ALLOWED_SIBLINGS = Object.freeze(new Set([ + '$ref', // the $ref itself + '$comment', 'title', 'description', 'examples', // documentational + /* do NOT add any non-documentationals + instead, use: + { "allOf": { "$ref": ... }, "$id" ..., "$anchor": ... } + { "allOf": { "$ref": ... }, "default" ... } + instead of additionalProperties -- { "allOf": { "$ref": ... }, "unevaluatedItems" ... } + */ +])); + +// keys whose values aren't schemas - don't inspect nodes underneath them +const SKIP_KEYS = Object.freeze(new Set( + ['enum', 'const', 'examples', 'default', 'meta:enum'])); + +/** + * Check that validates `$ref` best practice. + */ +class RefBestPracticeCheck extends LintCheck { + constructor() { + super( + 'ref-best-practice', + '$ref Best Practice', + 'Validates that $ref usage follows JSON Schema best practice.', + Severity.ERROR + ); + } + + async run(schema, rawContent, config = {}, filePath = null) { + const issues = []; + + traverseSchema(schema, (node, path, key, parent) => { + if (node === null || typeof node !== 'object' || Array.isArray(node)) return; + if (!('$ref' in node)) return; + if (key !== null && SKIP_KEYS.has(key)) return; + + const ref = node['$ref']; + + if (typeof ref !== 'string') { + issues.push(this.createIssue( + 'Unexpected type of $ref.', + `${path}.$ref`, + {actual: typeof ref, expected: 'string'} + )); + return; + } + + if (ref.startsWith('/')) { + issues.push(this.createIssue( + 'Absolute $ref is not allowed.', + `${path}.$ref`, + {actual: ref, expected: 'a relative reference'} + )); + } else if (filePath !== null) { + const hashPos = ref.indexOf('#'); + const filePart = hashPos === -1 + ? ref + : ref.slice(0, hashPos); + if (filePart !== '' && join(dirname(filePath), filePart) === filePath) { + const fragment = hashPos === -1 + ? '#' + : ref.slice(hashPos); + issues.push(this.createIssue( + 'Same-file $ref must start with "#".', + `${path}.$ref`, + {actual: ref, expected: fragment} + )); + } + } + + for (const siblingKey of Object.keys(node)) { + if (!REF_ALLOWED_SIBLINGS.has(siblingKey)) { + issues.push(this.createIssue( + 'Unexpected key along with $ref. Wrap the $ref in an "allOf" instead.', + `${path}.${siblingKey}`, + {actual: 'present', expected: 'absent'} + )); + } + } + }); + + return issues; + } +} + +// Create and register the check +const check = new RefBestPracticeCheck(); +registerCheck(check); + +export {RefBestPracticeCheck}; +export default check; diff --git a/tools/src/test/js/schema-v2/json-schema-semantic-tests.js b/tools/src/test/js/schema-v2/json-schema-semantic-tests.js index 92bd924c3..013b44998 100644 --- a/tools/src/test/js/schema-v2/json-schema-semantic-tests.js +++ b/tools/src/test/js/schema-v2/json-schema-semantic-tests.js @@ -278,64 +278,6 @@ const _REF_ALLOWED_SIBLINGS = Object.freeze(new Set([ */ ])) -/** - * `$ref` must follow JSON schema best-practice. - * @param {*} schema - * @param {string} schemaFile - * @return {number} number of errors found - */ -function testRefBestPractice(schema, schemaFile) { - let errCnt = 0 - for (const [path, node] of _findRefs(schema)) { - const ref = node['$ref'] - - if (typeof ref !== 'string') { - ++errCnt - _printError( - ref, 'a string', - 'unexpected type of $ref', - schemaFile, `${path}.$ref`) - continue - } - - if (ref.startsWith('/')) { - ++errCnt - _printError( - ref, 'a string', - 'absolute $ref', - schemaFile, `${path}.$ref`) - } else { - const hashPos = ref.indexOf('#') - const filePart = hashPos === -1 - ? ref - : ref.slice(0, hashPos) - if (filePart !== '') { - const resolved = join(dirname(schemaFile), filePart) - if (resolved === schemaFile) { - ++errCnt - const fragment = hashPos === -1 - ? '#' - : ref.slice(hashPos) - _printError( - ref, fragment, - 'same-file $ref must start with "#"', - schemaFile, path) - } - } - } - - const otherKeys = new Set(Object.keys(node)) - for (const key of otherKeys.difference(_REF_ALLOWED_SIBLINGS)) { - ++errCnt - _printError( - 'present', 'absent', - 'unexpected key along with $ref', - schemaFile, `${path}.${key}`) - } - } - return errCnt -} - /** * `bom-ref` properties must `$ref` refType — and nothing else may. * @param {*} schema @@ -500,7 +442,6 @@ function testDefaultValues(schema, schemaFile) { /** @type {Readonly>} */ const tests = Object.freeze({ - '$ref best practice': testRefBestPractice, 'refType usage (`bom-ref` <-> refType)': testRefTypeUsage, 'additionalProperties is `false`': testAdditionalProperties, 'enum value in range': testEnumValues, From fe5d015bb065428b4c78de5dd3cbf4d05d72cdfd Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Mon, 31 Aug 2026 14:31:33 +0200 Subject: [PATCH 02/11] linter: add check ref-best-practice Signed-off-by: Jan Kowalleck --- .../linter/checks/ref-best-practice.check.js | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/tools/src/main/js/linter/checks/ref-best-practice.check.js b/tools/src/main/js/linter/checks/ref-best-practice.check.js index 8f3205ebc..3568182f6 100644 --- a/tools/src/main/js/linter/checks/ref-best-practice.check.js +++ b/tools/src/main/js/linter/checks/ref-best-practice.check.js @@ -32,6 +32,34 @@ const REF_ALLOWED_SIBLINGS = Object.freeze(new Set([ const SKIP_KEYS = Object.freeze(new Set( ['enum', 'const', 'examples', 'default', 'meta:enum'])); +/** + * Build a predicate that tells whether a `$ref` file-part points to the current schema itself. + * Prefers the actual file path; falls back to the root schema's `$id` URL. + * + * @param {string|null} filePath + * @param {*} schema - the root schema + * @return {(function(string): boolean)|null} predicate, or null if no base is determinable + */ +function makeSameFileTest(filePath, schema) { + if (filePath !== null) { + // filesystem semantics + const baseDir = dirname(filePath); + return filePart => join(baseDir, filePart) === filePath; + } + const id = schema?.['$id']; + if (typeof id === 'string' && URL.canParse(id)) { + // URL semantics: resolve the ref against the $id and compare + return filePart => { + try { + return new URL(filePart, id).href === new URL(id).href; + } catch { + return false; + } + }; + } + return null; // no base known - skip the same-file rule +} + /** * Check that validates `$ref` best practice. */ @@ -48,6 +76,8 @@ class RefBestPracticeCheck extends LintCheck { async run(schema, rawContent, config = {}, filePath = null) { const issues = []; + const isSameFile = makeSameFileTest(filePath, schema); + traverseSchema(schema, (node, path, key, parent) => { if (node === null || typeof node !== 'object' || Array.isArray(node)) return; if (!('$ref' in node)) return; @@ -70,12 +100,12 @@ class RefBestPracticeCheck extends LintCheck { `${path}.$ref`, {actual: ref, expected: 'a relative reference'} )); - } else if (filePath !== null) { + } else if (isSameFile !== null) { const hashPos = ref.indexOf('#'); const filePart = hashPos === -1 ? ref : ref.slice(0, hashPos); - if (filePart !== '' && join(dirname(filePath), filePart) === filePath) { + if (filePart !== '' && isSameFile(filePart)) { const fragment = hashPos === -1 ? '#' : ref.slice(hashPos); From 3bcc377504c749a20aa53b17639bc89aeca3ebb5 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Mon, 31 Aug 2026 14:43:00 +0200 Subject: [PATCH 03/11] linter: add check ref-best-practice Signed-off-by: Jan Kowalleck --- .../linter/checks/ref-best-practice.check.js | 28 +++++++++++-------- tools/src/main/js/linter/index.js | 4 ++- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/tools/src/main/js/linter/checks/ref-best-practice.check.js b/tools/src/main/js/linter/checks/ref-best-practice.check.js index 3568182f6..786a5b2f2 100644 --- a/tools/src/main/js/linter/checks/ref-best-practice.check.js +++ b/tools/src/main/js/linter/checks/ref-best-practice.check.js @@ -10,9 +10,9 @@ * @license Apache-2.0 */ -import {dirname, join} from 'path'; +import { dirname, join } from 'path'; -import {LintCheck, registerCheck, Severity, traverseSchema} from '../index.js'; +import { LintCheck, registerCheck, Severity, traverseSchema } from '../index.js'; /** * Keys allowed as siblings of `$ref`. @@ -28,9 +28,11 @@ const REF_ALLOWED_SIBLINGS = Object.freeze(new Set([ */ ])); -// keys whose values aren't schemas - don't inspect nodes underneath them -const SKIP_KEYS = Object.freeze(new Set( - ['enum', 'const', 'examples', 'default', 'meta:enum'])); +// keys whose values aren't schemas - their entire subtrees are pruned +const SKIP_KEYS = Object.freeze(new Set([ + 'enum', 'const', 'default', // values + 'examples', 'meta:enum', // documentational +])); /** * Build a predicate that tells whether a `$ref` file-part points to the current schema itself. @@ -78,10 +80,12 @@ class RefBestPracticeCheck extends LintCheck { const isSameFile = makeSameFileTest(filePath, schema); - traverseSchema(schema, (node, path, key, parent) => { + traverseSchema(schema, (node, path, key) => { + // don't descend into keys whose values aren't schemas + if (typeof key === 'string' && SKIP_KEYS.has(key)) return false; + if (node === null || typeof node !== 'object' || Array.isArray(node)) return; if (!('$ref' in node)) return; - if (key !== null && SKIP_KEYS.has(key)) return; const ref = node['$ref']; @@ -89,7 +93,7 @@ class RefBestPracticeCheck extends LintCheck { issues.push(this.createIssue( 'Unexpected type of $ref.', `${path}.$ref`, - {actual: typeof ref, expected: 'string'} + { actual: typeof ref, expected: 'string' } )); return; } @@ -98,7 +102,7 @@ class RefBestPracticeCheck extends LintCheck { issues.push(this.createIssue( 'Absolute $ref is not allowed.', `${path}.$ref`, - {actual: ref, expected: 'a relative reference'} + { actual: ref, expected: 'a relative reference' } )); } else if (isSameFile !== null) { const hashPos = ref.indexOf('#'); @@ -112,7 +116,7 @@ class RefBestPracticeCheck extends LintCheck { issues.push(this.createIssue( 'Same-file $ref must start with "#".', `${path}.$ref`, - {actual: ref, expected: fragment} + { actual: ref, expected: fragment } )); } } @@ -122,7 +126,7 @@ class RefBestPracticeCheck extends LintCheck { issues.push(this.createIssue( 'Unexpected key along with $ref. Wrap the $ref in an "allOf" instead.', `${path}.${siblingKey}`, - {actual: 'present', expected: 'absent'} + { actual: 'present', expected: 'absent' } )); } } @@ -136,5 +140,5 @@ class RefBestPracticeCheck extends LintCheck { const check = new RefBestPracticeCheck(); registerCheck(check); -export {RefBestPracticeCheck}; +export { RefBestPracticeCheck }; export default check; diff --git a/tools/src/main/js/linter/index.js b/tools/src/main/js/linter/index.js index a56ccf9b5..b197151b5 100644 --- a/tools/src/main/js/linter/index.js +++ b/tools/src/main/js/linter/index.js @@ -384,7 +384,9 @@ export class SchemaLinter { * @param {object} [parent] - Parent node (used internally) */ export function traverseSchema(schema, visitor, path = '$', key = null, parent = null) { - visitor(schema, path, key, parent); + if (visitor(schema, path, key, parent) === false) { + return; // visitor pruned this subtree + } if (typeof schema !== 'object' || schema === null) { return; From 73466fd2ca60058c8099a5de83e4db7fdc57b7fa Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Mon, 31 Aug 2026 14:46:15 +0200 Subject: [PATCH 04/11] linter: add check ref-best-practice Signed-off-by: Jan Kowalleck --- tools/src/main/js/linter/index.js | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tools/src/main/js/linter/index.js b/tools/src/main/js/linter/index.js index b197151b5..0c6c50376 100644 --- a/tools/src/main/js/linter/index.js +++ b/tools/src/main/js/linter/index.js @@ -376,12 +376,24 @@ export class SchemaLinter { } /** - * Utility to traverse a JSON schema and call a visitor function - * @param {object} schema - The schema to traverse - * @param {function} visitor - Function called for each node: (node, path, key, parent) - * @param {string} [path] - Current path (used internally) - * @param {string} [key] - Current key (used internally) - * @param {object} [parent] - Parent node (used internally) + * Utility to traverse a JSON schema depth-first and call a visitor function for each node. + * + * The visitor is invoked before descending into a node's children. + * If the visitor returns `false`, the node's children are not traversed + * (the subtree is pruned); any other return value (including `undefined`) + * continues the traversal. + * + * @param {*} schema - The schema (or sub-schema/value) to traverse + * @param {function(*, string, (string|number|null), (object|null)): (boolean|void)} visitor - + * Function called for each node with `(node, path, key, parent)`: + * - `node`: the current value (object, array, or primitive) + * - `path`: JSON-path-like location, e.g. `$.properties.foo[0]` + * - `key`: the property name or array index of `node` in its parent, `null` at the root + * - `parent`: the parent object/array, `null` at the root + * Return `false` to skip traversal of this node's children. + * @param {string} [path='$'] - Current path (used internally) + * @param {string|number|null} [key=null] - Current key (used internally) + * @param {object|null} [parent=null] - Parent node (used internally) */ export function traverseSchema(schema, visitor, path = '$', key = null, parent = null) { if (visitor(schema, path, key, parent) === false) { From e3f477335f65dcb55badcf71063123fba86b0405 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Mon, 31 Aug 2026 14:52:18 +0200 Subject: [PATCH 05/11] linter: add check ref-best-practice Signed-off-by: Jan Kowalleck --- tools/src/main/js/linter/checks/ref-best-practice.check.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/src/main/js/linter/checks/ref-best-practice.check.js b/tools/src/main/js/linter/checks/ref-best-practice.check.js index 786a5b2f2..8dbf02f34 100644 --- a/tools/src/main/js/linter/checks/ref-best-practice.check.js +++ b/tools/src/main/js/linter/checks/ref-best-practice.check.js @@ -81,8 +81,10 @@ class RefBestPracticeCheck extends LintCheck { const isSameFile = makeSameFileTest(filePath, schema); traverseSchema(schema, (node, path, key) => { - // don't descend into keys whose values aren't schemas - if (typeof key === 'string' && SKIP_KEYS.has(key)) return false; + if (typeof key === 'string' && SKIP_KEYS.has(key)) { + // don't descend into keys whose values aren't schemas + return false; + } if (node === null || typeof node !== 'object' || Array.isArray(node)) return; if (!('$ref' in node)) return; From 27f69a299c01f456e7b69dedd2ed8508d5f1ae7c Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Mon, 31 Aug 2026 15:07:49 +0200 Subject: [PATCH 06/11] linter: add check ref-best-practice Signed-off-by: Jan Kowalleck --- tools/src/main/js/linter/index.js | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/tools/src/main/js/linter/index.js b/tools/src/main/js/linter/index.js index 0c6c50376..a51f0bb25 100644 --- a/tools/src/main/js/linter/index.js +++ b/tools/src/main/js/linter/index.js @@ -375,6 +375,21 @@ export class SchemaLinter { } +/** + * Visitor invoked for each node during schema traversal. + * + * All arguments are to be treated as read-only: + * visitors must NOT modify the schema, nodes, or parents — traversal is + * for inspection only. Mutating during traversal leads to undefined behaviour. + * + * @callback SchemaVisitor + * @param {Readonly<*>} node - The current value (object, array, or primitive). Do not modify. + * @param {string} path - JSON-path-like location, e.g. `$.properties.foo[0]` + * @param {string|number|null} key - The property name or array index of `node` in its parent, `null` at the root + * @param {Readonly|null} parent - The parent object/array, `null` at the root. Do not modify. + * @returns {boolean|void} Return `false` to skip traversal of this node's children; any other value continues. + */ + /** * Utility to traverse a JSON schema depth-first and call a visitor function for each node. * @@ -383,14 +398,10 @@ export class SchemaLinter { * (the subtree is pruned); any other return value (including `undefined`) * continues the traversal. * - * @param {*} schema - The schema (or sub-schema/value) to traverse - * @param {function(*, string, (string|number|null), (object|null)): (boolean|void)} visitor - - * Function called for each node with `(node, path, key, parent)`: - * - `node`: the current value (object, array, or primitive) - * - `path`: JSON-path-like location, e.g. `$.properties.foo[0]` - * - `key`: the property name or array index of `node` in its parent, `null` at the root - * - `parent`: the parent object/array, `null` at the root - * Return `false` to skip traversal of this node's children. + * The traversed schema is treated as immutable: visitors must not mutate it. + * + * @param {Readonly<*>} schema - The schema (or sub-schema/value) to traverse. Not modified. + * @param {SchemaVisitor} visitor - Function called for each node with `(node, path, key, parent)` * @param {string} [path='$'] - Current path (used internally) * @param {string|number|null} [key=null] - Current key (used internally) * @param {object|null} [parent=null] - Parent node (used internally) From 93ce8ec3858af1f498e4d0bb1af83ab2adb0c0a9 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Mon, 31 Aug 2026 15:09:00 +0200 Subject: [PATCH 07/11] linter: add check ref-best-practice Signed-off-by: Jan Kowalleck --- .../test/js/schema-v2/json-schema-semantic-tests.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tools/src/test/js/schema-v2/json-schema-semantic-tests.js b/tools/src/test/js/schema-v2/json-schema-semantic-tests.js index 013b44998..ab5ec62c5 100644 --- a/tools/src/test/js/schema-v2/json-schema-semantic-tests.js +++ b/tools/src/test/js/schema-v2/json-schema-semantic-tests.js @@ -267,17 +267,6 @@ function _printError(actual, expected, msg, schemaFile, schemaPath) { // region tests -const _REF_ALLOWED_SIBLINGS = Object.freeze(new Set([ - '$ref', // the $ref itself - '$comment', 'title', 'description', 'examples', // documentational - /* do NOT add any non-documentationals - instead, use: - { "allOf": { "$ref": ... }, "$id" ..., "$anchor": ... } - { "allOf": { "$ref": ... }, "default" ... } - instead of additionalProperties -- { "allOf": { "$ref": ... }, "unevaluatedItems" ... } - */ -])) - /** * `bom-ref` properties must `$ref` refType — and nothing else may. * @param {*} schema From 6fe8a870b5e6a2a05926ec591ecc8b816172d673 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Mon, 31 Aug 2026 15:25:38 +0200 Subject: [PATCH 08/11] linter: add check ref-best-practice Signed-off-by: Jan Kowalleck --- .../js/linter/checks/ref-best-practice.check.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tools/src/main/js/linter/checks/ref-best-practice.check.js b/tools/src/main/js/linter/checks/ref-best-practice.check.js index 8dbf02f34..fc2f8f37f 100644 --- a/tools/src/main/js/linter/checks/ref-best-practice.check.js +++ b/tools/src/main/js/linter/checks/ref-best-practice.check.js @@ -10,7 +10,7 @@ * @license Apache-2.0 */ -import { dirname, join } from 'path'; +import { dirname, join, resolve } from 'path'; import { LintCheck, registerCheck, Severity, traverseSchema } from '../index.js'; @@ -28,7 +28,9 @@ const REF_ALLOWED_SIBLINGS = Object.freeze(new Set([ */ ])); -// keys whose values aren't schemas - their entire subtrees are pruned +/** + * Keys whose values aren't schemas - their entire subtrees are pruned + */ const SKIP_KEYS = Object.freeze(new Set([ 'enum', 'const', 'default', // values 'examples', 'meta:enum', // documentational @@ -38,15 +40,15 @@ const SKIP_KEYS = Object.freeze(new Set([ * Build a predicate that tells whether a `$ref` file-part points to the current schema itself. * Prefers the actual file path; falls back to the root schema's `$id` URL. * + * @param {Readonly<*>} schema - the root schema * @param {string|null} filePath - * @param {*} schema - the root schema * @return {(function(string): boolean)|null} predicate, or null if no base is determinable */ -function makeSameFileTest(filePath, schema) { +function makeSameFileTest(schema, filePath) { if (filePath !== null) { // filesystem semantics const baseDir = dirname(filePath); - return filePart => join(baseDir, filePart) === filePath; + return filePart => resolve(baseDir, filePart) === filePath; } const id = schema?.['$id']; if (typeof id === 'string' && URL.canParse(id)) { @@ -78,7 +80,7 @@ class RefBestPracticeCheck extends LintCheck { async run(schema, rawContent, config = {}, filePath = null) { const issues = []; - const isSameFile = makeSameFileTest(filePath, schema); + const isSameFile = makeSameFileTest(schema, filePath); traverseSchema(schema, (node, path, key) => { if (typeof key === 'string' && SKIP_KEYS.has(key)) { From e491bdb6cfb570fae5a5888dbf840bbeb2e324a4 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Mon, 31 Aug 2026 15:38:47 +0200 Subject: [PATCH 09/11] linter: add check ref-best-practice Signed-off-by: Jan Kowalleck --- .../linter/checks/ref-best-practice.check.js | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/tools/src/main/js/linter/checks/ref-best-practice.check.js b/tools/src/main/js/linter/checks/ref-best-practice.check.js index fc2f8f37f..e0bc2009e 100644 --- a/tools/src/main/js/linter/checks/ref-best-practice.check.js +++ b/tools/src/main/js/linter/checks/ref-best-practice.check.js @@ -10,7 +10,8 @@ * @license Apache-2.0 */ -import { dirname, join, resolve } from 'path'; +import { resolve } from 'path'; +import { pathToFileURL } from 'url'; import { LintCheck, registerCheck, Severity, traverseSchema } from '../index.js'; @@ -36,32 +37,33 @@ const SKIP_KEYS = Object.freeze(new Set([ 'examples', 'meta:enum', // documentational ])); + /** - * Build a predicate that tells whether a `$ref` file-part points to the current schema itself. - * Prefers the actual file path; falls back to the root schema's `$id` URL. + * Build a predicate that tells whether a `$ref` file-part resolves to the current schema itself, + * mimicking JSON Schema's reference resolution: + * the base URI is the root schema's `$id` if present, otherwise the retrieval URI (the file's location). * * @param {Readonly<*>} schema - the root schema - * @param {string|null} filePath - * @return {(function(string): boolean)|null} predicate, or null if no base is determinable + * @param {string|null} filePath - OS-native path the schema was read from, if any + * @return {(function(string): boolean)|null} predicate, or null if no base URI is determinable */ function makeSameFileTest(schema, filePath) { - if (filePath !== null) { - // filesystem semantics - const baseDir = dirname(filePath); - return filePart => resolve(baseDir, filePart) === filePath; - } + // per JSON Schema spec: `$id` establishes the base URI ... const id = schema?.['$id']; - if (typeof id === 'string' && URL.canParse(id)) { - // URL semantics: resolve the ref against the $id and compare - return filePart => { - try { - return new URL(filePart, id).href === new URL(id).href; - } catch { - return false; - } - }; + const base = (typeof id === 'string' && URL.canParse(id)) + ? new URL(id) + // ... with the retrieval URI as fallback + : (filePath !== null ? pathToFileURL(resolve(filePath)) : null); + if (base === null) { + return null; // no base known - skip the same-file rule } - return null; // no base known - skip the same-file rule + return filePart => { + try { + return new URL(filePart, base).href === base.href; + } catch { + return false; + } + }; } /** From b1a78970f0258936b0298312260fd864b2b82311 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Mon, 31 Aug 2026 15:40:36 +0200 Subject: [PATCH 10/11] linter: add check ref-best-practice Signed-off-by: Jan Kowalleck --- tools/src/main/js/linter/checks/ref-best-practice.check.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/src/main/js/linter/checks/ref-best-practice.check.js b/tools/src/main/js/linter/checks/ref-best-practice.check.js index e0bc2009e..a6792e39e 100644 --- a/tools/src/main/js/linter/checks/ref-best-practice.check.js +++ b/tools/src/main/js/linter/checks/ref-best-practice.check.js @@ -52,8 +52,9 @@ function makeSameFileTest(schema, filePath) { const id = schema?.['$id']; const base = (typeof id === 'string' && URL.canParse(id)) ? new URL(id) - // ... with the retrieval URI as fallback - : (filePath !== null ? pathToFileURL(resolve(filePath)) : null); + : (filePath === null + ? null + : pathToFileURL(resolve(filePath))); if (base === null) { return null; // no base known - skip the same-file rule } From a7ec117ccae83f6dfd32b3afd0b031341e54320c Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Mon, 31 Aug 2026 15:45:09 +0200 Subject: [PATCH 11/11] linter: add check ref-best-practice Signed-off-by: Jan Kowalleck --- tools/src/main/js/linter/checks/ref-best-practice.check.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/src/main/js/linter/checks/ref-best-practice.check.js b/tools/src/main/js/linter/checks/ref-best-practice.check.js index a6792e39e..f040e89fb 100644 --- a/tools/src/main/js/linter/checks/ref-best-practice.check.js +++ b/tools/src/main/js/linter/checks/ref-best-practice.check.js @@ -56,12 +56,14 @@ function makeSameFileTest(schema, filePath) { ? null : pathToFileURL(resolve(filePath))); if (base === null) { - return null; // no base known - skip the same-file rule + // no base known - skip the same-file rule + return null; } return filePart => { try { return new URL(filePart, base).href === base.href; - } catch { + } catch (err) { + // maybe throw the error?? return false; } };