diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index a84ddbd64..5a5d565ed 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -21,4 +21,4 @@ jobs: - uses: codespell-project/actions-codespell@master with: ignore_words_list: ro,anser,te,tage,afterall,nwe,nin,nd,falsy - skip: package-lock.json + skip: package-lock.json, deps*, deno.lock diff --git a/bids-validator/bids-validator-deno b/bids-validator/bids-validator-deno index d2ba42d53..644fc7564 100755 --- a/bids-validator/bids-validator-deno +++ b/bids-validator/bids-validator-deno @@ -1,3 +1,3 @@ -#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --allow-net --allow-run +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --allow-net --allow-run import './src/bids-validator.ts' diff --git a/bids-validator/src/deps/hed-validator.ts b/bids-validator/src/deps/hed-validator.ts new file mode 100644 index 000000000..9726c10d5 --- /dev/null +++ b/bids-validator/src/deps/hed-validator.ts @@ -0,0 +1 @@ +export { default } from "https://esm.sh/hed-validator@3.15.3?deps=node@18" diff --git a/bids-validator/src/issues/list.ts b/bids-validator/src/issues/list.ts index 87f1239e3..2d603a286 100644 --- a/bids-validator/src/issues/list.ts +++ b/bids-validator/src/issues/list.ts @@ -120,4 +120,42 @@ export const filenameIssues: IssueDefinitionRecord = { }, } -export const nonSchemaIssues = { ...filenameIssues } +const hedIssues: IssueDefinitionRecord = { + HED_ERROR: { + severity: 'error', + reason: 'The validation on this HED string returned an error.', + }, + HED_WARNING: { + severity: 'warning', + reason: 'The validation on this HED string returned a warning.', + }, + HED_INTERNAL_ERROR: { + severity: 'error', + reason: 'An internal error occurred during HED validation.', + }, + HED_INTERNAL_WARNING: { + severity: 'warning', + reason: 'An internal warning occurred during HED validation.', + }, + HED_MISSING_VALUE_IN_SIDECAR: { + severity: 'warning', + reason: + 'The json sidecar does not contain this column value as a possible key to a HED string.', + }, + HED_VERSION_NOT_DEFINED: { + severity: 'warning', + reason: + "You should define 'HEDVersion' for this file. If you don't provide this information, the HED validation will use the latest version available.", + }, +} + +export const hedOldToNewLookup: Record> = { + 104: 'HED_ERROR', + 105: 'HED_WARNING', + 106: 'HED_INTERNAL_ERROR', + 107: 'HED_INTERNAL_WARNING', + 108: 'HED_MISSING_VALUE_IN_SIDECAR', + 109: 'HED_VERSION_NOT_DEFINED' +} + +export const nonSchemaIssues = { ...filenameIssues, ...hedIssues } diff --git a/bids-validator/src/tests/local/hed-integration.test.ts b/bids-validator/src/tests/local/hed-integration.test.ts new file mode 100644 index 000000000..07a51b903 --- /dev/null +++ b/bids-validator/src/tests/local/hed-integration.test.ts @@ -0,0 +1,62 @@ +import { formatAssertIssue, validatePath } from './common.ts' +import { assert, assertEquals } from '../../deps/asserts.ts' +import { BIDSFileDeno, readFileTree } from '../../files/deno.ts' +import { DatasetIssues } from '../../issues/datasetIssues.ts' +import { loadSchema } from '../../setup/loadSchema.ts' +import { BIDSContext, BIDSContextDataset } from '../../schema/context.ts' +import { BIDSFile, FileTree } from '../../types/filetree.ts' +import { GenericSchema } from '../../types/schema.ts' +import { hedValidate } from '../../validators/hed.ts' + +function getFile(fileTree: FileTree, path: string) { + let [current, ...nextPath] = path.split('/') + if (nextPath.length === 0) { + const target = fileTree.files.find(x => x.name === current) + if (target) { + return target + } + const dirTarget = fileTree.directories.find(x => x.name === nextPath[0]) + return dirTarget + } else { + const nextTree = fileTree.directories.find(x => x.name === current) + if (nextTree) { + return getFile(nextTree, nextPath.join('/')) + } + } + return undefined +} + +Deno.test('hed-validator not triggered', async (t) => { + const PATH = 'tests/data/bids-examples/ds003' + const tree = await readFileTree(PATH) + const schema = await loadSchema() + const issues = new DatasetIssues() + const dsContext = new BIDSContextDataset(undefined, {'HEDVersion': ['bad_version']}) + await t.step('detect hed returns false', async () => { + const eventFile = getFile(tree, 'sub-01/func/sub-01_task-rhymejudgment_events.tsv') + assert(eventFile !== undefined) + assert(eventFile instanceof BIDSFileDeno) + const context = new BIDSContext(tree, eventFile, issues, dsContext) + await context.asyncLoads() + await hedValidate(schema as unknown as GenericSchema, context) + assert(issues.size === 0) + }) +}) + +Deno.test('hed-validator fails with bad schema version', async (t) => { + const PATH = 'tests/data/bids-examples/eeg_ds003645s_hed_library' + const tree = await readFileTree(PATH) + const schema = await loadSchema() + const issues = new DatasetIssues() + const dsContext = new BIDSContextDataset(undefined, {'HEDVersion': ['bad_version']}) + await t.step('detect hed returns false', async () => { + const eventFile = getFile(tree, 'sub-002/eeg/sub-002_task-FacePerception_run-3_events.tsv') + assert(eventFile !== undefined) + assert(eventFile instanceof BIDSFileDeno) + const context = new BIDSContext(tree, eventFile, issues, dsContext) + await context.asyncLoads() + await hedValidate(schema as unknown as GenericSchema, context) + assert(issues.size === 1) + assert(issues.has('HED_ERROR')) + }) +}) diff --git a/bids-validator/src/types/check.ts b/bids-validator/src/types/check.ts index 704a5b0c9..081d7e966 100644 --- a/bids-validator/src/types/check.ts +++ b/bids-validator/src/types/check.ts @@ -1,8 +1,9 @@ import { GenericSchema } from './schema.ts' -import { BIDSContext } from '../schema/context.ts' +import { BIDSContext, BIDSContextDataset } from '../schema/context.ts' +import { DatasetIssues } from '../issues/datasetIssues.ts' /** Function interface for writing a check */ -export type CheckFunction = ( +export type ContextCheckFunction = ( schema: GenericSchema, context: BIDSContext, ) => Promise @@ -13,3 +14,9 @@ export type RuleCheckFunction = ( schema: GenericSchema, context: BIDSContext, ) => void + +export type DSCheckFunction = ( + schema: GenericSchema, + dsContext: BIDSContextDataset, + issues: DatasetIssues, +) => Promise diff --git a/bids-validator/src/types/context.ts b/bids-validator/src/types/context.ts index f13d34041..24c014b11 100644 --- a/bids-validator/src/types/context.ts +++ b/bids-validator/src/types/context.ts @@ -5,6 +5,7 @@ export interface ContextDatasetSubjects { participant_id?: string[] phenotype?: string[] } + export interface ContextDataset { dataset_description: Record files: any[] diff --git a/bids-validator/src/validators/bids.ts b/bids-validator/src/validators/bids.ts index 7518eeecd..96655365c 100644 --- a/bids-validator/src/validators/bids.ts +++ b/bids-validator/src/validators/bids.ts @@ -1,4 +1,4 @@ -import { CheckFunction } from '../types/check.ts' +import { DSCheckFunction, ContextCheckFunction } from '../types/check.ts' import { BIDSFile, FileTree } from '../types/filetree.ts' import { IssueFile } from '../types/issues.ts' import { GenericSchema } from '../types/schema.ts' @@ -14,17 +14,21 @@ import { DatasetIssues } from '../issues/datasetIssues.ts' import { emptyFile } from './internal/emptyFile.ts' import { BIDSContext, BIDSContextDataset } from '../schema/context.ts' import { parseOptions } from '../setup/options.ts' +import { hedValidate } from './hed.ts' /** * Ordering of checks to apply */ -const CHECKS: CheckFunction[] = [ +const perContextChecks: ContextCheckFunction[] = [ emptyFile, filenameIdentify, filenameValidate, applyRules, + hedValidate, ] +const perDSChecks: DSCheckFunction[] = [] + /** * Full BIDS schema validation entrypoint */ @@ -84,11 +88,14 @@ export async function validate( } await context.asyncLoads() // Run majority of checks - for (const check of CHECKS) { + for (const check of perContextChecks) { await check(schema as unknown as GenericSchema, context) } await summary.update(context) } + for (const check of perDSChecks) { + await check(schema as unknown as GenericSchema, dsContext, issues) + } let derivativesSummary: Record = {} await Promise.allSettled( diff --git a/bids-validator/src/validators/filenameValidate.ts b/bids-validator/src/validators/filenameValidate.ts index 6e5b2e964..23e1a4076 100644 --- a/bids-validator/src/validators/filenameValidate.ts +++ b/bids-validator/src/validators/filenameValidate.ts @@ -1,4 +1,4 @@ -import { CheckFunction, RuleCheckFunction } from '../types/check.ts' +import { ContextCheckFunction, RuleCheckFunction } from '../types/check.ts' import { DatasetIssues } from '../issues/datasetIssues.ts' import { BIDSContext } from '../schema/context.ts' import { Entity, Format, GenericSchema, Schema } from '../types/schema.ts' @@ -7,7 +7,7 @@ import { hasProp } from '../utils/objectPathHandler.ts' const sidecarExtensions = ['.json', '.tsv', '.bvec', '.bval'] -const CHECKS: CheckFunction[] = [ +const CHECKS: ContextCheckFunction[] = [ missingLabel, atRoot, entityLabelCheck, diff --git a/bids-validator/src/validators/hed.ts b/bids-validator/src/validators/hed.ts new file mode 100644 index 000000000..e241c20d8 --- /dev/null +++ b/bids-validator/src/validators/hed.ts @@ -0,0 +1,110 @@ +import hedValidator from '../deps/hed-validator.ts' +import { hedOldToNewLookup } from '../issues/list.ts' +import { GenericSchema } from '../types/schema.ts' +import { IssueFile, IssueFileOutput } from '../types/issues.ts' +import { BIDSContext, BIDSContextDataset } from '../schema/context.ts' +import { DatasetIssues } from '../issues/datasetIssues.ts' +import { ColumnsMap } from '../types/columns.ts' + +function sidecarHasHed(sidecarData: BIDSContext["sidecar"]) { + if (!sidecarData) { + return false + } + return Object.keys(sidecarData).some(x => sidecarValueHasHed(sidecarData[x])) +} + +function sidecarValueHasHed(sidecarValue: unknown) { + return ( + sidecarValue !== null && + typeof sidecarValue === 'object' && + 'HED' in sidecarValue && + sidecarValue.HED !== undefined + ) +} + +let hedSchemas: object | undefined | null = undefined + +async function setHedSchemas(datasetDescriptionJson = {}) { + const datasetDescriptionData = new hedValidator.bids.BidsJsonFile( + '/dataset_description.json', + datasetDescriptionJson, + null, + ) + try { + hedSchemas = await hedValidator.bids.buildBidsSchemas( + datasetDescriptionData, + null, + ) + return [] as HedIssue[] + } catch (issueError) { + hedSchemas = null + return hedValidator.bids.BidsHedIssue.fromHedIssues( + issueError, + datasetDescriptionData.file, + ) + } +} + +export interface HedIssue { + code: number + file: IssueFile + evidence: string +} + +export async function hedValidate( + schema: GenericSchema, + context: BIDSContext, +): Promise { + let file + let hedValidationIssues = [] as HedIssue[] + + try { + if (context.extension == '.tsv' && context.columns) { + if (!('HED' in context.columns) && !sidecarHasHed(context.sidecar)) { + return + } + hedValidationIssues = await setHedSchemas(context.dataset.dataset_description) + + file = await buildHedTsvFile(context) + } else if (context.extension == '.json' && sidecarHasHed(context.json)) { + hedValidationIssues = hedValidationIssues = await setHedSchemas(context.dataset.dataset_description) + file = buildHedSidecarFile(context) + } + + if (file !== undefined) { + hedValidationIssues.push(...file.validate(hedSchemas)) + } + } catch (error) { + context.issues.addNonSchemaIssue('HED_ERROR', [{ ...context.file, evidence: error}]) + } + + hedValidationIssues.map((hedIssue) => { + const code = hedIssue.code + if (code in hedOldToNewLookup) { + context.issues.addNonSchemaIssue( + hedOldToNewLookup[code], + [{ ...hedIssue.file, evidence: hedIssue.evidence }], + ) + } + }) +} + +function buildHedTsvFile(context: BIDSContext) { + const eventFile = new hedValidator.bids.BidsTsvFile( + context.path, + context.columns, + context.file, + [], + context.sidecar, + ) + return eventFile +} + +function buildHedSidecarFile(context: BIDSContext) { + const sidecarFile = new hedValidator.bids.BidsSidecar( + context.path, + context.json, + context.file, + ) + return sidecarFile +} diff --git a/bids-validator/src/validators/internal/emptyFile.ts b/bids-validator/src/validators/internal/emptyFile.ts index 878e148a1..25ef3bd32 100644 --- a/bids-validator/src/validators/internal/emptyFile.ts +++ b/bids-validator/src/validators/internal/emptyFile.ts @@ -1,7 +1,7 @@ -import { CheckFunction } from '../../types/check.ts' +import { ContextCheckFunction } from '../../types/check.ts' // Non-schema EMPTY_FILE implementation -export const emptyFile: CheckFunction = (schema, context) => { +export const emptyFile: ContextCheckFunction = (schema, context) => { if (context.file.size === 0) { context.issues.addNonSchemaIssue('EMPTY_FILE', [context.file]) } diff --git a/bids-validator/src/validators/isBidsy.ts b/bids-validator/src/validators/isBidsy.ts index bc55b70e4..a2a7ccaaa 100644 --- a/bids-validator/src/validators/isBidsy.ts +++ b/bids-validator/src/validators/isBidsy.ts @@ -4,11 +4,11 @@ */ // @ts-nocheck import { BIDSContext } from '../schema/context.ts' -import { CheckFunction } from '../../types/check.ts' +import { ContextCheckFunction } from '../../types/check.ts' import { BIDSFile } from '../types/filetree.ts' import { Schema } from '../types/schema.ts' -export const isBidsyFilename: CheckFunction = (schema, context) => { +export const isBidsyFilename: ContextCheckFunction = (schema, context) => { // every '.', '-', '_' followed by an alnum // only contains '.', '-', '_' and alnum }