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).
99 changes: 69 additions & 30 deletions src/setup/loadSchema.test.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,75 @@
import { assert, assertObjectMatch } from '@std/assert'
import { loadSchema } from './loadSchema.ts'
import { assertEquals, assert, assertRejects } 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'],
})
}
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('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 throws error when custom URL fails without network', async () => {
// Check if network permission is granted
const netPermission = await Deno.permissions.query({ name: 'net' })

if (netPermission.state !== 'granted') {
// Without network permission, it should throw an error
const customUrl = 'https://example.com/custom-schema.json'
await assertRejects(
async () => await loadSchemaWithSource(customUrl),
Error,
'Failed to load schema from https://example.com/custom-schema.json'
)
} else {
assert(false, 'failed to test schema defs')
// With network permission, test might behave differently
// Skip this specific test when network is available
console.log('Skipping test - network permission granted')
}
})
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 with environment variable throws without network', async () => {
// Check if network permission is granted
const netPermission = await Deno.permissions.query({ name: 'net' })

const originalEnv = Deno.env.get('BIDS_SCHEMA')
try {
const customUrl = 'https://env-schema.example.com/schema.json'
Deno.env.set('BIDS_SCHEMA', customUrl)

if (netPermission.state !== 'granted') {
// Without network permission, it should throw
await assertRejects(
async () => await loadSchemaWithSource(),
Error,
'Failed to load schema from https://env-schema.example.com/schema.json'
)
} else {
// With network, might still fail but for different reasons (404, etc)
try {
const result = await loadSchemaWithSource()
// If it succeeds, check the result
assert(result.schema)
assert(result.schema.schema_version)
} catch (error) {
// Expected to fail with unreachable URL
assert(error instanceof Error)
assert(error.message.includes('Failed to load schema'))
}
}
} finally {
if (originalEnv !== undefined) {
Deno.env.set('BIDS_SCHEMA', originalEnv)
} else {
Deno.env.delete('BIDS_SCHEMA')
}
}
})
})
})
21 changes: 18 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,13 @@ export async function loadSchema(version?: string): Promise<Schema> {
}
}
setCustomMetadataFormats(schema)
return schema
return { schema, source: actualSchemaSource }
}

/**
* Load the schema from the specification
*/
export async function loadSchema(version?: string): Promise<Schema> {
const result = await loadSchemaWithSource(version)
return result.schema
}
5 changes: 4 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 @@ -166,6 +167,8 @@ export class Summary {
pet: this.pet,
dataTypes: Array.from(this.dataTypes),
schemaVersion: this.schemaVersion,
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
136 changes: 135 additions & 1 deletion src/validators/bids.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { assert } from '@std/assert'
import { assert, assertRejects } from '@std/assert'
import { pathsToTree } from '../files/filetree.ts'
import { validate } from './bids.ts'

Expand Down Expand Up @@ -66,4 +66,138 @@ 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 () => {
// Check network permission status
const netPermission = await Deno.permissions.query({ name: 'net' })

// 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 - should throw error without network
if (netPermission.state !== 'granted') {
await assertRejects(
async () => await validate(dataset, {
datasetPath: '/dataset',
debug: 'INFO',
ignoreNiftiHeaders: true,
blacklistModalities: [],
datasetTypes: [],
schema: 'https://example.com/schema.json',
}),
Error,
'Failed to load schema'
)
} else {
// With network, might fail with 404 or succeed with source set
try {
result = await validate(dataset, {
datasetPath: '/dataset',
debug: 'INFO',
ignoreNiftiHeaders: true,
blacklistModalities: [],
datasetTypes: [],
schema: 'https://example.com/schema.json',
})
// If it works, source should be set
assert(result.summary.schemaVersion)
assert(result.summary.schemaSource === 'https://example.com/schema.json')
} catch (error) {
// Expected to fail with unreachable URL
assert(error instanceof Error)
// The error message should mention the schema loading failure
assert(error.message.includes('Failed to load schema') || error.message.includes('fetch'))
}
}

// Test with version tag
if (netPermission.state !== 'granted') {
await assertRejects(
async () => await validate(dataset, {
datasetPath: '/dataset',
debug: 'INFO',
ignoreNiftiHeaders: true,
blacklistModalities: [],
datasetTypes: [],
schema: 'v1.9.0',
}),
Error,
'Failed to load schema'
)
} else {
// With network, might succeed
try {
result = await validate(dataset, {
datasetPath: '/dataset',
debug: 'INFO',
ignoreNiftiHeaders: true,
blacklistModalities: [],
datasetTypes: [],
schema: 'v1.9.0',
})
assert(result.summary.schemaVersion)
// If successful, source should be the constructed URL
if (result.summary.schemaSource) {
assert(result.summary.schemaSource.includes('v1.9.0'))
}
} catch (error) {
// Could fail if version doesn't exist
assert(error instanceof Error)
// In CI with network, might have different error messages
console.log('Schema version load error:', error.message)
}
}

// 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')

if (netPermission.state !== 'granted') {
await assertRejects(
async () => await validate(dataset, {
datasetPath: '/dataset',
debug: 'INFO',
ignoreNiftiHeaders: true,
blacklistModalities: [],
datasetTypes: [],
}),
Error,
'Failed to load schema'
)
} else {
// With network, might fail with 404
try {
result = await validate(dataset, {
datasetPath: '/dataset',
debug: 'INFO',
ignoreNiftiHeaders: true,
blacklistModalities: [],
datasetTypes: [],
})
assert(result.summary.schemaVersion)
if (result.summary.schemaSource) {
assert(result.summary.schemaSource === 'https://custom-schema.example.com/schema.json')
}
} catch (error) {
assert(error instanceof Error)
// The error message should indicate a schema loading issue
console.log('Schema env var load error:', error.message)
}
}
} 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