diff --git a/README.md b/README.md index 1455c79..d09f835 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,8 @@ Validation uses a local snapshot of the stable OGraf Graphics v1 specification: - Local files: [`packages/validator-core/spec/ebu-ograf-v1-d42afced`](packages/validator-core/spec/ebu-ograf-v1-d42afced) The app never downloads schemas at runtime. Spec updates are reviewed and added -manually. `npm run spec:check` verifies the stored hashes and generated -standalone validator. +manually. `npm run spec:check` verifies that the documented EBU commit, +snapshot metadata, stored hashes, and generated standalone validator agree. ## Local development diff --git a/package-lock.json b/package-lock.json index f865fe6..1dadad7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4216,7 +4216,7 @@ }, "packages/validator-core": { "name": "@streamshapers/ograf-validator-core", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "devDependencies": { "@types/node": "^24.13.3", diff --git a/packages/validator-core/CHANGELOG.md b/packages/validator-core/CHANGELOG.md index 1e6cf38..104279a 100644 --- a/packages/validator-core/CHANGELOG.md +++ b/packages/validator-core/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to `@streamshapers/ograf-validator-core` are documented here. +## 0.2.1 - 2026-08-12 + +### Added + +- Machine-readable metadata for the vendored EBU OGraf specification snapshot. +- A release check that keeps the snapshot directory, upstream commit, documentation, checksums, generated validator, and packaged files in sync. +- Regression tests for malformed snapshot metadata, stale documentation, and unsafe or duplicate checksum paths. + +### Changed + +- Validator generation, snapshot tests, and the npm tarball smoke test now discover the active snapshot from its metadata instead of using a hardcoded directory. + ## 0.2.0 - 2026-08-10 ### Added diff --git a/packages/validator-core/README.md b/packages/validator-core/README.md index 9e9f41c..a3648dc 100644 --- a/packages/validator-core/README.md +++ b/packages/validator-core/README.md @@ -265,8 +265,9 @@ npm run generate:validator npm run spec:check ``` -`spec:check` verifies `SHA256SUMS` and fails when regenerated standalone code -differs from the checked-in artifact. +`spec:check` verifies the snapshot metadata, current documentation, +`SHA256SUMS`, and generated standalone code. It fails if any of them refers to +a different EBU commit. ## Compatibility diff --git a/packages/validator-core/package.json b/packages/validator-core/package.json index 36a0ebb..eb944ff 100644 --- a/packages/validator-core/package.json +++ b/packages/validator-core/package.json @@ -1,6 +1,6 @@ { "name": "@streamshapers/ograf-validator-core", - "version": "0.2.0", + "version": "0.2.1", "description": "Validate OGraf v1 Graphics Packages in Node.js and browsers with zero runtime dependencies", "keywords": ["ograf", "broadcast", "graphics", "validator", "ebu"], "repository": { diff --git a/packages/validator-core/scripts/check-spec.mjs b/packages/validator-core/scripts/check-spec.mjs index e8d791f..5fba66d 100644 --- a/packages/validator-core/scripts/check-spec.mjs +++ b/packages/validator-core/scripts/check-spec.mjs @@ -1,11 +1,18 @@ import { createHash } from 'node:crypto'; -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { dirname, relative, resolve } from 'node:path'; +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { posix, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { generateValidatorSource, generatedValidatorPath } from './generate-standalone-validator.mjs'; +import { + assertCurrentSnapshotReferences, + assertSnapshotReferences, + packageRoot, + snapshotMetadata, + snapshotRoot, + validateSnapshotMarkdown, +} from './spec-snapshot.mjs'; -const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const snapshotRoot = resolve(packageRoot, 'spec/ebu-ograf-v1-d42afced'); +const repositoryRoot = resolve(packageRoot, '../..'); const checksumPath = resolve(snapshotRoot, 'SHA256SUMS'); function normalizedRelativePath(path) { @@ -15,50 +22,121 @@ function normalizedRelativePath(path) { function collectFiles(directory) { return readdirSync(directory) .map((name) => resolve(directory, name)) - .flatMap((path) => statSync(path).isDirectory() ? collectFiles(path) : [path]); + .flatMap((path) => { + const details = lstatSync(path); + if (details.isSymbolicLink()) { + throw new Error(`Snapshot must not contain symbolic links: ${normalizedRelativePath(path)}`); + } + if (details.isDirectory()) return collectFiles(path); + if (!details.isFile()) { + throw new Error(`Snapshot contains a non-regular file: ${normalizedRelativePath(path)}`); + } + return [path]; + }); } function sha256(path) { return createHash('sha256').update(readFileSync(path)).digest('hex'); } -if (!existsSync(checksumPath)) { - throw new Error(`Missing snapshot checksum file: ${checksumPath}`); +function isCanonicalChecksumPath(path) { + if (path === '' || path === 'SHA256SUMS') return false; + if (path.includes('\\') || path.startsWith('/') || path.startsWith('./')) return false; + if (path.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')) { + return false; + } + return posix.normalize(path) === path; } -const expected = new Map( - readFileSync(checksumPath, 'utf8') - .trim() - .split(/\r?\n/u) - .filter(Boolean) - .map((line) => { - const match = /^([a-f0-9]{64}) {2}(.+)$/u.exec(line); - if (match === null) throw new Error(`Malformed SHA256SUMS line: ${line}`); - return [match[2], match[1]]; - }), -); - -const actualFiles = collectFiles(snapshotRoot) - .map(normalizedRelativePath) - .filter((path) => path !== 'SHA256SUMS') - .sort(); - -for (const path of actualFiles) { - const expectedHash = expected.get(path); - if (expectedHash === undefined) throw new Error(`Snapshot file is not pinned in SHA256SUMS: ${path}`); - const actualHash = sha256(resolve(snapshotRoot, path)); - if (actualHash !== expectedHash) throw new Error(`Snapshot checksum mismatch: ${path}`); - expected.delete(path); -} -if (expected.size > 0) { - throw new Error(`SHA256SUMS references missing files: ${[...expected.keys()].join(', ')}`); +export function parseChecksumManifest(content) { + const expected = new Map(); + for (const line of content.split(/\r?\n/u).filter(Boolean)) { + const match = /^([a-f0-9]{64}) {2}(.+)$/u.exec(line); + if (match === null) throw new Error(`Malformed SHA256SUMS line: ${line}`); + const path = match[2]; + const hash = match[1]; + if (path === undefined || hash === undefined || !isCanonicalChecksumPath(path)) { + throw new Error(`Unsafe or non-canonical SHA256SUMS path: ${String(path)}`); + } + if (expected.has(path)) throw new Error(`Duplicate SHA256SUMS path: ${path}`); + expected.set(path, hash); + } + if (expected.size === 0) throw new Error('SHA256SUMS must contain at least one file.'); + return expected; } -if (!existsSync(generatedValidatorPath)) { - throw new Error(`Missing generated validator: ${generatedValidatorPath}`); +function readUtf8(path) { + return readFileSync(path, 'utf8'); } -if (readFileSync(generatedValidatorPath, 'utf8') !== generateValidatorSource()) { - throw new Error('Generated validator drift detected. Run npm run generate:validator.'); + +export function runSpecCheck() { + if (!existsSync(checksumPath)) { + throw new Error(`Missing snapshot checksum file: ${checksumPath}`); + } + + const snapshotMarkdown = readUtf8(resolve(snapshotRoot, 'SNAPSHOT.md')); + validateSnapshotMarkdown(snapshotMetadata, snapshotMarkdown); + + assertCurrentSnapshotReferences(snapshotMetadata, [ + { + label: 'README.md', + content: readUtf8(resolve(repositoryRoot, 'README.md')), + tokens: [ + snapshotMetadata.commit, + snapshotMetadata.shortCommit, + `packages/validator-core/spec/${snapshotMetadata.directory}`, + snapshotMetadata.sourceDateDisplay, + ], + }, + { + label: 'packages/validator-core/README.md', + content: readUtf8(resolve(packageRoot, 'README.md')), + tokens: [ + snapshotMetadata.commit, + snapshotMetadata.shortCommit, + `spec/${snapshotMetadata.directory}`, + snapshotMetadata.sourceDateDisplay, + ], + }, + ]); + assertSnapshotReferences([{ + label: 'packages/validator-core/CHANGELOG.md', + content: readUtf8(resolve(packageRoot, 'CHANGELOG.md')), + tokens: [snapshotMetadata.shortCommit], + }]); + + const expected = parseChecksumManifest(readUtf8(checksumPath)); + const actualFiles = collectFiles(snapshotRoot) + .map(normalizedRelativePath) + .filter((path) => path !== 'SHA256SUMS') + .sort(); + + for (const path of actualFiles) { + const expectedHash = expected.get(path); + if (expectedHash === undefined) { + throw new Error(`Snapshot file is not pinned in SHA256SUMS: ${path}`); + } + const actualHash = sha256(resolve(snapshotRoot, path)); + if (actualHash !== expectedHash) throw new Error(`Snapshot checksum mismatch: ${path}`); + expected.delete(path); + } + if (expected.size > 0) { + throw new Error(`SHA256SUMS references missing files: ${[...expected.keys()].join(', ')}`); + } + + if (!existsSync(generatedValidatorPath)) { + throw new Error(`Missing generated validator: ${generatedValidatorPath}`); + } + if (readUtf8(generatedValidatorPath) !== generateValidatorSource()) { + throw new Error('Generated validator drift detected. Run npm run generate:validator.'); + } + + console.log( + `Pinned OGraf snapshot ${snapshotMetadata.shortCommit}, documentation, checksums, ` + + 'and generated validator are up to date.', + ); } -console.log('Pinned OGraf snapshot checksums and generated validator are up to date.'); +if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runSpecCheck(); +} diff --git a/packages/validator-core/scripts/generate-standalone-validator.mjs b/packages/validator-core/scripts/generate-standalone-validator.mjs index 8edd86d..626b7eb 100644 --- a/packages/validator-core/scripts/generate-standalone-validator.mjs +++ b/packages/validator-core/scripts/generate-standalone-validator.mjs @@ -3,9 +3,8 @@ import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; +import { packageRoot, snapshotMetadata, snapshotRoot } from './spec-snapshot.mjs'; -const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const snapshotRoot = resolve(packageRoot, 'spec/ebu-ograf-v1-d42afced'); export const generatedValidatorPath = resolve(packageRoot, 'src/generated/ograf-manifest-validator.ts'); const require = createRequire(import.meta.url); const dependencySearchPaths = process.env['OGRAF_AJV_NODE_MODULES'] === undefined @@ -69,7 +68,7 @@ export function generateValidatorSource() { '// @ts-nocheck', '/**', ' * Generated by scripts/generate-standalone-validator.mjs.', - ' * Source: EBU OGraf v1 snapshot d42afcedf9348e05e35b2009b04fb9552785e35b.', + ` * Source: EBU OGraf v1 snapshot ${snapshotMetadata.commit}.`, ' * Do not edit manually; run npm run generate:validator.', ' */', runtimeHelpers, diff --git a/packages/validator-core/scripts/spec-snapshot.mjs b/packages/validator-core/scripts/spec-snapshot.mjs new file mode 100644 index 0000000..37e087d --- /dev/null +++ b/packages/validator-core/scripts/spec-snapshot.mjs @@ -0,0 +1,188 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const specRoot = resolve(packageRoot, 'spec'); + +const expectedMetadataKeys = [ + 'commit', + 'formatVersion', + 'sourceDate', + 'specification', + 'upstreamRepository', +]; +const upstreamRepository = 'https://github.com/ebu/ograf'; + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isRealIsoDate(value) { + if (!/^\d{4}-\d{2}-\d{2}$/u.test(value)) return false; + const parsed = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value; +} + +export function formatSnapshotDate(sourceDate) { + if (!isRealIsoDate(sourceDate)) { + throw new Error(`Invalid snapshot source date: ${String(sourceDate)}`); + } + const [year, month, day] = sourceDate.split('-').map(Number); + const monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + return `${String(day)} ${monthNames[month - 1]} ${String(year)}`; +} + +export function validateSnapshotMetadata(value, directory) { + if (!isRecord(value)) throw new Error('SNAPSHOT.json must contain a JSON object.'); + + const actualKeys = Object.keys(value).sort(); + if (actualKeys.join('\n') !== expectedMetadataKeys.join('\n')) { + throw new Error( + `SNAPSHOT.json must contain exactly these keys: ${expectedMetadataKeys.join(', ')}.`, + ); + } + if (value['formatVersion'] !== 1) { + throw new Error('SNAPSHOT.json formatVersion must be 1.'); + } + if (value['specification'] !== 'OGraf Graphics v1') { + throw new Error('SNAPSHOT.json specification must be "OGraf Graphics v1".'); + } + if (value['upstreamRepository'] !== upstreamRepository) { + throw new Error(`SNAPSHOT.json upstreamRepository must be ${upstreamRepository}.`); + } + + const commit = value['commit']; + if (typeof commit !== 'string' || !/^[a-f0-9]{40}$/u.test(commit)) { + throw new Error('SNAPSHOT.json commit must be a lowercase 40-character Git SHA.'); + } + const sourceDate = value['sourceDate']; + if (typeof sourceDate !== 'string' || !isRealIsoDate(sourceDate)) { + throw new Error('SNAPSHOT.json sourceDate must be a real date in YYYY-MM-DD format.'); + } + + const shortCommit = commit.slice(0, 8); + const expectedDirectory = `ebu-ograf-v1-${shortCommit}`; + if (directory !== expectedDirectory) { + throw new Error( + `Snapshot directory must be ${expectedDirectory}, received ${directory}.`, + ); + } + + return Object.freeze({ + commit, + directory, + formatVersion: 1, + shortCommit, + sourceDate, + sourceDateDisplay: formatSnapshotDate(sourceDate), + specification: 'OGraf Graphics v1', + upstreamRepository, + }); +} + +export function loadSnapshotMetadata(root = specRoot) { + if (!existsSync(root)) throw new Error(`Missing specification directory: ${root}`); + const directories = readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith('ebu-ograf-v1-')) + .map((entry) => entry.name); + if (directories.length !== 1) { + throw new Error( + `Expected exactly one EBU OGraf v1 snapshot directory, found ${String(directories.length)}.`, + ); + } + + const directory = directories[0]; + if (directory === undefined) throw new Error('Snapshot directory lookup failed.'); + const metadataPath = resolve(root, directory, 'SNAPSHOT.json'); + if (!existsSync(metadataPath)) throw new Error(`Missing snapshot metadata: ${metadataPath}`); + + let value; + try { + value = JSON.parse(readFileSync(metadataPath, 'utf8')); + } catch (error) { + throw new Error(`Invalid SNAPSHOT.json: ${String(error)}`, { cause: error }); + } + return validateSnapshotMetadata(value, directory); +} + +export function validateSnapshotMarkdown(metadata, markdown) { + const exactLines = [ + ['- Commit:', `- Commit: \`${metadata.commit}\``], + [ + '- Commit URL:', + `- Commit URL: ${metadata.upstreamRepository}/tree/${metadata.commit}`, + ], + ['- Source date:', `- Source date: ${metadata.sourceDate}`], + ]; + const lines = markdown.split(/\r?\n/u); + for (const [prefix, expected] of exactLines) { + const matches = lines.filter((line) => line.startsWith(prefix)); + if (matches.length !== 1 || matches[0] !== expected) { + throw new Error(`SNAPSHOT.md must contain exactly this line: ${expected}`); + } + } + + const vendoredPaths = '- Vendored paths: `v1/specification/docs/Specification.md`, ' + + '`v1/specification/json-schemas/**`, and the four upstream ' + + '`v1/examples/*.ograf.json` manifests'; + if (!markdown.includes(vendoredPaths)) { + throw new Error(`SNAPSHOT.md is missing or has a stale line: ${vendoredPaths}`); + } +} + +export function assertSnapshotReferences(references) { + for (const reference of references) { + for (const token of reference.tokens) { + if (!reference.content.includes(token)) { + throw new Error( + `${reference.label} does not reference the active OGraf snapshot token: ${token}`, + ); + } + } + } +} + +export function assertCurrentSnapshotReferences(metadata, references) { + assertSnapshotReferences(references); + const patterns = [ + { + label: 'EBU commit URL', + regex: /https:\/\/github\.com\/ebu\/ograf\/(?:commit|tree)\/([a-f0-9]{7,40})/giu, + expected: metadata.commit, + }, + { + label: 'snapshot directory', + regex: /ebu-ograf-v1-([a-f0-9]{7,40})/giu, + expected: metadata.shortCommit, + }, + ]; + + for (const reference of references) { + for (const pattern of patterns) { + for (const match of reference.content.matchAll(pattern.regex)) { + if (match[1] !== pattern.expected) { + throw new Error( + `${reference.label} contains a stale ${pattern.label}: ${String(match[1])}`, + ); + } + } + } + } +} + +export const snapshotMetadata = loadSnapshotMetadata(); +export const snapshotRoot = resolve(specRoot, snapshotMetadata.directory); diff --git a/packages/validator-core/scripts/spec-snapshot.test.mjs b/packages/validator-core/scripts/spec-snapshot.test.mjs new file mode 100644 index 0000000..e9205ff --- /dev/null +++ b/packages/validator-core/scripts/spec-snapshot.test.mjs @@ -0,0 +1,147 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it } from 'vitest'; +import { parseChecksumManifest } from './check-spec.mjs'; +import { + assertCurrentSnapshotReferences, + assertSnapshotReferences, + formatSnapshotDate, + loadSnapshotMetadata, + snapshotMetadata, + validateSnapshotMarkdown, + validateSnapshotMetadata, +} from './spec-snapshot.mjs'; + +const validValue = { + formatVersion: 1, + specification: 'OGraf Graphics v1', + upstreamRepository: 'https://github.com/ebu/ograf', + commit: '0123456789abcdef0123456789abcdef01234567', + sourceDate: '2026-08-07', +}; +const temporaryRoots = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function createSpecRoot(directories) { + const root = mkdtempSync(resolve(tmpdir(), 'ograf-spec-snapshot-')); + temporaryRoots.push(root); + for (const directory of directories) { + const snapshotRoot = resolve(root, directory); + mkdirSync(snapshotRoot); + writeFileSync( + resolve(snapshotRoot, 'SNAPSHOT.json'), + `${JSON.stringify(validValue, null, 2)}\n`, + 'utf8', + ); + } + return root; +} + +describe('OGraf snapshot metadata', () => { + it('loads the current repository snapshot', () => { + expect(snapshotMetadata.commit).toMatch(/^[a-f0-9]{40}$/u); + expect(snapshotMetadata.directory).toBe( + `ebu-ograf-v1-${snapshotMetadata.commit.slice(0, 8)}`, + ); + expect(snapshotMetadata.sourceDateDisplay).toMatch(/^\d{1,2} [A-Z][a-z]+ \d{4}$/u); + }); + + it('validates and derives the active snapshot identity', () => { + expect(validateSnapshotMetadata(validValue, 'ebu-ograf-v1-01234567')).toMatchObject({ + commit: validValue.commit, + directory: 'ebu-ograf-v1-01234567', + shortCommit: '01234567', + sourceDateDisplay: '7 August 2026', + }); + expect(formatSnapshotDate('2024-02-29')).toBe('29 February 2024'); + }); + + it('rejects malformed commits, impossible dates, extra keys, and stale directory names', () => { + expect(() => validateSnapshotMetadata( + { ...validValue, commit: '01234567' }, + 'ebu-ograf-v1-01234567', + )).toThrow(/40-character Git SHA/u); + expect(() => validateSnapshotMetadata( + { ...validValue, sourceDate: '2026-02-31' }, + 'ebu-ograf-v1-01234567', + )).toThrow(/real date/u); + expect(() => validateSnapshotMetadata( + { ...validValue, extra: true }, + 'ebu-ograf-v1-01234567', + )).toThrow(/exactly these keys/u); + expect(() => validateSnapshotMetadata( + validValue, + 'ebu-ograf-v1-deadbeef', + )).toThrow(/must be ebu-ograf-v1-01234567/u); + }); + + it('requires exactly one snapshot directory', () => { + expect(() => loadSnapshotMetadata(createSpecRoot([]))).toThrow(/exactly one/u); + expect(() => loadSnapshotMetadata(createSpecRoot([ + 'ebu-ograf-v1-01234567', + 'ebu-ograf-v1-deadbeef', + ]))).toThrow(/exactly one/u); + }); + + it('rejects stale human-readable metadata and documentation references', () => { + const metadata = validateSnapshotMetadata(validValue, 'ebu-ograf-v1-01234567'); + const validMarkdown = [ + `- Commit: \`${validValue.commit}\``, + `- Commit URL: https://github.com/ebu/ograf/tree/${validValue.commit}`, + '- Source date: 2026-08-07', + '- Vendored paths: `v1/specification/docs/Specification.md`, ' + + '`v1/specification/json-schemas/**`, and the four upstream ' + + '`v1/examples/*.ograf.json` manifests', + ].join('\n'); + + expect(() => validateSnapshotMarkdown(metadata, validMarkdown)).not.toThrow(); + expect(() => validateSnapshotMarkdown( + metadata, + validMarkdown.replace(validValue.commit, 'f'.repeat(40)), + )).toThrow(/exactly this line/u); + expect(() => assertSnapshotReferences([{ + label: 'README.md', + content: 'old snapshot', + tokens: [validValue.commit], + }])).toThrow(/README\.md/u); + + expect(() => assertCurrentSnapshotReferences(metadata, [{ + label: 'README.md', + content: [ + validValue.commit, + 'ebu-ograf-v1-01234567', + `https://github.com/ebu/ograf/commit/${validValue.commit}`, + `https://github.com/ebu/ograf/commit/${'f'.repeat(40)}`, + ].join('\n'), + tokens: [validValue.commit, 'ebu-ograf-v1-01234567'], + }])).toThrow(/stale EBU commit URL/u); + }); +}); + +describe('snapshot checksum manifest', () => { + const hash = 'a'.repeat(64); + + it('accepts canonical unique package paths', () => { + expect(parseChecksumManifest( + `${hash} docs/Specification.md\n${hash} SNAPSHOT.json\n`, + )).toEqual(new Map([ + ['docs/Specification.md', hash], + ['SNAPSHOT.json', hash], + ])); + }); + + it('rejects duplicate and unsafe paths', () => { + expect(() => parseChecksumManifest( + `${hash} docs/Specification.md\n${hash} docs/Specification.md\n`, + )).toThrow(/Duplicate/u); + for (const path of ['../outside', './inside', 'folder\\file', '/absolute', 'SHA256SUMS']) { + expect(() => parseChecksumManifest(`${hash} ${path}\n`)).toThrow(/path/u); + } + }); +}); diff --git a/packages/validator-core/spec/ebu-ograf-v1-d42afced/SHA256SUMS b/packages/validator-core/spec/ebu-ograf-v1-d42afced/SHA256SUMS index 0ecd04c..3c936a5 100644 --- a/packages/validator-core/spec/ebu-ograf-v1-d42afced/SHA256SUMS +++ b/packages/validator-core/spec/ebu-ograf-v1-d42afced/SHA256SUMS @@ -14,4 +14,5 @@ bf8c11ac37d051ce2eb191698c24ebfbb3913ba45b5691fd76bcdc8eca2c87a6 json-schemas/l 9478cd10295099a96dd4db8f89cb320c2396b9e7c9f7154c547fb7e926f9303f json-schemas/lib/constraints/number.json 968c262586e08e0bc460ddab2f74a13c1e43add729105a5574300e72b233f42b json-schemas/lib/constraints/string.json 9733ac73e64f483cb7afeab86fd327d5ea18a32edbde6ac61df9272d812e8ea2 LICENSE -013cbfc13d8529f1035b60e5384a36110f38e4250cf0c98709b35808f7947004 SNAPSHOT.md +2159797087bbf3649ef7633cae6c25e727e8313d0189fbb14822ae189f90ba91 SNAPSHOT.json +0afb1a7bb157f229dda937326b32cafc31c168def58d9adb4a5f0d5f28bc263a SNAPSHOT.md diff --git a/packages/validator-core/spec/ebu-ograf-v1-d42afced/SNAPSHOT.json b/packages/validator-core/spec/ebu-ograf-v1-d42afced/SNAPSHOT.json new file mode 100644 index 0000000..a158f3c --- /dev/null +++ b/packages/validator-core/spec/ebu-ograf-v1-d42afced/SNAPSHOT.json @@ -0,0 +1,7 @@ +{ + "formatVersion": 1, + "specification": "OGraf Graphics v1", + "upstreamRepository": "https://github.com/ebu/ograf", + "commit": "d42afcedf9348e05e35b2009b04fb9552785e35b", + "sourceDate": "2026-08-07" +} diff --git a/packages/validator-core/spec/ebu-ograf-v1-d42afced/SNAPSHOT.md b/packages/validator-core/spec/ebu-ograf-v1-d42afced/SNAPSHOT.md index 43af0f4..32d069c 100644 --- a/packages/validator-core/spec/ebu-ograf-v1-d42afced/SNAPSHOT.md +++ b/packages/validator-core/spec/ebu-ograf-v1-d42afced/SNAPSHOT.md @@ -3,6 +3,7 @@ - Upstream: https://github.com/ebu/ograf - Commit: `d42afcedf9348e05e35b2009b04fb9552785e35b` - Commit URL: https://github.com/ebu/ograf/tree/d42afcedf9348e05e35b2009b04fb9552785e35b +- Source date: 2026-08-07 - Vendored paths: `v1/specification/docs/Specification.md`, `v1/specification/json-schemas/**`, and the four upstream `v1/examples/*.ograf.json` manifests - License: MIT; see `LICENSE` diff --git a/packages/validator-core/src/__tests__/spec-d42afced.test.ts b/packages/validator-core/src/__tests__/spec-snapshot.test.ts similarity index 97% rename from packages/validator-core/src/__tests__/spec-d42afced.test.ts rename to packages/validator-core/src/__tests__/spec-snapshot.test.ts index c1963bd..d18b87b 100644 --- a/packages/validator-core/src/__tests__/spec-d42afced.test.ts +++ b/packages/validator-core/src/__tests__/spec-snapshot.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { validateManifest, validatePackage } from '../index.js'; @@ -13,7 +13,17 @@ import type { } from '../index.js'; const OFFICIAL_SCHEMA_URL = 'https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json'; -const SNAPSHOT_ROOT = resolve(__dirname, '../../spec/ebu-ograf-v1-d42afced'); +const SPEC_ROOT = resolve(__dirname, '../../spec'); +const SNAPSHOT_DIRECTORIES = readdirSync(SPEC_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith('ebu-ograf-v1-')); +if (SNAPSHOT_DIRECTORIES.length !== 1 || SNAPSHOT_DIRECTORIES[0] === undefined) { + throw new Error('Expected exactly one vendored EBU OGraf v1 snapshot.'); +} +const SNAPSHOT_ROOT = resolve(SPEC_ROOT, SNAPSHOT_DIRECTORIES[0].name); +const SNAPSHOT_METADATA = JSON.parse(readFileSync( + resolve(SNAPSHOT_ROOT, 'SNAPSHOT.json'), + 'utf8', +)) as { commit: string; sourceDate: string }; function manifest(overrides: Record = {}): unknown { return { @@ -60,7 +70,7 @@ function memoryFs( }; } -describe('vendored EBU d42afced snapshot', () => { +describe(`vendored EBU ${SNAPSHOT_METADATA.commit.slice(0, 8)} snapshot`, () => { it('pins the full upstream commit and the new schema fields', () => { const snapshot = readFileSync(resolve(SNAPSHOT_ROOT, 'SNAPSHOT.md'), 'utf8'); const schema = JSON.parse(readFileSync( @@ -68,7 +78,8 @@ describe('vendored EBU d42afced snapshot', () => { 'utf8', )) as { properties: Record }; - expect(snapshot).toContain('d42afcedf9348e05e35b2009b04fb9552785e35b'); + expect(snapshot).toContain(SNAPSHOT_METADATA.commit); + expect(snapshot).toContain(SNAPSHOT_METADATA.sourceDate); expect(schema.properties).toHaveProperty('actionDurations'); expect(schema.properties).toHaveProperty('thumbnails'); expect(schema.properties).toHaveProperty('renderRequirements'); diff --git a/packages/validator-core/src/types.ts b/packages/validator-core/src/types.ts index 3e32c10..084b484 100644 --- a/packages/validator-core/src/types.ts +++ b/packages/validator-core/src/types.ts @@ -1,8 +1,7 @@ /** * Public types for EBU OGraf v1 manifests. * - * The normative schema snapshot is pinned in - * `spec/ebu-ograf-v1-d42afced`. + * The normative schema snapshot and its upstream commit are included in `spec/`. */ export interface OgrafVendorExtensions { diff --git a/packages/validator-core/src/validate.ts b/packages/validator-core/src/validate.ts index 490bf90..d1a0d59 100644 --- a/packages/validator-core/src/validate.ts +++ b/packages/validator-core/src/validate.ts @@ -1,7 +1,4 @@ -/** - * Public validator API for the EBU OGraf v1 schema pinned at - * d42afcedf9348e05e35b2009b04fb9552785e35b. - */ +/** Public validator API for the pinned EBU OGraf v1 schema snapshot. */ import type { ValidationIssue, ValidationResult, VirtualFS } from './types.js'; import { validateAssets } from './package-validation.js'; diff --git a/scripts/core-package-smoke.mjs b/scripts/core-package-smoke.mjs index bb44864..a903e4a 100644 --- a/scripts/core-package-smoke.mjs +++ b/scripts/core-package-smoke.mjs @@ -5,6 +5,7 @@ import { mkdir, mkdtemp, readFile, + readdir, rm, writeFile, } from 'node:fs/promises'; @@ -16,6 +17,7 @@ import { resolve, } from 'node:path'; import { tmpdir } from 'node:os'; +import { validateSnapshotMetadata } from '../packages/validator-core/scripts/spec-snapshot.mjs'; const require = createRequire(import.meta.url); const repositoryRoot = resolve(import.meta.dirname, '..'); @@ -86,10 +88,24 @@ try { const installedLicense = await readFile(resolve(installedRoot, 'LICENSE'), 'utf8'); assert.match(installedLicense, /MIT License/); await access(resolve(installedRoot, 'CHANGELOG.md')); - await access(resolve( - installedRoot, - 'spec/ebu-ograf-v1-d42afced/json-schemas/graphics/schema.json', - )); + const installedSpecRoot = resolve(installedRoot, 'spec'); + const installedSnapshotDirectories = (await readdir(installedSpecRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && entry.name.startsWith('ebu-ograf-v1-')); + assert.equal( + installedSnapshotDirectories.length, + 1, + 'The package must contain exactly one EBU OGraf v1 snapshot.', + ); + const installedSnapshotDirectory = installedSnapshotDirectories[0]?.name; + assert.equal(typeof installedSnapshotDirectory, 'string'); + const installedSnapshotRoot = resolve(installedSpecRoot, installedSnapshotDirectory); + const installedSnapshotMetadata = JSON.parse( + await readFile(resolve(installedSnapshotRoot, 'SNAPSHOT.json'), 'utf8'), + ); + validateSnapshotMetadata(installedSnapshotMetadata, installedSnapshotDirectory); + await access(resolve(installedSnapshotRoot, 'SNAPSHOT.md')); + await access(resolve(installedSnapshotRoot, 'SHA256SUMS')); + await access(resolve(installedSnapshotRoot, 'json-schemas/graphics/schema.json')); run(process.execPath, ['esm-smoke.mjs'], consumerDirectory); run(process.execPath, ['cjs-smoke.cjs'], consumerDirectory);