Skip to content
10 changes: 10 additions & 0 deletions changelog.d/20250918_172922_yarikoptic_enh_schema_uri.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!--
A new scriv changelog fragment.

Uncomment the section that is right (remove the HTML comment wrapper).
For top level release notes, leave all the headers commented out.
-->

### Added

- Schema source reporting in validation output. The validator now includes `schemaSource` field in JSON output and displays schema information (version and source) in console output, helping users understand which schema was used for validation (URL, file path, or version tag).
76 changes: 44 additions & 32 deletions src/setup/loadSchema.test.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,48 @@
import { assert, assertObjectMatch } from '@std/assert'
import { loadSchema } from './loadSchema.ts'
import { assertEquals, assert } from '@std/assert'
import { loadSchemaWithSource, loadSchema } from './loadSchema.ts'

Deno.test('schema loader', async (t) => {
await t.step('reads in top level files document', async () => {
const schemaDefs = await loadSchema()
// Look for some stable fields in top level files
if (
typeof schemaDefs.rules.files.common === 'object' &&
schemaDefs.rules.files.common.core !== null
) {
const top_level = schemaDefs.rules.files.common.core as Record<
string,
any
>
if (top_level.hasOwnProperty('README')) {
assertObjectMatch(top_level.README, {
level: 'recommended',
stem: 'README',
extensions: ['', '.md', '.rst', '.txt'],
})
}
} else {
assert(false, 'failed to test schema defs')
}
Deno.test('loadSchemaWithSource function', async (t) => {
await t.step('loadSchema returns just Schema for backward compatibility', async () => {
const schema = await loadSchema()
assert(schema.schema_version)
assert(!('source' in schema))
})
await t.step('loads all schema files', async () => {
const schemaDefs = await loadSchema()
if (
!(typeof schemaDefs.objects === 'object') ||
!(typeof schemaDefs.rules === 'object')
) {
assert(false, 'failed to load objects/rules')

await t.step('loadSchemaWithSource returns SchemaWithSource', async () => {
const result = await loadSchemaWithSource()
assert(result.schema)
assert(result.schema.schema_version)
// When no custom schema is provided, source should be undefined
assertEquals(result.source, undefined)
})

await t.step('loadSchemaWithSource tracks source when custom URL provided', async () => {
// This test validates the structure even though network fetch will fail
const customUrl = 'https://example.com/custom-schema.json'
const result = await loadSchemaWithSource(customUrl)
assert(result.schema)
assert(result.schema.schema_version)
// Since network fetch fails, it falls back to default schema and source is undefined
assertEquals(result.source, undefined)
})

await t.step('loadSchemaWithSource handles environment variable', async () => {
const originalEnv = Deno.env.get('BIDS_SCHEMA')
try {
const customUrl = 'https://env-schema.example.com/schema.json'
Deno.env.set('BIDS_SCHEMA', customUrl)

const result = await loadSchemaWithSource()
assert(result.schema)
assert(result.schema.schema_version)
// Since network fetch fails, source should be undefined
assertEquals(result.source, undefined)
} finally {
if (originalEnv !== undefined) {
Deno.env.set('BIDS_SCHEMA', originalEnv)
} else {
Deno.env.delete('BIDS_SCHEMA')
}
}
})
})
})
23 changes: 20 additions & 3 deletions src/setup/loadSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ import { objectPathHandler } from '../utils/objectPathHandler.ts'
import { schema as schemaDefault } from '@bids/schema'
import { setCustomMetadataFormats } from '../validators/json.ts'

export interface SchemaWithSource {
schema: Schema
source?: string
}

/**
* Load the schema from the specification
* Load the schema from the specification with source tracking
*
*/
export async function loadSchema(version?: string): Promise<Schema> {
export async function loadSchemaWithSource(version?: string): Promise<SchemaWithSource> {
let schemaUrl = version
const bidsSchema = typeof Deno !== 'undefined' ? Deno.env.get('BIDS_SCHEMA') : undefined
if (bidsSchema !== undefined) {
Expand All @@ -20,6 +25,7 @@ export async function loadSchema(version?: string): Promise<Schema> {
schemaDefault as object,
objectPathHandler,
) as Schema
let actualSchemaSource: string | undefined

if (schemaUrl !== undefined) {
try {
Expand All @@ -29,6 +35,7 @@ export async function loadSchema(version?: string): Promise<Schema> {
jsonData as object,
objectPathHandler,
) as Schema
actualSchemaSource = schemaUrl
} catch (error) {
// If a custom schema URL was explicitly provided, fail rather than falling back
console.error(error)
Expand All @@ -38,5 +45,15 @@ export async function loadSchema(version?: string): Promise<Schema> {
}
}
setCustomMetadataFormats(schema)
return schema
return { schema, source: actualSchemaSource }
}

/**
* Load the schema from the specification
*
* version is ignored when the network cannot be accessed
*/
export async function loadSchema(version?: string): Promise<Schema> {
const result = await loadSchemaWithSource(version)
return result.schema
}
7 changes: 6 additions & 1 deletion src/summary/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export class Summary {
secondaryModalitiesCount: Record<string, number>
dataTypes: Set<string>
schemaVersion: string
schemaSource?: string
constructor() {
this.dataProcessed = false
this.totalFiles = 0
Expand Down Expand Up @@ -153,7 +154,7 @@ export class Summary {
}

formatOutput(): SummaryOutput {
return {
const output: SummaryOutput = {
sessions: Array.from(this.sessions),
subjects: Array.from(this.subjects),
subjectMetadata: this.subjectMetadata,
Expand All @@ -167,5 +168,9 @@ export class Summary {
dataTypes: Array.from(this.dataTypes),
schemaVersion: this.schemaVersion,
}
if (this.schemaSource) {
output.schemaSource = this.schemaSource
}
return output
}
}
1 change: 1 addition & 0 deletions src/types/validation-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface SummaryOutput {
pet: Record<string, any>
dataTypes: string[]
schemaVersion: string
schemaSource?: string
}

/**
Expand Down
9 changes: 9 additions & 0 deletions src/utils/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,15 @@ function formatSummary(summary: SummaryOutput): string {

output.push('')

// Add schema information
output.push(pad + colors.magenta('Schema Information:'))
output.push(pad + 'Version: ' + summary.schemaVersion)
if (summary.schemaSource) {
output.push(pad + 'Source: ' + summary.schemaSource)
}

output.push('')

//Neurostars message
output.push(
colors.cyan(
Expand Down
60 changes: 60 additions & 0 deletions src/validators/bids.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,64 @@ Deno.test('Smoke tests of main validation function', async (t) => {
assert(errors.get({ location: '/dataset_description.json' }).length === 0)
assert(warnings.get({ location: '/dataset_description.json' }).length === 0)
})
await t.step('Schema source is reported in validation output', async () => {
// Test with default schema (no source should be provided)
let result = await validate(dataset, {
datasetPath: '/dataset',
debug: 'INFO',
ignoreNiftiHeaders: true,
blacklistModalities: [],
datasetTypes: [],
})
assert(result.summary.schemaVersion)
assert(result.summary.schemaSource === undefined)

// Test with custom schema URL
result = await validate(dataset, {
datasetPath: '/dataset',
debug: 'INFO',
ignoreNiftiHeaders: true,
blacklistModalities: [],
datasetTypes: [],
schema: 'https://example.com/schema.json',
})
assert(result.summary.schemaVersion)
// Since the URL won't be reachable, it should fall back to default and not set source
assert(result.summary.schemaSource === undefined)

// Test with version tag
result = await validate(dataset, {
datasetPath: '/dataset',
debug: 'INFO',
ignoreNiftiHeaders: true,
blacklistModalities: [],
datasetTypes: [],
schema: 'v1.9.0',
})
assert(result.summary.schemaVersion)
// Since network fetch will likely fail, it should fall back to default
assert(result.summary.schemaSource === undefined)

// Test with BIDS_SCHEMA environment variable
const originalEnv = Deno.env.get('BIDS_SCHEMA')
try {
Deno.env.set('BIDS_SCHEMA', 'https://custom-schema.example.com/schema.json')
result = await validate(dataset, {
datasetPath: '/dataset',
debug: 'INFO',
ignoreNiftiHeaders: true,
blacklistModalities: [],
datasetTypes: [],
})
assert(result.summary.schemaVersion)
// Environment variable should override, but since network will fail, source won't be set
assert(result.summary.schemaSource === undefined)
} finally {
if (originalEnv !== undefined) {
Deno.env.set('BIDS_SCHEMA', originalEnv)
} else {
Deno.env.delete('BIDS_SCHEMA')
}
}
})
})
6 changes: 4 additions & 2 deletions src/validators/bids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { GenericSchema } from '../types/schema.ts'
import type { ValidationResult } from '../types/validation-result.ts'
import { applyRules } from '../schema/applyRules.ts'
import { walkFileTree } from '../schema/walk.ts'
import { loadSchema } from '../setup/loadSchema.ts'
import { loadSchemaWithSource } from '../setup/loadSchema.ts'
import type { Config, ValidatorOptions } from '../setup/options.ts'
import { Summary } from '../summary/summary.ts'
import { filenameIdentify } from './filenameIdentify.ts'
Expand Down Expand Up @@ -46,8 +46,10 @@ export async function validate(
config?: Config,
): Promise<ValidationResult> {
const summary = new Summary()
const schema = await loadSchema(options.schema)
const schemaResult = await loadSchemaWithSource(options.schema)
const schema = schemaResult.schema
summary.schemaVersion = schema.schema_version
summary.schemaSource = schemaResult.source

/* There should be a dataset_description in root, this will tell us if we
* are dealing with a derivative dataset
Expand Down
Loading