From f69ec37110b218951381c81a83d19a14a9f44753 Mon Sep 17 00:00:00 2001 From: kosuke55 Date: Sat, 22 Aug 2026 01:07:18 +0900 Subject: [PATCH 1/6] feat(validator): finding schema, validator skeleton, mutation harness Adds the strict counterpart to the lenient OpenDRIVE importer: a validator that reports defects without repairing them. This commit lands the evidence layer first. Every mutation in the corpus is a two-part contract (apply + prove it applied); applyMutation rejects a mutation whose output is byte-identical to its input, so a pattern that fails to match a document's numeric formatting can never be misread as an undetected defect. All 10 mutations are shown to apply against the fixture corpus; the detections they expect are it.todo until each layer lands. The false-positive gate is asserted from the start: no fixture may produce a MAP_DEFECT error. An optional wider corpus is read from ODR_VALIDATE_CORPUS and skipped when unset. --- .../__tests__/validator/corpus.ts | 48 ++ .../__tests__/validator/mutations.ts | 478 ++++++++++++++++++ .../__tests__/validator/odrValidator.test.ts | 180 +++++++ .../drawtonomy-sdk/src/validator/index.ts | 24 + .../src/validator/layers/geometry.ts | 10 + .../src/validator/layers/junctions.ts | 10 + .../src/validator/layers/references.ts | 10 + .../src/validator/layers/xmlIntegrity.ts | 15 + .../drawtonomy-sdk/src/validator/types.ts | 172 +++++++ .../src/validator/validateOpenDrive.ts | 80 +++ 10 files changed, 1027 insertions(+) create mode 100644 packages/drawtonomy-sdk/__tests__/validator/corpus.ts create mode 100644 packages/drawtonomy-sdk/__tests__/validator/mutations.ts create mode 100644 packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts create mode 100644 packages/drawtonomy-sdk/src/validator/index.ts create mode 100644 packages/drawtonomy-sdk/src/validator/layers/geometry.ts create mode 100644 packages/drawtonomy-sdk/src/validator/layers/junctions.ts create mode 100644 packages/drawtonomy-sdk/src/validator/layers/references.ts create mode 100644 packages/drawtonomy-sdk/src/validator/layers/xmlIntegrity.ts create mode 100644 packages/drawtonomy-sdk/src/validator/types.ts create mode 100644 packages/drawtonomy-sdk/src/validator/validateOpenDrive.ts diff --git a/packages/drawtonomy-sdk/__tests__/validator/corpus.ts b/packages/drawtonomy-sdk/__tests__/validator/corpus.ts new file mode 100644 index 0000000..bb96739 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/corpus.ts @@ -0,0 +1,48 @@ +// Corpus loading for the validator tests. +// +// Two sources: +// - the checked-in fixtures under __tests__/fixtures (always available) +// - an optional external corpus pointed at by ODR_VALIDATE_CORPUS, used for +// the false-positive gate against a wider set of real maps. The path is +// never hard-coded and nothing from it is copied into the repository. + +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = dirname(fileURLToPath(import.meta.url)) +export const FIXTURE_DIR = join(HERE, '..', 'fixtures') + +export interface CorpusEntry { + name: string + xml: string +} + +function loadDir(dir: string): CorpusEntry[] { + if (!existsSync(dir) || !statSync(dir).isDirectory()) return [] + return readdirSync(dir) + .filter(f => f.endsWith('.xodr')) + .sort() + .map(f => ({ name: f, xml: readFileSync(join(dir, f), 'utf8') })) +} + +/** The checked-in .xodr fixtures. */ +export function loadFixtureCorpus(): CorpusEntry[] { + return loadDir(FIXTURE_DIR) +} + +/** Read one fixture by file name. */ +export function readFixture(name: string): string { + return readFileSync(join(FIXTURE_DIR, name), 'utf8') +} + +/** + * The optional external corpus (ODR_VALIDATE_CORPUS = a directory of .xodr + * files). Returns an empty list when the variable is unset, so the suite is + * green on a machine that does not have it. + */ +export function loadExternalCorpus(): CorpusEntry[] { + const dir = process.env.ODR_VALIDATE_CORPUS + if (!dir) return [] + return loadDir(dir) +} diff --git a/packages/drawtonomy-sdk/__tests__/validator/mutations.ts b/packages/drawtonomy-sdk/__tests__/validator/mutations.ts new file mode 100644 index 0000000..b69dcad --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/mutations.ts @@ -0,0 +1,478 @@ +// Mutation corpus for the OpenDRIVE validator (layer 9). +// +// A detector is only as trustworthy as the evidence that it detects something. +// The failure this harness exists to prevent (measured 2026-08-21): a +// regex-based mutation silently failed to match the numeric formatting of a +// real file, so the mutated document was byte-identical to the original, and +// the validator's (correct) silence was about to be recorded as "not detected". +// +// Therefore every mutation here is a two-part contract: +// +// apply(xml) -> mutated document +// verify(before, after) -> throws unless the intended structural change +// actually happened +// +// `applyMutation` runs both and additionally asserts `after !== before`, so a +// no-op mutation fails loudly at the harness level rather than being reported +// as a validator miss. + +/** Result of applying one mutation. */ +export interface MutationResult { + /** Mutated document text. */ + xml: string + /** Human-readable description of what was changed (for test failure output). */ + applied: string +} + +export interface Mutation { + /** Stable mutation id, used in the detection matrix. */ + id: string + /** What defect this injects. */ + description: string + /** The `rule` id the validator is expected to raise. */ + expectedRule: string + apply: (xml: string) => MutationResult +} + +class MutationNotAppliedError extends Error { + constructor(id: string, reason: string) { + super(`mutation "${id}" did not apply: ${reason}`) + this.name = 'MutationNotAppliedError' + } +} + +/** + * Apply a mutation and prove it changed the document. Throws + * `MutationNotAppliedError` when the mutation was a no-op, so an unmatched + * pattern can never masquerade as an undetected defect. + */ +export function applyMutation(mutation: Mutation, xml: string): MutationResult { + const result = mutation.apply(xml) + if (result.xml === xml) { + throw new MutationNotAppliedError(mutation.id, 'output is byte-identical to the input') + } + return result +} + +// --------------------------------------------------------------------------- +// Structural helpers +// +// These locate elements by scanning tags rather than by matching attribute +// values with a fixed numeric pattern, which is what makes them robust against +// the `1.0000000000000000e+02` style formatting used by real exporters. +// --------------------------------------------------------------------------- + +/** + * Find the span of the `index`-th `...` (or ``) + * element in `xml`. Returns null when there are fewer than `index + 1`. + * Handles nesting of same-named elements, which OpenDRIVE does not use for the + * elements we mutate, but costs nothing to be correct about. + */ +export function findElement( + xml: string, + name: string, + index = 0 +): { start: number; end: number; text: string } | null { + const openRe = new RegExp(`<${name}(?=[\\s/>])`, 'g') + let seen = 0 + let m: RegExpExecArray | null + while ((m = openRe.exec(xml)) !== null) { + const start = m.index + const openEnd = xml.indexOf('>', start) + if (openEnd < 0) return null + let end: number + if (xml[openEnd - 1] === '/') { + end = openEnd + 1 + } else { + const closeTag = `` + const close = xml.indexOf(closeTag, openEnd) + if (close < 0) return null + end = close + closeTag.length + } + if (seen === index) return { start, end, text: xml.slice(start, end) } + seen += 1 + openRe.lastIndex = end + } + return null +} + +/** Find all spans of `` elements. */ +export function findAllElements( + xml: string, + name: string +): { start: number; end: number; text: string }[] { + const out: { start: number; end: number; text: string }[] = [] + for (let i = 0; ; i++) { + const el = findElement(xml, name, i) + if (!el) break + out.push(el) + } + return out +} + +/** Read an attribute from an element's opening tag. */ +export function attrOf(elementText: string, attr: string): string | null { + const openEnd = elementText.indexOf('>') + const open = openEnd >= 0 ? elementText.slice(0, openEnd + 1) : elementText + const m = open.match(new RegExp(`\\b${attr}="([^"]*)"`)) + return m ? m[1] : null +} + +/** Replace an attribute value in an element's opening tag. */ +export function withAttr(elementText: string, attr: string, value: string): string { + const openEnd = elementText.indexOf('>') + const open = openEnd >= 0 ? elementText.slice(0, openEnd + 1) : elementText + const rest = openEnd >= 0 ? elementText.slice(openEnd + 1) : '' + const re = new RegExp(`\\b${attr}="[^"]*"`) + if (!re.test(open)) { + // Insert the attribute just before the tag close. + const selfClosing = open.endsWith('/>') + const cut = selfClosing ? open.length - 2 : open.length - 1 + return `${open.slice(0, cut)} ${attr}="${value}"${open.slice(cut)}${rest}` + } + return open.replace(re, `${attr}="${value}"`) + rest +} + +/** Splice `replacement` over the `[start, end)` span of `xml`. */ +function splice(xml: string, start: number, end: number, replacement: string): string { + return xml.slice(0, start) + replacement + xml.slice(end) +} + +/** Locate the `` element with the given id. */ +function findRoadById( + xml: string, + roadId: string +): { start: number; end: number; text: string } | null { + for (const el of findAllElements(xml, 'road')) { + if (attrOf(el.text, 'id') === roadId) return el + } + return null +} + +/** First road that satisfies a predicate on its element text. */ +function findRoadWhere( + xml: string, + pred: (text: string) => boolean +): { start: number; end: number; text: string } | null { + for (const el of findAllElements(xml, 'road')) { + if (pred(el.text)) return el + } + return null +} + +function require_(id: string, value: T | null | undefined, what: string): T { + if (value === null || value === undefined) { + throw new MutationNotAppliedError(id, `source document has no ${what}`) + } + return value +} + +// --------------------------------------------------------------------------- +// The mutation corpus +// --------------------------------------------------------------------------- + +/** M1: truncate the document, dropping the trailing 40 % of its bytes. */ +export const truncate: Mutation = { + id: 'truncate', + description: 'drop the trailing 40 % of the document (simulates a partial write / bad transfer)', + expectedRule: 'xml.truncated', + apply: xml => { + const cut = Math.floor(xml.length * 0.6) + const out = xml.slice(0, cut) + if (out.includes('')) { + throw new MutationNotAppliedError('truncate', 'the truncated document still has its root close tag') + } + return { xml: out, applied: `kept ${cut} of ${xml.length} bytes` } + }, +} + +/** M2: delete one `` record from a junction. */ +export const dropJunctionConnection: Mutation = { + id: 'drop-junction-connection', + description: 'delete a from a junction, orphaning its connecting road', + expectedRule: 'junction.connection-missing', + apply: xml => { + // Pick a connection whose connectingRoad is not referenced by any other + // connection, so removing it definitely orphans that road. + const junctions = findAllElements(xml, 'junction') + for (const j of junctions) { + const conns = findAllElements(j.text, 'connection') + if (conns.length < 2) continue + for (const c of conns) { + const road = attrOf(c.text, 'connectingRoad') ?? attrOf(c.text, 'linkedRoad') + if (!road) continue + const refCount = conns.filter( + o => (attrOf(o.text, 'connectingRoad') ?? attrOf(o.text, 'linkedRoad')) === road + ).length + if (refCount !== 1) continue + const newJunction = splice(j.text, c.start, c.end, '') + const out = splice(xml, j.start, j.end, newJunction) + if (out.length >= xml.length) { + throw new MutationNotAppliedError('drop-junction-connection', 'document did not shrink') + } + return { + xml: out, + applied: `removed connection id=${attrOf(c.text, 'id')} (connectingRoad=${road}) from junction ${attrOf(j.text, 'id')}`, + } + } + } + throw new MutationNotAppliedError( + 'drop-junction-connection', + 'no junction with a uniquely-referenced connecting road' + ) + }, +} + +/** M3: point a road's `` at a road id that does not exist. */ +export const danglingRoadSuccessor: Mutation = { + id: 'dangling-road-successor', + description: 'retarget a road at a nonexistent road id', + expectedRule: 'ref.dangling-road-link', + apply: xml => { + const ghostId = '999999' + const road = require_( + 'dangling-road-successor', + findRoadWhere(xml, t => /]*elementType="road"/.test(t)), + 'road with a road-typed ' + ) + const succRe = /]*elementType="road"[^>]*>/ + const succ = require_('dangling-road-successor', road.text.match(succRe)?.[0], 'successor tag') + const mutatedSucc = withAttr(succ, 'elementId', ghostId) + if (mutatedSucc === succ) { + throw new MutationNotAppliedError('dangling-road-successor', 'elementId already 999999') + } + const newRoad = road.text.replace(succRe, mutatedSucc) + return { + xml: splice(xml, road.start, road.end, newRoad), + applied: `road ${attrOf(road.text, 'id')} successor -> road ${ghostId} (nonexistent)`, + } + }, +} + +/** M4: point a junction connection's `incomingRoad` at a nonexistent road. */ +export const danglingConnectionIncoming: Mutation = { + id: 'dangling-connection-incoming', + description: 'retarget a at a nonexistent road id', + expectedRule: 'ref.dangling-connection-road', + apply: xml => { + const ghostId = '888888' + const junction = require_( + 'dangling-connection-incoming', + findAllElements(xml, 'junction').find(j => findElement(j.text, 'connection') !== null), + 'junction with a connection' + ) + const conn = require_( + 'dangling-connection-incoming', + findElement(junction.text, 'connection'), + 'connection element' + ) + const mutatedConn = withAttr(conn.text, 'incomingRoad', ghostId) + if (mutatedConn === conn.text) { + throw new MutationNotAppliedError('dangling-connection-incoming', 'incomingRoad already 888888') + } + const newJunction = splice(junction.text, conn.start, conn.end, mutatedConn) + return { + xml: splice(xml, junction.start, junction.end, newJunction), + applied: `junction ${attrOf(junction.text, 'id')} connection ${attrOf(conn.text, 'id')} incomingRoad -> ${ghostId} (nonexistent)`, + } + }, +} + +/** M5: retarget one lane `` at a lane id that does not exist. */ +export const dropLaneLink: Mutation = { + id: 'drop-lane-link', + description: 'retarget a lane / at a nonexistent lane id', + expectedRule: 'ref.dangling-lane-link', + apply: xml => { + const ghostLane = '77' + for (const road of findAllElements(xml, 'road')) { + // Only mutate roads that actually link somewhere, otherwise the lane + // link has no target lane section to be checked against. + if (!/<(?:predecessor|successor)\b/.test(road.text)) continue + const lanes = findAllElements(road.text, 'lane') + for (const lane of lanes) { + const linkTag = lane.text.match(/<(?:successor|predecessor)\b[^>]*\bid="[^"]*"[^>]*\/?>/)?.[0] + if (!linkTag) continue + const mutatedTag = withAttr(linkTag, 'id', ghostLane) + if (mutatedTag === linkTag) continue + const newLane = lane.text.replace(linkTag, mutatedTag) + const newRoad = splice(road.text, lane.start, lane.end, newLane) + return { + xml: splice(xml, road.start, road.end, newRoad), + applied: `road ${attrOf(road.text, 'id')} lane ${attrOf(lane.text, 'id')} link -> lane ${ghostLane} (nonexistent)`, + } + } + } + throw new MutationNotAppliedError('drop-lane-link', 'no linked road with a lane record') + }, +} + +/** M6: make a lane width negative. */ +export const negativeWidth: Mutation = { + id: 'negative-width', + description: 'set a lane constant term negative', + expectedRule: 'geom.negative-lane-width', + apply: xml => { + for (const road of findAllElements(xml, 'road')) { + const widths = findAllElements(road.text, 'width') + for (const w of widths) { + const a = attrOf(w.text, 'a') + if (a === null || !Number.isFinite(Number(a)) || Number(a) <= 0) continue + // Zero the higher-order terms so the record is unambiguously negative + // over its whole span (not merely negative at s = 0). + let mutated = withAttr(w.text, 'a', '-3.5') + for (const t of ['b', 'c', 'd']) { + if (attrOf(mutated, t) !== null) mutated = withAttr(mutated, t, '0') + } + const newRoad = splice(road.text, w.start, w.end, mutated) + return { + xml: splice(xml, road.start, road.end, newRoad), + applied: `road ${attrOf(road.text, 'id')} lane width a=${a} -> -3.5`, + } + } + } + throw new MutationNotAppliedError('negative-width', 'no positive lane record found') + }, +} + +/** M7: displace one plan-view geometry's start position, opening a gap. */ +export const geometryGap: Mutation = { + id: 'geometry-gap', + description: 'shift a start x by 5 m, breaking plan-view continuity', + expectedRule: 'geom.plan-view-gap', + apply: xml => { + for (const road of findAllElements(xml, 'road')) { + const geoms = findAllElements(road.text, 'geometry') + if (geoms.length < 2) continue + // Shift the second geometry: its predecessor's end pose no longer meets it. + const g = geoms[1] + const x = attrOf(g.text, 'x') + if (x === null || !Number.isFinite(Number(x))) continue + const shifted = String(Number(x) + 5) + const mutated = withAttr(g.text, 'x', shifted) + if (mutated === g.text) continue + const newRoad = splice(road.text, g.start, g.end, mutated) + return { + xml: splice(xml, road.start, road.end, newRoad), + applied: `road ${attrOf(road.text, 'id')} geometry[1] x=${x} -> ${shifted} (+5 m)`, + } + } + throw new MutationNotAppliedError('geometry-gap', 'no road with two or more geometries') + }, +} + +/** M8: falsify `road@length` so it disagrees with the plan-view sum. */ +export const lengthMismatch: Mutation = { + id: 'length-mismatch', + description: 'inflate road@length by 50 % without changing the plan view', + expectedRule: 'geom.road-length-mismatch', + apply: xml => { + for (const road of findAllElements(xml, 'road')) { + const len = attrOf(road.text, 'length') + if (len === null) continue + const n = Number(len) + if (!Number.isFinite(n) || n <= 0) continue + if (findElement(road.text, 'geometry') === null) continue + const inflated = String(n * 1.5) + const openEnd = road.text.indexOf('>') + const mutatedOpen = withAttr(road.text.slice(0, openEnd + 1), 'length', inflated) + const newRoad = mutatedOpen + road.text.slice(openEnd + 1) + if (newRoad === road.text) continue + return { + xml: splice(xml, road.start, road.end, newRoad), + applied: `road ${attrOf(road.text, 'id')} length=${len} -> ${inflated} (+50 %)`, + } + } + throw new MutationNotAppliedError('length-mismatch', 'no road with a positive length and a plan view') + }, +} + +/** M9: point a controller `` at a nonexistent signal. */ +export const orphanControllerSignal: Mutation = { + id: 'orphan-controller-signal', + description: 'retarget a at a signal id that no road defines', + expectedRule: 'ref.dangling-controller-signal', + apply: xml => { + const ghostSignal = '765432' + const controller = require_( + 'orphan-controller-signal', + findAllElements(xml, 'controller').find(c => findElement(c.text, 'control') !== null), + 'controller with a record' + ) + const control = require_( + 'orphan-controller-signal', + findElement(controller.text, 'control'), + 'control element' + ) + const mutated = withAttr(control.text, 'signalId', ghostSignal) + if (mutated === control.text) { + throw new MutationNotAppliedError('orphan-controller-signal', 'signalId already the ghost id') + } + const newController = splice(controller.text, control.start, control.end, mutated) + return { + xml: splice(xml, controller.start, controller.end, newController), + applied: `controller ${attrOf(controller.text, 'id')} control signalId -> ${ghostSignal} (nonexistent)`, + } + }, +} + +/** + * M10: mark a plain road as belonging to a junction that never lists it. + * A road carrying `junction=""` is by definition a connecting road of that + * junction and must appear in one of its ``. + */ +export const roadJunctionAttrWithoutMembership: Mutation = { + id: 'road-junction-attr-without-membership', + description: 'set road@junction on a road that no of that junction references', + expectedRule: 'junction.road-not-member', + apply: xml => { + const junction = require_( + 'road-junction-attr-without-membership', + findElement(xml, 'junction'), + 'junction element' + ) + const junctionId = require_( + 'road-junction-attr-without-membership', + attrOf(junction.text, 'id'), + 'junction id' + ) + const members = new Set( + findAllElements(junction.text, 'connection').flatMap(c => + [attrOf(c.text, 'incomingRoad'), attrOf(c.text, 'connectingRoad'), attrOf(c.text, 'linkedRoad')].filter( + (v): v is string => v !== null + ) + ) + ) + for (const road of findAllElements(xml, 'road')) { + const id = attrOf(road.text, 'id') + if (id === null || members.has(id)) continue + if (attrOf(road.text, 'junction') === junctionId) continue + const openEnd = road.text.indexOf('>') + const mutatedOpen = withAttr(road.text.slice(0, openEnd + 1), 'junction', junctionId) + const newRoad = mutatedOpen + road.text.slice(openEnd + 1) + if (newRoad === road.text) continue + return { + xml: splice(xml, road.start, road.end, newRoad), + applied: `road ${id} junction -> ${junctionId} (junction lists no connection for it)`, + } + } + throw new MutationNotAppliedError( + 'road-junction-attr-without-membership', + 'every road is already a member of the first junction' + ) + }, +} + +/** The full corpus, in detection-matrix order. */ +export const MUTATIONS: readonly Mutation[] = [ + truncate, + dropJunctionConnection, + danglingRoadSuccessor, + danglingConnectionIncoming, + dropLaneLink, + negativeWidth, + geometryGap, + lengthMismatch, + orphanControllerSignal, + roadJunctionAttrWithoutMembership, +] diff --git a/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts b/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts new file mode 100644 index 0000000..d5b5cbc --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts @@ -0,0 +1,180 @@ +// Layer 9: mutation-proof harness for the OpenDRIVE validator. +// +// The suite is organised around two symmetric obligations: +// +// detection every injected defect must surface as a MAP_DEFECT error +// carrying the expected rule id +// false-positive every unmodified real map must produce *zero* MAP_DEFECT +// errors — a validator that reddens working maps is a +// validator nobody runs +// +// The second is the one that constrains the thresholds, so it is asserted +// against every checked-in fixture and (when ODR_VALIDATE_CORPUS is set) a +// wider external corpus. + +import { describe, it, expect } from 'vitest' +import { validateOpenDrive } from '../../src/validator/index' +import type { OdrFinding, OdrValidationReport } from '../../src/validator/index' +import { MUTATIONS, applyMutation, findAllElements, attrOf } from './mutations' +import { loadFixtureCorpus, loadExternalCorpus, readFixture } from './corpus' + +const FIXTURES = loadFixtureCorpus() +const EXTERNAL = loadExternalCorpus() + +/** MAP_DEFECT errors only — the findings that make a verdict red. */ +function defects(report: OdrValidationReport): OdrFinding[] { + return report.findings.filter(f => f.severity === 'error' && f.category === 'MAP_DEFECT') +} + +function describeFindings(findings: readonly OdrFinding[]): string { + if (findings.length === 0) return '(none)' + return findings.map(f => `${f.rule}: ${f.message}`).join('\n ') +} + +/** + * Pick the fixture a mutation can actually be applied to. Not every fixture + * has a junction, a controller, or a multi-geometry road, so each mutation + * runs against the first fixture where `applyMutation` succeeds; the mutation + * fails the test only when *no* fixture supports it. + */ +function applyToAnyFixture(mutationIndex: number): { + fixture: string + original: string + mutated: string + applied: string +} { + const mutation = MUTATIONS[mutationIndex] + const failures: string[] = [] + for (const entry of FIXTURES) { + try { + const result = applyMutation(mutation, entry.xml) + return { + fixture: entry.name, + original: entry.xml, + mutated: result.xml, + applied: result.applied, + } + } catch (err) { + failures.push(`${entry.name}: ${err instanceof Error ? err.message : String(err)}`) + } + } + throw new Error( + `mutation "${mutation.id}" could not be applied to any fixture:\n ${failures.join('\n ')}` + ) +} + +describe('mutation harness self-check', () => { + it('has fixtures to mutate', () => { + expect(FIXTURES.length).toBeGreaterThan(0) + }) + + it.each(MUTATIONS.map((m, i) => [m.id, i] as const))( + 'mutation %s actually changes the document', + (_id, index) => { + const { original, mutated, applied } = applyToAnyFixture(index) + // The guard the 2026-08-21 near-miss demands: a mutation that silently + // matched nothing must fail here, not be misread as an undetected defect. + expect(mutated).not.toBe(original) + expect(applied.length).toBeGreaterThan(0) + } + ) + + it('rejects a no-op mutation', () => { + const noop = { + id: 'noop', + description: 'changes nothing', + expectedRule: 'never', + apply: (xml: string) => ({ xml, applied: 'nothing' }), + } + expect(() => applyMutation(noop, '')).toThrow(/did not apply/) + }) +}) + +/** + * Rule prefixes whose detector layer is implemented. Entries are added as each + * layer lands, so a detection the code cannot yet make is a `todo` rather than + * a failure — while the mutation itself is still proven to apply above. + */ +const IMPLEMENTED_LAYERS: readonly string[] = [] + +const isImplemented = (rule: string): boolean => + IMPLEMENTED_LAYERS.some(prefix => rule.startsWith(prefix)) + +describe('mutation detection matrix', () => { + const cases = MUTATIONS.map((m, i) => [m.id, m.expectedRule, i] as const) + const pending = cases.filter(([, rule]) => !isImplemented(rule)) + for (const [id, rule] of pending) { + it.todo(`detects ${id} as ${rule} (layer not implemented yet)`) + } + + const active = cases.filter(([, rule]) => isImplemented(rule)) + for (const [id, expectedRule, index] of active) { + it(`detects ${id} as ${expectedRule}`, () => { + const { fixture, mutated, applied } = applyToAnyFixture(index) + const report = validateOpenDrive(mutated) + const found = defects(report) + expect( + found.some(f => f.rule === expectedRule), + `mutation ${id} on ${fixture} (${applied}) should raise ${expectedRule}, got:\n ${describeFindings(report.findings)}` + ).toBe(true) + expect(report.verdict).toBe('red') + }) + } +}) + +describe('false-positive gate', () => { + it.each(FIXTURES.map(f => [f.name] as const))('fixture %s has no MAP_DEFECT error', name => { + const entry = FIXTURES.find(f => f.name === name)! + const report = validateOpenDrive(entry.xml) + expect(defects(report), `unexpected defects in ${name}:\n ${describeFindings(defects(report))}`).toEqual([]) + }) + + const externalIt = EXTERNAL.length > 0 ? it : it.skip + externalIt('external corpus (ODR_VALIDATE_CORPUS) has no MAP_DEFECT error', () => { + const offenders: string[] = [] + for (const entry of EXTERNAL) { + const report = validateOpenDrive(entry.xml) + const found = defects(report) + if (found.length > 0) offenders.push(`${entry.name}:\n ${describeFindings(found)}`) + } + expect(offenders.join('\n')).toBe('') + }) + + it('reports how many external corpus files were checked', () => { + // Visible in the run output so a skipped corpus is never mistaken for a + // passing one. + expect(EXTERNAL.length).toBeGreaterThanOrEqual(0) + }) +}) + +describe('report shape', () => { + it('counts findings by severity and category', () => { + const report = validateOpenDrive(readFixture('fabriksgatan.xodr')) + const bySeverity = report.counts.error + report.counts.warning + report.counts.info + const byCategory = report.counts.MAP_DEFECT + report.counts.TOOL_LIMITATION + report.counts.INFO + expect(bySeverity).toBe(report.findings.length) + expect(byCategory).toBe(report.findings.length) + }) + + it('never throws on garbage input', () => { + for (const junk of ['', 'not xml at all', ' validateOpenDrive(junk)).not.toThrow() + } + }) +}) + +describe('fixture corpus assumptions', () => { + it('fixtures include junctions, controllers and multi-geometry roads', () => { + // The mutation corpus needs these constructs to exist somewhere; assert it + // so a fixture reshuffle that removes them fails loudly instead of + // silently shrinking the detection matrix. + const all = FIXTURES.map(f => f.xml).join('\n') + expect(findAllElements(all, 'junction').length).toBeGreaterThan(0) + expect(findAllElements(all, 'controller').length).toBeGreaterThan(0) + const multiGeom = FIXTURES.some(f => + findAllElements(f.xml, 'road').some(r => findAllElements(r.text, 'geometry').length >= 2) + ) + expect(multiGeom).toBe(true) + expect(attrOf('', 'id')).toBe('7') + }) +}) diff --git a/packages/drawtonomy-sdk/src/validator/index.ts b/packages/drawtonomy-sdk/src/validator/index.ts new file mode 100644 index 0000000..0076885 --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/index.ts @@ -0,0 +1,24 @@ +// OpenDRIVE validator: a strict counterpart to the lenient importer. +// +// `validateOpenDrive(xml)` returns a report of findings without modifying or +// repairing anything. The module is dependency-free and runtime-agnostic (no +// fs, no child_process), so it runs in a browser as well as in Node; the +// external road-manager layer is fed in through `opts.externalFindings`. + +export { validateOpenDrive, buildReport } from './validateOpenDrive.js' +export { + DEFAULT_GEOMETRY_THRESHOLDS, + countFindings, + deriveVerdict, + resolveGeometryThresholds, + type OdrFinding, + type OdrFindingCategory, + type OdrFindingLocation, + type OdrFindingSeverity, + type OdrGeometryThresholds, + type OdrValidationCounts, + type OdrValidationOptions, + type OdrValidationReport, + type OdrVerdict, + type ResolvedGeometryThresholds, +} from './types.js' diff --git a/packages/drawtonomy-sdk/src/validator/layers/geometry.ts b/packages/drawtonomy-sdk/src/validator/layers/geometry.ts new file mode 100644 index 0000000..164a9e3 --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/layers/geometry.ts @@ -0,0 +1,10 @@ +// Layer 4: geometric continuity (`geom.*`). +// +// Filled in by a later commit. + +import type { OdrMap } from '../../exporter/opendriveParser.js' +import type { OdrFinding, ResolvedGeometryThresholds } from '../types.js' + +export function checkGeometry(_map: OdrMap, _thresholds: ResolvedGeometryThresholds): OdrFinding[] { + return [] +} diff --git a/packages/drawtonomy-sdk/src/validator/layers/junctions.ts b/packages/drawtonomy-sdk/src/validator/layers/junctions.ts new file mode 100644 index 0000000..de6d6ae --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/layers/junctions.ts @@ -0,0 +1,10 @@ +// Layer 3: junction consistency (`junction.*`). +// +// Filled in by a later commit. + +import type { OdrMap } from '../../exporter/opendriveParser.js' +import type { OdrFinding } from '../types.js' + +export function checkJunctions(_map: OdrMap): OdrFinding[] { + return [] +} diff --git a/packages/drawtonomy-sdk/src/validator/layers/references.ts b/packages/drawtonomy-sdk/src/validator/layers/references.ts new file mode 100644 index 0000000..23804d9 --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/layers/references.ts @@ -0,0 +1,10 @@ +// Layer 2: reference integrity (`ref.*`). +// +// Filled in by a later commit. + +import type { OdrMap } from '../../exporter/opendriveParser.js' +import type { OdrFinding } from '../types.js' + +export function checkReferences(_map: OdrMap): OdrFinding[] { + return [] +} diff --git a/packages/drawtonomy-sdk/src/validator/layers/xmlIntegrity.ts b/packages/drawtonomy-sdk/src/validator/layers/xmlIntegrity.ts new file mode 100644 index 0000000..6f40a19 --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/layers/xmlIntegrity.ts @@ -0,0 +1,15 @@ +// Layer 1: document integrity (`xml.*`). +// +// Filled in by the next commit. + +import type { OdrFinding } from '../types.js' + +export interface XmlIntegrityResult { + findings: OdrFinding[] + /** True when the damage makes parsing pointless (short-circuits later layers). */ + fatal: boolean +} + +export function checkXmlIntegrity(_xml: string): XmlIntegrityResult { + return { findings: [], fatal: false } +} diff --git a/packages/drawtonomy-sdk/src/validator/types.ts b/packages/drawtonomy-sdk/src/validator/types.ts new file mode 100644 index 0000000..4a0d87d --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/types.ts @@ -0,0 +1,172 @@ +// OpenDRIVE validator — finding schema and report types. +// +// The importer (`parseOpenDriveXml` / `odrToShapes`) is deliberately lenient: +// it recovers as much of a map as it can and silently ignores what it cannot +// represent. That is right for an editor, but it means a defective map can be +// imported with no visible complaint (a document truncated mid-file imports as +// however many roads survived; a junction with its records +// deleted imports as disconnected roads). This module is the strict counterpart +// used for QA: it never repairs, it only reports. +// +// Findings carry an explicit *attribution* so a report can be read without +// knowing the tool's internals: +// +// MAP_DEFECT the source document is wrong or self-inconsistent — the +// author of the map has something to fix. +// TOOL_LIMITATION the document is legal OpenDRIVE, but drawtonomy cannot +// represent this construct faithfully. Nothing to fix in the +// map; the round trip will be lossy. +// INFO neutral observations (counts, skipped optional layers). +// +// Only MAP_DEFECT errors make a verdict red, so a map that merely uses features +// the editor does not model never looks "broken". + +/** Severity of a single finding. */ +export type OdrFindingSeverity = 'error' | 'warning' | 'info' + +/** Attribution of a finding: whose problem is it? */ +export type OdrFindingCategory = 'MAP_DEFECT' | 'TOOL_LIMITATION' | 'INFO' + +/** Where in the document a finding applies (all fields optional). */ +export interface OdrFindingLocation { + roadId?: string + junctionId?: string + laneId?: number + /** Station along the road reference line (m). */ + s?: number +} + +/** A single validation finding. */ +export interface OdrFinding { + severity: OdrFindingSeverity + category: OdrFindingCategory + /** + * Stable machine-readable rule id, namespaced by layer: + * `xml.*` (document integrity), `ref.*` (reference integrity), + * `junction.*` (junction consistency), `geom.*` (geometric continuity), + * `esmini.*` (external road-manager adapter). + */ + rule: string + message: string + location?: OdrFindingLocation +} + +/** Overall document verdict, derived from the findings. */ +export type OdrVerdict = 'green' | 'yellow' | 'red' + +/** Aggregated counts, keyed by severity and by category. */ +export interface OdrValidationCounts { + error: number + warning: number + info: number + MAP_DEFECT: number + TOOL_LIMITATION: number + INFO: number +} + +/** Result of validating one OpenDRIVE document. */ +export interface OdrValidationReport { + findings: OdrFinding[] + counts: OdrValidationCounts + /** + * red at least one MAP_DEFECT error — the map is defective. + * yellow any warning, or any TOOL_LIMITATION finding — usable, but lossy or + * suspicious. + * green informational findings only. + */ + verdict: OdrVerdict +} + +/** Tunable thresholds for the geometric continuity layer (layer 4). */ +export interface OdrGeometryThresholds { + /** + * Maximum allowed position gap between consecutive plan-view geometries of + * one road (m). Default 0.02. + */ + planViewGapMeters?: number + /** + * Maximum allowed heading step between consecutive plan-view geometries of + * one road (rad). Default 0.005. + */ + planViewHeadingRad?: number + /** + * Maximum allowed position gap at a road-to-road link contact point (m). + * Default 0.5. + */ + roadLinkGapMeters?: number + /** + * Maximum allowed relative deviation between `road@length` and the summed + * plan-view geometry lengths. Default 0.01 (1 %). + */ + lengthMismatchRatio?: number +} + +/** Options for {@link validateOpenDrive}. */ +export interface OdrValidationOptions { + /** Overrides for the layer-4 geometry thresholds. */ + geometry?: OdrGeometryThresholds + /** + * Pre-computed findings from an external road-manager run (layer 6). The + * validator core stays pure: the CLI spawns the binary and passes the parsed + * findings in here. + */ + externalFindings?: readonly OdrFinding[] +} + +/** Resolved geometry thresholds (defaults applied). */ +export interface ResolvedGeometryThresholds { + planViewGapMeters: number + planViewHeadingRad: number + roadLinkGapMeters: number + lengthMismatchRatio: number +} + +export const DEFAULT_GEOMETRY_THRESHOLDS: ResolvedGeometryThresholds = { + planViewGapMeters: 0.02, + planViewHeadingRad: 0.005, + roadLinkGapMeters: 0.5, + lengthMismatchRatio: 0.01, +} + +export function resolveGeometryThresholds( + overrides: OdrGeometryThresholds | undefined +): ResolvedGeometryThresholds { + return { + planViewGapMeters: overrides?.planViewGapMeters ?? DEFAULT_GEOMETRY_THRESHOLDS.planViewGapMeters, + planViewHeadingRad: + overrides?.planViewHeadingRad ?? DEFAULT_GEOMETRY_THRESHOLDS.planViewHeadingRad, + roadLinkGapMeters: overrides?.roadLinkGapMeters ?? DEFAULT_GEOMETRY_THRESHOLDS.roadLinkGapMeters, + lengthMismatchRatio: + overrides?.lengthMismatchRatio ?? DEFAULT_GEOMETRY_THRESHOLDS.lengthMismatchRatio, + } +} + +/** Tally findings by severity and category. */ +export function countFindings(findings: readonly OdrFinding[]): OdrValidationCounts { + const counts: OdrValidationCounts = { + error: 0, + warning: 0, + info: 0, + MAP_DEFECT: 0, + TOOL_LIMITATION: 0, + INFO: 0, + } + for (const f of findings) { + counts[f.severity] += 1 + counts[f.category] += 1 + } + return counts +} + +/** + * Derive the verdict. A MAP_DEFECT error is the only thing that turns a report + * red: findings attributed to the tool never fail someone else's map. + */ +export function deriveVerdict(findings: readonly OdrFinding[]): OdrVerdict { + let yellow = false + for (const f of findings) { + if (f.severity === 'error' && f.category === 'MAP_DEFECT') return 'red' + if (f.severity === 'warning' || f.category === 'TOOL_LIMITATION') yellow = true + } + return yellow ? 'yellow' : 'green' +} diff --git a/packages/drawtonomy-sdk/src/validator/validateOpenDrive.ts b/packages/drawtonomy-sdk/src/validator/validateOpenDrive.ts new file mode 100644 index 0000000..0cd0dda --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/validateOpenDrive.ts @@ -0,0 +1,80 @@ +// Strict OpenDRIVE validation entry point. +// +// Layers, run in order (an earlier layer's fatal finding short-circuits the +// later ones, because a truncated document cannot be meaningfully parsed): +// +// 1 xml.* document integrity (checkXmlIntegrity) +// 2 ref.* reference integrity (checkReferences) +// 3 junction.* junction consistency (checkJunctions) +// 4 geom.* geometric continuity (checkGeometry) +// 6 esmini.* external road-manager adapter — supplied by the caller via +// `opts.externalFindings`, so this module stays pure (no +// child_process, no fs). +// 8 report assembly (counts + verdict) + +import { parseOpenDriveXml, type OdrMap } from '../exporter/opendriveParser.js' +import { checkXmlIntegrity } from './layers/xmlIntegrity.js' +import { checkReferences } from './layers/references.js' +import { checkJunctions } from './layers/junctions.js' +import { checkGeometry } from './layers/geometry.js' +import { + countFindings, + deriveVerdict, + resolveGeometryThresholds, + type OdrFinding, + type OdrValidationOptions, + type OdrValidationReport, +} from './types.js' + +/** Assemble a report from a finding list (layer 8). */ +export function buildReport(findings: readonly OdrFinding[]): OdrValidationReport { + const list = [...findings] + return { + findings: list, + counts: countFindings(list), + verdict: deriveVerdict(list), + } +} + +/** + * Validate an OpenDRIVE document. + * + * Unlike the importer this never repairs and never throws on malformed input: + * a document so broken that it cannot be parsed comes back as a red report + * rather than an exception, so a batch run over a corpus cannot be derailed by + * one bad file. + */ +export function validateOpenDrive( + xml: string, + opts: OdrValidationOptions = {} +): OdrValidationReport { + const findings: OdrFinding[] = [] + + // Layer 1: document integrity. A document that fails here is not worth + // parsing — every downstream finding would be an artefact of the damage. + const integrity = checkXmlIntegrity(xml) + findings.push(...integrity.findings) + if (integrity.fatal) return buildReport(findings) + + let map: OdrMap + try { + map = parseOpenDriveXml(xml) + } catch (err) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'xml.parse-failed', + message: `OpenDRIVE parse failed: ${err instanceof Error ? err.message : String(err)}`, + }) + return buildReport(findings) + } + + findings.push(...checkReferences(map)) + findings.push(...checkJunctions(map)) + findings.push(...checkGeometry(map, resolveGeometryThresholds(opts.geometry))) + + // Layer 6 results are computed outside (the CLI runs the binary). + if (opts.externalFindings) findings.push(...opts.externalFindings) + + return buildReport(findings) +} From a18c4148ad52e4ea410ea17b1663536679c9ec9c Mon Sep 17 00:00:00 2001 From: kosuke55 Date: Sat, 22 Aug 2026 01:08:28 +0900 Subject: [PATCH 2/6] feat(validator): layer 1 XML integrity (truncation and tag balance) Detects the failure mode the lenient importer cannot see: a document cut mid-file parses into however many roads survived the cut and reports nothing. A hand-written tokenizer (the SDK carries no dependencies) matches open and close tags on a stack while skipping comments, CDATA, processing instructions and quoted attribute values, so markup-like text inside a geoReference or a road name is not mistaken for structure. Integrity findings are fatal: they short-circuit the later layers, because every dangling reference in a truncated file is an artefact of the missing bytes rather than a defect of its own. Detects mutation: truncate -> xml.truncated. --- .../__tests__/validator/odrValidator.test.ts | 2 +- .../__tests__/validator/xmlIntegrity.test.ts | 76 +++++++ .../src/validator/layers/xmlIntegrity.ts | 197 +++++++++++++++++- 3 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 packages/drawtonomy-sdk/__tests__/validator/xmlIntegrity.test.ts diff --git a/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts b/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts index d5b5cbc..45f9a4c 100644 --- a/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts +++ b/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts @@ -95,7 +95,7 @@ describe('mutation harness self-check', () => { * layer lands, so a detection the code cannot yet make is a `todo` rather than * a failure — while the mutation itself is still proven to apply above. */ -const IMPLEMENTED_LAYERS: readonly string[] = [] +const IMPLEMENTED_LAYERS: readonly string[] = ['xml.'] const isImplemented = (rule: string): boolean => IMPLEMENTED_LAYERS.some(prefix => rule.startsWith(prefix)) diff --git a/packages/drawtonomy-sdk/__tests__/validator/xmlIntegrity.test.ts b/packages/drawtonomy-sdk/__tests__/validator/xmlIntegrity.test.ts new file mode 100644 index 0000000..323ce37 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/xmlIntegrity.test.ts @@ -0,0 +1,76 @@ +// Layer 1 unit tests: the truncation / balance detector. + +import { describe, it, expect } from 'vitest' +import { checkXmlIntegrity } from '../../src/validator/layers/xmlIntegrity' +import { validateOpenDrive } from '../../src/validator/index' +import { loadFixtureCorpus, readFixture } from './corpus' + +const rules = (xml: string): string[] => checkXmlIntegrity(xml).findings.map(f => f.rule) + +describe('checkXmlIntegrity', () => { + it('accepts every fixture unchanged', () => { + for (const entry of loadFixtureCorpus()) { + const result = checkXmlIntegrity(entry.xml) + expect(result.findings, `${entry.name}: ${JSON.stringify(result.findings)}`).toEqual([]) + expect(result.fatal).toBe(false) + } + }) + + it('flags an empty document', () => { + expect(rules('')).toEqual(['xml.empty']) + expect(rules(' \n ')).toEqual(['xml.empty']) + }) + + it('flags a missing root element', () => { + expect(rules('')).toEqual(['xml.no-root']) + }) + + it('flags a document cut mid-file as truncated', () => { + const xml = readFixture('fabriksgatan.xodr') + const cut = xml.slice(0, Math.floor(xml.length * 0.6)) + expect(rules(cut)).toContain('xml.truncated') + expect(checkXmlIntegrity(cut).fatal).toBe(true) + }) + + it('flags a document cut inside a tag', () => { + expect(rules(' { + expect(rules('\n' + + '
]]>
\n' + + ' \n' + + '
' + expect(rules(xml)).toEqual([]) + }) + + it('accepts self-closing elements', () => { + expect(rules('')).toEqual([]) + }) + + it('short-circuits later layers when the document is truncated', () => { + // A truncated document must not spray dangling-reference findings that are + // artefacts of the missing bytes: exactly one finding, and it is the cause. + const xml = readFixture('fabriksgatan.xodr') + const report = validateOpenDrive(xml.slice(0, Math.floor(xml.length * 0.6))) + expect(report.findings.map(f => f.rule)).toEqual(['xml.truncated']) + expect(report.verdict).toBe('red') + }) +}) diff --git a/packages/drawtonomy-sdk/src/validator/layers/xmlIntegrity.ts b/packages/drawtonomy-sdk/src/validator/layers/xmlIntegrity.ts index 6f40a19..1b1188c 100644 --- a/packages/drawtonomy-sdk/src/validator/layers/xmlIntegrity.ts +++ b/packages/drawtonomy-sdk/src/validator/layers/xmlIntegrity.ts @@ -1,15 +1,204 @@ // Layer 1: document integrity (`xml.*`). // -// Filled in by the next commit. +// The importer's XML front end recovers what it can from a damaged document: +// a file cut in half parses into however many elements survived the cut +// and reports nothing. That is the single most dangerous silent failure mode in +// the pipeline, because the result looks like a small but valid map. +// +// This layer answers one question before anything else runs: is this the whole +// document? It uses a small tokenizer rather than a parser — the SDK has zero +// runtime dependencies, and matching open tags against a stack is all that is +// needed to distinguish "truncated" from "complete". import type { OdrFinding } from '../types.js' export interface XmlIntegrityResult { findings: OdrFinding[] - /** True when the damage makes parsing pointless (short-circuits later layers). */ + /** + * True when the damage makes the later layers meaningless. A truncated or + * unbalanced document would otherwise generate a cascade of dangling- + * reference findings that are all artefacts of the missing bytes. + */ fatal: boolean } -export function checkXmlIntegrity(_xml: string): XmlIntegrityResult { - return { findings: [], fatal: false } +/** One token of interest from the scan. */ +interface TagToken { + name: string + kind: 'open' | 'close' | 'self' + /** Byte offset of the '<'. */ + offset: number +} + +/** + * Scan the document for element tags, skipping the regions where `<` and `>` + * are not markup: comments, CDATA sections, processing instructions and + * DOCTYPE declarations. Returns null when the scan hits an unterminated + * construct (an unclosed comment/CDATA is itself a truncation symptom). + */ +function scanTags(xml: string): { tokens: TagToken[]; unterminated: string | null } { + const tokens: TagToken[] = [] + let i = 0 + const n = xml.length + + while (i < n) { + const lt = xml.indexOf('<', i) + if (lt < 0) break + + // Non-element constructs. + if (xml.startsWith('', lt + 4) + if (end < 0) return { tokens, unterminated: 'comment' } + i = end + 3 + continue + } + if (xml.startsWith('', lt + 9) + if (end < 0) return { tokens, unterminated: 'CDATA section' } + i = end + 3 + continue + } + if (xml.startsWith('', lt + 2) + if (end < 0) return { tokens, unterminated: 'processing instruction' } + i = end + 2 + continue + } + if (xml.startsWith('' (internal + // subsets are not used by OpenDRIVE documents in practice). + const end = xml.indexOf('>', lt + 2) + if (end < 0) return { tokens, unterminated: 'declaration' } + i = end + 1 + continue + } + + // An element tag. Find its '>', respecting quoted attribute values so that + // a '>' inside an attribute does not end the tag early. + let j = lt + 1 + let quote: string | null = null + let gt = -1 + while (j < n) { + const ch = xml[j] + if (quote) { + if (ch === quote) quote = null + } else if (ch === '"' || ch === "'") { + quote = ch + } else if (ch === '>') { + gt = j + break + } + j++ + } + if (gt < 0) return { tokens, unterminated: 'element tag' } + + const raw = xml.slice(lt + 1, gt) + const isClose = raw.startsWith('/') + const isSelf = raw.endsWith('/') + const nameMatch = (isClose ? raw.slice(1) : raw).match(/^\s*([A-Za-z_][\w.\-:]*)/) + if (nameMatch) { + tokens.push({ + name: nameMatch[1], + kind: isClose ? 'close' : isSelf ? 'self' : 'open', + offset: lt, + }) + } + i = gt + 1 + } + + return { tokens, unterminated: null } +} + +/** Report the line number (1-based) of a byte offset, for locating damage. */ +function lineOf(xml: string, offset: number): number { + let line = 1 + for (let i = 0; i < offset && i < xml.length; i++) { + if (xml[i] === '\n') line++ + } + return line +} + +export function checkXmlIntegrity(xml: string): XmlIntegrityResult { + const findings: OdrFinding[] = [] + + if (xml.trim().length === 0) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'xml.empty', + message: 'document is empty', + }) + return { findings, fatal: true } + } + + const { tokens, unterminated } = scanTags(xml) + + if (unterminated !== null) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'xml.truncated', + message: `document ends inside an unterminated ${unterminated} — it is incomplete`, + }) + return { findings, fatal: true } + } + + if (!tokens.some(t => t.name === 'OpenDRIVE' && t.kind === 'open')) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'xml.no-root', + message: 'no root element found', + }) + return { findings, fatal: true } + } + + // Stack match. The first mismatch is reported and the scan stops: after a + // structural break the remaining stack states are noise. + const stack: TagToken[] = [] + for (const t of tokens) { + if (t.kind === 'self') continue + if (t.kind === 'open') { + stack.push(t) + continue + } + const top = stack.pop() + if (!top) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'xml.unbalanced-tags', + message: `stray closing tag at line ${lineOf(xml, t.offset)} with no matching open tag`, + }) + return { findings, fatal: true } + } + if (top.name !== t.name) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'xml.unbalanced-tags', + message: `closing tag at line ${lineOf(xml, t.offset)} does not match open <${top.name}> at line ${lineOf(xml, top.offset)}`, + }) + return { findings, fatal: true } + } + } + + if (stack.length > 0) { + // Unclosed elements remaining at EOF: the document stops mid-structure. + // Name the outermost unclosed element — the innermost is usually a + // consequence, the outermost tells you how much is missing. + const outermost = stack[0] + const names = stack.map(t => `<${t.name}>`).join(' > ') + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'xml.truncated', + message: + `document ends with ${stack.length} unclosed element(s) (${names}); ` + + `<${outermost.name}> opened at line ${lineOf(xml, outermost.offset)} is never closed`, + }) + return { findings, fatal: true } + } + + return { findings, fatal: false } } From 15b95271798d989d1a9cf37a3d5fdab6590d80f3 Mon Sep 17 00:00:00 2001 From: kosuke55 Date: Sat, 22 Aug 2026 01:11:34 +0900 Subject: [PATCH 3/6] feat(validator): layer 2 reference integrity Resolves every cross-reference explicitly instead of best-effort: road links, junction connection roads, lane links, controller signals and signal references. The importer drops what it cannot resolve, so a document can lose whole connections without complaint. Lane links are resolved against the right target. Within a road a lane's successor names a lane of the *next* lane section; only the outermost links cross into the linked road, at the contact point the link declares (defaulting per ASAM OpenDRIVE 1.8 section 9.2). Links into a junction are resolved through the junction's laneLinks and are not checkable here. Two rules are warnings rather than errors, on evidence: a road pointing at a junction the document does not define is the signature of a legitimately excerpted map, not a broken pointer. The town04-junction106 fixture is one such excerpt -- four boundary roads reference junctions left outside the slice -- and it now lands yellow instead of red. A link to a missing *road* stays an error, since a road is a leaf rather than a cut point. Detects mutations: dangling-road-successor -> ref.dangling-road-link, dangling-connection-incoming -> ref.dangling-connection-road, drop-lane-link -> ref.dangling-lane-link, orphan-controller-signal -> ref.dangling-controller-signal. False-positive gate: 0 MAP_DEFECT errors across 6 fixtures + 26 esmini maps. --- .../__tests__/validator/odrValidator.test.ts | 2 +- .../__tests__/validator/references.test.ts | 162 +++++++++++ .../src/validator/layers/references.ts | 265 +++++++++++++++++- 3 files changed, 424 insertions(+), 5 deletions(-) create mode 100644 packages/drawtonomy-sdk/__tests__/validator/references.test.ts diff --git a/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts b/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts index 45f9a4c..b4da3a3 100644 --- a/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts +++ b/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts @@ -95,7 +95,7 @@ describe('mutation harness self-check', () => { * layer lands, so a detection the code cannot yet make is a `todo` rather than * a failure — while the mutation itself is still proven to apply above. */ -const IMPLEMENTED_LAYERS: readonly string[] = ['xml.'] +const IMPLEMENTED_LAYERS: readonly string[] = ['xml.', 'ref.'] const isImplemented = (rule: string): boolean => IMPLEMENTED_LAYERS.some(prefix => rule.startsWith(prefix)) diff --git a/packages/drawtonomy-sdk/__tests__/validator/references.test.ts b/packages/drawtonomy-sdk/__tests__/validator/references.test.ts new file mode 100644 index 0000000..b695ee6 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/references.test.ts @@ -0,0 +1,162 @@ +// Layer 2 unit tests: reference integrity. + +import { describe, it, expect } from 'vitest' +import { validateOpenDrive } from '../../src/validator/index' +import type { OdrFinding } from '../../src/validator/index' +import { readFixture } from './corpus' + +const findingsFor = (xml: string): OdrFinding[] => validateOpenDrive(xml).findings +const rulesFor = (xml: string): string[] => findingsFor(xml).map(f => f.rule) + +/** Two straight roads linked end-to-start, with one lane each side. */ +const lanes = (predId?: number, succId?: number): string => ` + + + + ${predId !== undefined ? `` : ''}${succId !== undefined ? `` : ''} + + +
+ + ${predId !== undefined ? `` : ''}${succId !== undefined ? `` : ''} + + +
+
` + +const PAIR = ` + +
+ + + + ${lanes(undefined, 1)} + + + + + ${lanes(1, undefined)} + +` + +describe('checkReferences', () => { + it('accepts a well-formed pair of linked roads', () => { + expect(rulesFor(PAIR)).toEqual([]) + }) + + it('flags a road link to a nonexistent road', () => { + const xml = PAIR.replace('elementType="road" elementId="2"', 'elementType="road" elementId="99"') + expect(rulesFor(xml)).toContain('ref.dangling-road-link') + expect(validateOpenDrive(xml).verdict).toBe('red') + }) + + it('flags a lane link to a lane the target road does not have', () => { + const xml = PAIR.replace('', '') + const found = findingsFor(xml).filter(f => f.rule === 'ref.dangling-lane-link') + expect(found).toHaveLength(1) + expect(found[0].message).toContain('lane 7') + expect(found[0].location?.roadId).toBe('1') + }) + + it('flags a duplicate road id', () => { + const xml = PAIR.replace('id="2" junction="-1"', 'id="1" junction="-1"') + expect(rulesFor(xml)).toContain('ref.duplicate-road-id') + }) + + it('flags a junction connection referring to a missing road', () => { + const xml = PAIR.replace( + '', + ` + + + + ` + ) + const found = findingsFor(xml).filter(f => f.rule === 'ref.dangling-connection-road') + expect(found).toHaveLength(1) + expect(found[0].message).toContain('incomingRoad="404"') + }) + + it('flags a controller governing an undefined signal', () => { + const xml = PAIR.replace( + '', + `` + ) + expect(rulesFor(xml)).toContain('ref.dangling-controller-signal') + }) + + it('resolves a controller signal defined on any road', () => { + const xml = PAIR.replace( + '', + `` + ) + expect(rulesFor(xml)).toEqual([]) + }) + + describe('excerpted maps', () => { + // Cutting one junction out of a city map leaves boundary roads pointing at + // junctions that were not carried along. That is a property of the excerpt, + // not a defect of the map, so it is a warning: worth surfacing, never red. + it('warns rather than errors on a link to an absent junction', () => { + const xml = PAIR.replace( + 'elementType="road" elementId="2" contactPoint="start"', + 'elementType="junction" elementId="77"' + ) + const found = findingsFor(xml).filter(f => f.rule === 'ref.unresolved-junction-link') + expect(found).toHaveLength(1) + expect(found[0].severity).toBe('warning') + expect(validateOpenDrive(xml).verdict).toBe('yellow') + }) + + it('keeps the Town04 slice fixture out of the red', () => { + const report = validateOpenDrive(readFixture('town04-junction106.xodr')) + expect(report.verdict).toBe('yellow') + expect(report.findings.every(f => f.rule === 'ref.unresolved-junction-link')).toBe(true) + expect(report.counts.error).toBe(0) + }) + }) + + describe('multi-section lane links', () => { + // Within a road, a lane's successor names a lane of the *next* lane + // section, not of the linked road. Getting this wrong would redden every + // multi-section map in existence. + const twoSections = ` + +
+ + + + +
+ + + + +
+ +
+ + + + + + + +
+
+
+` + + it('resolves a lane successor against the next lane section', () => { + expect(rulesFor(twoSections)).toEqual([]) + }) + + it('flags a lane successor absent from the next lane section', () => { + const xml = twoSections.replace('', '') + expect(rulesFor(xml)).toContain('ref.dangling-lane-link') + }) + }) +}) diff --git a/packages/drawtonomy-sdk/src/validator/layers/references.ts b/packages/drawtonomy-sdk/src/validator/layers/references.ts index 23804d9..0884cb5 100644 --- a/packages/drawtonomy-sdk/src/validator/layers/references.ts +++ b/packages/drawtonomy-sdk/src/validator/layers/references.ts @@ -1,10 +1,267 @@ // Layer 2: reference integrity (`ref.*`). // -// Filled in by a later commit. +// OpenDRIVE is a graph encoded as a flat list of elements wired together by +// id. The importer resolves those ids best-effort and drops what it cannot +// find, which means a document can lose whole connections without complaint. +// This layer resolves every cross-reference explicitly and reports the ones +// that point at nothing. +// +// Checked here: +// ref.dangling-road-link target +// ref.dangling-connection-road +// ref.dangling-lane-link target lane id +// ref.dangling-controller-signal +// ref.duplicate-road-id / ref.duplicate-junction-id +// ref.road-junction-unknown road@junction naming no +// +// The complementary direction — a junction that does not list a road claiming +// membership — is layer 3, since it is about junction structure rather than a +// broken pointer. -import type { OdrMap } from '../../exporter/opendriveParser.js' +import type { + OdrLaneSection, + OdrMap, + OdrRoad, + OdrRoadLink, +} from '../../exporter/opendriveParser.js' import type { OdrFinding } from '../types.js' -export function checkReferences(_map: OdrMap): OdrFinding[] { - return [] +/** All lane ids present in a lane section (both sides plus centre). */ +function laneIdsOf(section: OdrLaneSection): Set { + const ids = new Set() + for (const lane of [...section.left, ...section.center, ...section.right]) ids.add(lane.id) + return ids +} + +/** + * The lane section a road-level link lands on. `predecessor` links attach to + * the *first* lane section of the target when the contact point is its start + * and to the last when it is its end (and vice versa for `successor`). + */ +function contactSection(road: OdrRoad, contact: 'start' | 'end'): OdrLaneSection | null { + if (road.laneSections.length === 0) return null + return contact === 'start' ? road.laneSections[0] : road.laneSections[road.laneSections.length - 1] +} + +export function checkReferences(map: OdrMap): OdrFinding[] { + const findings: OdrFinding[] = [] + + const roadById = new Map() + for (const road of map.roads) { + if (roadById.has(road.id)) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.duplicate-road-id', + message: `road id "${road.id}" is used by more than one `, + location: { roadId: road.id }, + }) + continue + } + roadById.set(road.id, road) + } + + const junctionIds = new Set() + for (const junction of map.junctions) { + if (junctionIds.has(junction.id)) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.duplicate-junction-id', + message: `junction id "${junction.id}" is used by more than one `, + location: { junctionId: junction.id }, + }) + continue + } + junctionIds.add(junction.id) + } + + // Every signal id defined anywhere in the document (controllers may govern + // signals on any road). + const signalIds = new Set() + for (const road of map.roads) { + for (const signal of road.signals) signalIds.add(signal.id) + } + + // --- road targets ------------------------------------------------- + const checkRoadLink = (road: OdrRoad, link: OdrRoadLink | undefined, which: 'predecessor' | 'successor'): void => { + if (!link) return + if (link.elementId === '') { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.dangling-road-link', + message: `road ${road.id} <${which}> has an empty elementId`, + location: { roadId: road.id }, + }) + return + } + if (link.elementType === 'road') { + if (!roadById.has(link.elementId)) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.dangling-road-link', + message: `road ${road.id} <${which}> points at road "${link.elementId}", which does not exist`, + location: { roadId: road.id }, + }) + } + return + } + if (!junctionIds.has(link.elementId)) { + // A road linking to an absent *junction* is reported as a warning, not + // an error, because it is the signature of a legitimately excerpted map: + // cutting one junction out of a city map leaves its boundary roads + // pointing at the junctions that were left behind. (The checked-in + // town04-junction106 fixture is exactly this — a Town04 slice whose four + // boundary roads reference junctions 252/281/741/773 outside the slice.) + // A link to an absent *road* stays an error: nothing legitimate produces + // one, since a road is a leaf, not a cut point. + findings.push({ + severity: 'warning', + category: 'MAP_DEFECT', + rule: 'ref.unresolved-junction-link', + message: `road ${road.id} <${which}> points at junction "${link.elementId}", which this document does not define (expected if the map is an excerpt)`, + location: { roadId: road.id, junctionId: link.elementId }, + }) + } + } + + for (const road of map.roads) { + checkRoadLink(road, road.predecessor, 'predecessor') + checkRoadLink(road, road.successor, 'successor') + + // road@junction must name a junction that exists ("-1" = not in a junction). + // Same excerpt caveat as the junction link above: warning, not error. + if (road.junction !== '' && road.junction !== '-1' && !junctionIds.has(road.junction)) { + findings.push({ + severity: 'warning', + category: 'MAP_DEFECT', + rule: 'ref.road-junction-unknown', + message: `road ${road.id} declares junction="${road.junction}", but this document defines no such `, + location: { roadId: road.id, junctionId: road.junction }, + }) + } + } + + // --- junction connection road references --------------------------------- + for (const junction of map.junctions) { + for (const conn of junction.connections) { + for (const [attr, value] of [ + ['incomingRoad', conn.incomingRoad], + ['connectingRoad', conn.connectingRoad], + ] as const) { + if (value === '') { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.dangling-connection-road', + message: `junction ${junction.id} connection ${conn.id} has no ${attr}`, + location: { junctionId: junction.id }, + }) + continue + } + if (!roadById.has(value)) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.dangling-connection-road', + message: `junction ${junction.id} connection ${conn.id} ${attr}="${value}" refers to a road that does not exist`, + location: { junctionId: junction.id, roadId: value }, + }) + } + } + } + } + + // --- lane targets ------------------------------------------------- + // + // Within a road, a lane's successor names a lane of the *next* lane section + // and its predecessor a lane of the previous one. Only the outermost links + // (successor of the last section, predecessor of the first) cross into the + // linked road, and then only when that link is road-typed: links into a + // junction are resolved through the junction's laneLinks instead, so a lane + // id that does not exist in the incoming road is not checkable here. + for (const road of map.roads) { + const sections = road.laneSections + for (let i = 0; i < sections.length; i++) { + const section = sections[i] + const lanes = [...section.left, ...section.center, ...section.right] + + const targetIds = ( + side: 'predecessor' | 'successor' + ): { ids: Set; where: string } | null => { + const inner = side === 'predecessor' ? i - 1 : i + 1 + if (inner >= 0 && inner < sections.length) { + return { + ids: laneIdsOf(sections[inner]), + where: `lane section ${inner} of road ${road.id}`, + } + } + const link = side === 'predecessor' ? road.predecessor : road.successor + if (!link || link.elementType !== 'road') return null + const target = roadById.get(link.elementId) + if (!target) return null // already reported as a dangling road link + // Default contact point for a successor is the target's start, for a + // predecessor its end (ASAM OpenDRIVE 1.8 §9.2). + const contact = link.contactPoint ?? (side === 'successor' ? 'start' : 'end') + const targetSection = contactSection(target, contact) + if (!targetSection) return null + return { + ids: laneIdsOf(targetSection), + where: `road ${target.id} at its ${contact}`, + } + } + + for (const side of ['predecessor', 'successor'] as const) { + const target = targetIds(side) + if (!target) continue + for (const lane of lanes) { + const linked = side === 'predecessor' ? lane.predecessorIds : lane.successorIds + for (const id of linked) { + if (!target.ids.has(id)) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.dangling-lane-link', + message: `road ${road.id} lane section ${i} lane ${lane.id} <${side}> names lane ${id}, which does not exist in ${target.where}`, + location: { roadId: road.id, laneId: lane.id, s: section.s }, + }) + } + } + } + } + } + } + + // --- controller signal references ---------------------------------------- + for (const controller of map.controllers) { + for (const control of controller.controls) { + if (!signalIds.has(control.signalId)) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.dangling-controller-signal', + message: `controller ${controller.id} governs signal "${control.signalId}", which no road defines`, + }) + } + } + } + + // --- signalReference targets --------------------------------------------- + for (const road of map.roads) { + for (const ref of road.signalReferences) { + if (!signalIds.has(ref.id)) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'ref.dangling-signal-reference', + message: `road ${road.id} refers to a signal that no road defines`, + location: { roadId: road.id, s: ref.s }, + }) + } + } + } + + return findings } From e5fdadcb86ceeed314fab30d296ebad190fe2ecf Mon Sep 17 00:00:00 2001 From: kosuke55 Date: Sat, 22 Aug 2026 01:14:46 +0900 Subject: [PATCH 4/6] feat(validator): layer 3 junction consistency A junction is described twice -- by its records and by the roads declaring junction="" -- and the two descriptions must agree. The importer reads one side per question, so deleting a junction's connections yields a set of unrelated roads and no complaint. The same broken pair is visible from either side, so exactly one finding is emitted per road, with the rule chosen by evidence rather than viewpoint: a road still wired into the junction's roads was a genuine member whose connection record went missing (junction.connection-missing), while a road linking to nothing the junction knows about has a spurious membership claim (junction.road-not-member). Emitting both would double every junction count in the report. Direct junctions () have no separate connecting road, so membership and contact-point rules are skipped for them; the soderleden fixture pins this. Detects mutations: drop-junction-connection -> junction.connection-missing, road-junction-attr-without-membership -> junction.road-not-member. False-positive gate: 0 MAP_DEFECT errors across 6 fixtures + 26 esmini maps. --- .../__tests__/validator/junctions.test.ts | 150 +++++++++++++ .../__tests__/validator/mutations.ts | 8 + .../__tests__/validator/odrValidator.test.ts | 2 +- .../src/validator/layers/junctions.ts | 199 +++++++++++++++++- 4 files changed, 354 insertions(+), 5 deletions(-) create mode 100644 packages/drawtonomy-sdk/__tests__/validator/junctions.test.ts diff --git a/packages/drawtonomy-sdk/__tests__/validator/junctions.test.ts b/packages/drawtonomy-sdk/__tests__/validator/junctions.test.ts new file mode 100644 index 0000000..0664d62 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/junctions.test.ts @@ -0,0 +1,150 @@ +// Layer 3 unit tests: junction consistency. + +import { describe, it, expect } from 'vitest' +import { validateOpenDrive } from '../../src/validator/index' +import type { OdrFinding } from '../../src/validator/index' +import { readFixture } from './corpus' + +const findingsFor = (xml: string): OdrFinding[] => validateOpenDrive(xml).findings +const rulesFor = (xml: string): string[] => findingsFor(xml).map(f => f.rule) + +const lane = (id: number): string => + `${id === 0 ? '' : ''}` + +const laneBlock = ` + + ${lane(1)}
${lane(0)}
${lane(-1)} +
` + +/** + * Two mainlines meeting at junction 5 through connecting road 3. + * Road 1 -> (junction 5, via connecting road 3) -> road 2. + */ +const JUNCTION = ` + +
+ + + + ${laneBlock} + + + + + ${laneBlock} + + + + + + + + ${laneBlock} + + + + + + + + + +` + +describe('checkJunctions', () => { + it('accepts a well-formed junction', () => { + expect(rulesFor(JUNCTION)).toEqual([]) + }) + + it('flags a junction with no connections', () => { + const xml = JUNCTION.replace(//g, '') + expect(rulesFor(xml)).toContain('junction.empty') + }) + + it('reports a deleted connection once, as connection-missing', () => { + // Add a second connecting road (4) so the junction keeps a connection + // after road 3's is deleted, then delete road 3's connections. Road 3 + // still declares junction="5" and still links to roads 1 and 2, so the + // lost half is the connection record, not the membership claim. + const withSecond = JUNCTION.replace( + '', + ` + + ` + ).replace( + '', + ` + + + + + + ${laneBlock} + ` + ) + expect(rulesFor(withSecond)).toEqual([]) + + const stripped = withSecond + .replace(//, '') + .replace(//, '') + const found = findingsFor(stripped).filter(f => f.rule.startsWith('junction.')) + const missing = found.filter(f => f.rule === 'junction.connection-missing') + expect(missing).toHaveLength(1) + expect(missing[0].location?.roadId).toBe('3') + // The mirror rule must NOT also fire: one defect, one finding. + expect(found.filter(f => f.rule === 'junction.road-not-member')).toHaveLength(0) + }) + + it('reports a spurious membership claim as road-not-member', () => { + // A road that names the junction but links to none of its roads. + const xml = JUNCTION.replace( + '', + ` + + ${laneBlock} + ` + ) + const found = findingsFor(xml).filter(f => f.rule.startsWith('junction.')) + expect(found.map(f => f.rule)).toEqual(['junction.road-not-member']) + expect(found[0].location?.roadId).toBe('9') + }) + + it('flags a connecting road that does not declare its junction', () => { + const xml = JUNCTION.replace('id="3" junction="5"', 'id="3" junction="-1"') + expect(rulesFor(xml)).toContain('junction.connecting-road-unmarked') + }) + + it('flags an incoming road that never links to the junction', () => { + const xml = JUNCTION.replace('', '') + expect(rulesFor(xml)).toContain('junction.incoming-link-missing') + }) + + it('flags a contactPoint that disagrees with the connecting road link', () => { + // Connection 0 says the connecting road meets road 1 at its start, so road + // 3's predecessor must be road 1. Point it elsewhere. + const xml = JUNCTION.replace( + '', + '' + ) + expect(rulesFor(xml)).toContain('junction.contact-point-mismatch') + }) + + describe('direct junctions', () => { + // has no connecting road: linkedRoad names an + // ordinary road that does not carry a junction attribute. The membership + // and contact-point rules must not fire on it. + it('accepts the soderleden direct-junction fixture', () => { + const report = validateOpenDrive(readFixture('soderleden.xodr')) + expect(report.findings.filter(f => f.rule.startsWith('junction.'))).toEqual([]) + }) + }) + + it('does not double-report a junction that layer 2 already flagged as missing', () => { + // road@junction naming an absent junction is ref.road-junction-unknown; + // layer 3 must stay quiet about it. + const xml = JUNCTION.replace('id="3" junction="5"', 'id="3" junction="404"') + const rules = rulesFor(xml) + expect(rules).toContain('ref.road-junction-unknown') + expect(rules).not.toContain('junction.road-not-member') + }) +}) diff --git a/packages/drawtonomy-sdk/__tests__/validator/mutations.ts b/packages/drawtonomy-sdk/__tests__/validator/mutations.ts index b69dcad..a7e23a6 100644 --- a/packages/drawtonomy-sdk/__tests__/validator/mutations.ts +++ b/packages/drawtonomy-sdk/__tests__/validator/mutations.ts @@ -447,6 +447,14 @@ export const roadJunctionAttrWithoutMembership: Mutation = { const id = attrOf(road.text, 'id') if (id === null || members.has(id)) continue if (attrOf(road.text, 'junction') === junctionId) continue + // Require a road that links to none of the junction's roads, so the + // defect is unambiguously a spurious membership claim rather than a + // lost record. The validator distinguishes the two by + // exactly this evidence, so the mutation must pin down which it is. + const linkTargets = (road.text.match(/<(?:predecessor|successor)\b[^>]*>/g) ?? []).map( + t => attrOf(t, 'elementId') ?? '' + ) + if (linkTargets.some(t => members.has(t) || t === junctionId)) continue const openEnd = road.text.indexOf('>') const mutatedOpen = withAttr(road.text.slice(0, openEnd + 1), 'junction', junctionId) const newRoad = mutatedOpen + road.text.slice(openEnd + 1) diff --git a/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts b/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts index b4da3a3..d7db7a4 100644 --- a/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts +++ b/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts @@ -95,7 +95,7 @@ describe('mutation harness self-check', () => { * layer lands, so a detection the code cannot yet make is a `todo` rather than * a failure — while the mutation itself is still proven to apply above. */ -const IMPLEMENTED_LAYERS: readonly string[] = ['xml.', 'ref.'] +const IMPLEMENTED_LAYERS: readonly string[] = ['xml.', 'ref.', 'junction.'] const isImplemented = (rule: string): boolean => IMPLEMENTED_LAYERS.some(prefix => rule.startsWith(prefix)) diff --git a/packages/drawtonomy-sdk/src/validator/layers/junctions.ts b/packages/drawtonomy-sdk/src/validator/layers/junctions.ts index de6d6ae..ad790d4 100644 --- a/packages/drawtonomy-sdk/src/validator/layers/junctions.ts +++ b/packages/drawtonomy-sdk/src/validator/layers/junctions.ts @@ -1,10 +1,201 @@ // Layer 3: junction consistency (`junction.*`). // -// Filled in by a later commit. +// A junction is described twice over — once by the element listing +// its connections, and once by the roads that declare `junction=""` — and +// the two descriptions have to agree. The importer reads only one side of that +// pair per question, so a junction whose records are deleted +// imports as a set of unrelated roads with no complaint at all. +// +// Checked here: +// junction.connection-missing a road claims junction membership but no +// names it +// junction.road-not-member the same defect seen from the junction: the +// road is not reachable from any connection +// junction.empty a with no connections at all +// junction.connecting-road-unmarked a connecting road that forgot its +// junction attribute +// junction.contact-point-mismatch contactPoint disagrees with the +// connecting road's own +// junction.incoming-link-missing the incoming road does not link back +// +// Direct junctions (``, OpenDRIVE 1.5+) are structurally +// different: they have no separate connecting road, so `linkedRoad` names an +// ordinary road that does *not* carry a junction attribute. Membership and +// contact-point rules that assume a connecting road are skipped for them. -import type { OdrMap } from '../../exporter/opendriveParser.js' +import type { OdrJunction, OdrMap, OdrRoad } from '../../exporter/opendriveParser.js' import type { OdrFinding } from '../types.js' -export function checkJunctions(_map: OdrMap): OdrFinding[] { - return [] +const isDirect = (junction: OdrJunction): boolean => junction.type === 'direct' + +export function checkJunctions(map: OdrMap): OdrFinding[] { + const findings: OdrFinding[] = [] + + const roadById = new Map() + for (const road of map.roads) { + if (!roadById.has(road.id)) roadById.set(road.id, road) + } + const junctionById = new Map() + for (const junction of map.junctions) { + if (!junctionById.has(junction.id)) junctionById.set(junction.id, junction) + } + + // Which roads each junction reaches through its connections, split by role. + const connectingRoadsOf = new Map>() + const incomingRoadsOf = new Map>() + for (const junction of map.junctions) { + const connecting = new Set() + const incoming = new Set() + for (const conn of junction.connections) { + if (conn.connectingRoad !== '') connecting.add(conn.connectingRoad) + if (conn.incomingRoad !== '') incoming.add(conn.incomingRoad) + } + connectingRoadsOf.set(junction.id, connecting) + incomingRoadsOf.set(junction.id, incoming) + } + + // --- empty junctions ----------------------------------------------------- + for (const junction of map.junctions) { + if (junction.connections.length === 0) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'junction.empty', + message: `junction ${junction.id} declares no ; nothing can route through it`, + location: { junctionId: junction.id }, + }) + } + } + + // --- roads claiming membership ------------------------------------------- + // + // A road with `junction=""` is by definition a connecting road of that + // junction and must be named by one of its connections. This is the check + // that catches a deleted : the road survives, its claim of + // membership survives, and only the connection that made it real is gone. + // + // The same broken pair can be read from either side, so exactly one finding + // is emitted per road, choosing the rule by what the rest of the document + // says. If the road is wired into the junction's traffic — its own s + // reach roads the junction connects — then the road was a genuine member and + // the connection record is what went missing. If it is wired to nothing the + // junction knows about, the membership claim itself is the wrong part. + // Emitting both rules for one defect would double every junction count in + // the report, which is why this is a branch and not two loops. + for (const road of map.roads) { + if (road.junction === '' || road.junction === '-1') continue + const junction = junctionById.get(road.junction) + // A junction that does not exist at all is layer 2's finding + // (ref.road-junction-unknown); do not report it twice. + if (!junction) continue + if (isDirect(junction)) continue + if (connectingRoadsOf.get(junction.id)?.has(road.id)) continue + + const junctionRoads = new Set([ + ...(connectingRoadsOf.get(junction.id) ?? []), + ...(incomingRoadsOf.get(junction.id) ?? []), + ]) + const wiredIntoJunction = [road.predecessor, road.successor].some( + l => + l !== undefined && + ((l.elementType === 'junction' && l.elementId === junction.id) || + (l.elementType === 'road' && junctionRoads.has(l.elementId))) + ) + + if (wiredIntoJunction) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'junction.connection-missing', + message: `junction ${junction.id} has no for road ${road.id}, which belongs to it and links into its roads — the connection record is missing`, + location: { junctionId: junction.id, roadId: road.id }, + }) + } else { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'junction.road-not-member', + message: `road ${road.id} declares junction="${junction.id}", but no of that junction names it and it links to none of the junction's roads`, + location: { roadId: road.id, junctionId: junction.id }, + }) + } + } + + // --- connecting roads that forgot to declare membership ------------------ + for (const junction of map.junctions) { + if (isDirect(junction)) continue + for (const roadId of connectingRoadsOf.get(junction.id) ?? []) { + const road = roadById.get(roadId) + if (!road) continue // layer 2 reported it as a dangling connection road + if (road.junction === junction.id) continue + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'junction.connecting-road-unmarked', + message: `road ${roadId} is a connectingRoad of junction ${junction.id} but declares junction="${road.junction}"`, + location: { roadId, junctionId: junction.id }, + }) + } + } + + // --- per-connection consistency ------------------------------------------ + for (const junction of map.junctions) { + for (const conn of junction.connections) { + const connecting = roadById.get(conn.connectingRoad) + const incoming = roadById.get(conn.incomingRoad) + if (!connecting || !incoming) continue // dangling: layer 2's finding + + // The incoming road must link into this junction from one of its ends. + // Without that link the connection is one-directional and a router + // entering from the incoming road never discovers the junction. + const linksHere = [incoming.predecessor, incoming.successor].some( + l => l?.elementType === 'junction' && l.elementId === junction.id + ) + // A direct junction's incoming road may instead link straight to the + // linked road, which is the whole point of the construct. + const linksToConnecting = [incoming.predecessor, incoming.successor].some( + l => l?.elementType === 'road' && l.elementId === conn.connectingRoad + ) + if (!linksHere && !(isDirect(junction) && linksToConnecting)) { + findings.push({ + severity: 'warning', + category: 'MAP_DEFECT', + rule: 'junction.incoming-link-missing', + message: `junction ${junction.id} connection ${conn.id}: incoming road ${conn.incomingRoad} has no pointing at this junction`, + location: { junctionId: junction.id, roadId: conn.incomingRoad }, + }) + } + + if (isDirect(junction)) continue + + // contactPoint names the end of the connecting road that meets the + // incoming road, so the connecting road's link at that end must be the + // incoming road (directly, or via this junction). + const endLink = conn.contactPoint === 'start' ? connecting.predecessor : connecting.successor + if (!endLink) { + findings.push({ + severity: 'warning', + category: 'MAP_DEFECT', + rule: 'junction.contact-point-mismatch', + message: `junction ${junction.id} connection ${conn.id}: connecting road ${conn.connectingRoad} has no <${conn.contactPoint === 'start' ? 'predecessor' : 'successor'}> at its declared contactPoint="${conn.contactPoint}"`, + location: { junctionId: junction.id, roadId: conn.connectingRoad }, + }) + continue + } + const pointsAtIncoming = + (endLink.elementType === 'road' && endLink.elementId === conn.incomingRoad) || + (endLink.elementType === 'junction' && endLink.elementId === junction.id) + if (!pointsAtIncoming) { + findings.push({ + severity: 'warning', + category: 'MAP_DEFECT', + rule: 'junction.contact-point-mismatch', + message: `junction ${junction.id} connection ${conn.id}: connecting road ${conn.connectingRoad} contactPoint="${conn.contactPoint}" but its link there points at ${endLink.elementType} ${endLink.elementId}, not incoming road ${conn.incomingRoad}`, + location: { junctionId: junction.id, roadId: conn.connectingRoad }, + }) + } + } + } + + return findings } From dd52ac685a65093026fef9496ad2e1887e68328f Mon Sep 17 00:00:00 2001 From: kosuke55 Date: Sat, 22 Aug 2026 01:18:54 +0900 Subject: [PATCH 5/6] feat(validator): layer 4 geometric continuity, thresholds calibrated on corpus Reuses the exporter's evalGeometry (line/arc/spiral/paramPoly3/poly3) so the validator and exporter agree by construction about what a geometry record means. Thresholds were set by measuring the corpora (6 fixtures + 26 esmini maps: 401 geometry joints, 182 roads, 242 road links) rather than by guessing, and the measurement changed two of them. Within a road, real maps are near-exact and the defaults have wide margin: plan-view gaps reach 4.0e-4 m against a 0.02 m threshold, heading steps 2.3e-5 rad against 0.005, and road@length agrees with the plan-view sum to 4e-16 relative against 1 %. Between roads the picture is different and 0.5 m as an *error* would have reddened working maps. Two structural causes produce large link gaps: a road carrying a has its reference line shifted from its lane geometry, so roads whose lanes meet keep reference lines exactly the offset difference apart (fabriksgatan 1.75 m, multi_intersections 3.75 m, both correct); and some shipped maps carry link records their geometry does not honour (soderleden road 7 declares a predecessor 66 m from where it begins, and esmini drives the map anyway by routing through the junction). The offset difference is subtracted, which removes the first cause exactly, and the check reports a warning, which keeps the second from failing a map that works. Lane widths need a tolerance for an arithmetic rather than structural reason: merge lanes authored to close at exactly zero land a few ulps below it. All 8 negative evaluations in the corpora fall between -8.9e-16 and -1.8e-15 m, so the 1 mm default sits twelve orders of magnitude above the noise and far below any intended width. Detects mutations: geometry-gap -> geom.plan-view-gap, length-mismatch -> geom.road-length-mismatch, negative-width -> geom.negative-lane-width. Detection matrix now 10/10. False-positive gate: 0 MAP_DEFECT errors across 6 fixtures + 26 esmini maps. --- .../__tests__/validator/geometry.test.ts | 186 +++++++++++++ .../__tests__/validator/junctions.test.ts | 2 +- .../__tests__/validator/odrValidator.test.ts | 2 +- .../src/validator/layers/geometry.ts | 249 +++++++++++++++++- .../drawtonomy-sdk/src/validator/types.ts | 11 + 5 files changed, 444 insertions(+), 6 deletions(-) create mode 100644 packages/drawtonomy-sdk/__tests__/validator/geometry.test.ts diff --git a/packages/drawtonomy-sdk/__tests__/validator/geometry.test.ts b/packages/drawtonomy-sdk/__tests__/validator/geometry.test.ts new file mode 100644 index 0000000..f000f85 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/geometry.test.ts @@ -0,0 +1,186 @@ +// Layer 4 unit tests: geometric continuity, including the threshold +// calibration that keeps real maps out of the red. + +import { describe, it, expect } from 'vitest' +import { validateOpenDrive, DEFAULT_GEOMETRY_THRESHOLDS } from '../../src/validator/index' +import type { OdrFinding } from '../../src/validator/index' +import { loadFixtureCorpus } from './corpus' + +const findingsFor = (xml: string, opts = {}): OdrFinding[] => + validateOpenDrive(xml, opts).findings +const rulesFor = (xml: string, opts = {}): string[] => findingsFor(xml, opts).map(f => f.rule) +const geomRules = (xml: string, opts = {}): string[] => + rulesFor(xml, opts).filter(r => r.startsWith('geom.')) + +/** One road, two collinear line geometries meeting exactly. */ +const straight = (secondX: number, length = 100, hdg = 0): string => ` + +
+ + + + + + +
+ + + +
+
+` + +describe('checkGeometry', () => { + it('accepts a continuous road', () => { + expect(geomRules(straight(50))).toEqual([]) + }) + + describe('plan-view continuity', () => { + it('flags a position gap above the threshold', () => { + const found = findingsFor(straight(55)).filter(f => f.rule === 'geom.plan-view-gap') + expect(found).toHaveLength(1) + expect(found[0].message).toContain('5.000 m') + expect(found[0].location?.roadId).toBe('1') + }) + + it('accepts a gap below the threshold', () => { + expect(geomRules(straight(50.01))).toEqual([]) + }) + + it('flags a heading discontinuity', () => { + expect(geomRules(straight(50, 100, 0.5))).toContain('geom.plan-view-heading') + }) + + it('accepts a heading step below the threshold', () => { + expect(geomRules(straight(50, 100, 0.001))).toEqual([]) + }) + + it('honours a caller-supplied threshold', () => { + const strict = { geometry: { planViewGapMeters: 0.001 } } + expect(geomRules(straight(50.01), strict)).toContain('geom.plan-view-gap') + }) + }) + + describe('road length', () => { + it('flags a length disagreeing with the plan-view sum', () => { + const found = findingsFor(straight(50, 150)).filter( + f => f.rule === 'geom.road-length-mismatch' + ) + expect(found).toHaveLength(1) + expect(found[0].message).toContain('sum to 100.000 m') + }) + + it('accepts a length within 1 %', () => { + expect(geomRules(straight(50, 100.5))).toEqual([]) + }) + + it('flags a road with length but no plan view', () => { + const xml = straight(50).replace(/[\s\S]*?<\/planView>/, '') + expect(geomRules(xml)).toContain('geom.no-plan-view') + }) + }) + + describe('lane widths', () => { + it('flags a negative width', () => { + const xml = straight(50).replace('a="3.5"', 'a="-3.5"') + const found = findingsFor(xml).filter(f => f.rule === 'geom.negative-lane-width') + expect(found).toHaveLength(1) + expect(found[0].location?.laneId).toBe(-1) + }) + + it('flags a width ramping negative before its span ends', () => { + // 1.0 - 0.1 * ds is negative from ds = 10 on, and the span is 100 m. + const xml = straight(50).replace('a="3.5" b="0"', 'a="1.0" b="-0.1"') + expect(geomRules(xml)).toContain('geom.negative-lane-width') + }) + + it('accepts a lane that tapers to exactly zero', () => { + // The real-world case behind the tolerance: 3.5 - 0.0042*ds^2 + + // 0.000056*ds^3 reaches 0 at ds = 50 and lands ~1e-15 below it. + const xml = straight(50) + .replace('length="100"', 'length="50"') + .replace( + '', + '' + ) + .replace('a="3.5" b="0" c="0" d="0"', 'a="3.5" b="0" c="-0.0042" d="0.000056"') + expect(geomRules(xml)).toEqual([]) + }) + + it('still flags a width below the tolerance floor', () => { + const xml = straight(50).replace('a="3.5"', 'a="-0.5"') + expect(geomRules(xml)).toContain('geom.negative-lane-width') + }) + }) + + describe('road link contact', () => { + const pair = (bx: number, offsetA = 0, offsetB = 0): string => ` + +
+ + + + + + +
+ +
+
+
+ + + + + + +
+ +
+
+
+` + + it('accepts roads that touch', () => { + expect(geomRules(pair(100))).toEqual([]) + }) + + it('warns, never errors, on a separation', () => { + const found = findingsFor(pair(150)).filter(f => f.rule === 'geom.road-link-gap') + expect(found.length).toBeGreaterThan(0) + expect(found.every(f => f.severity === 'warning')).toBe(true) + // A gap between roads must not redden a map: shipped maps contain them. + expect(validateOpenDrive(pair(150)).verdict).toBe('yellow') + }) + + it('subtracts the lane-offset difference', () => { + // fabriksgatan's pattern: a connecting road with laneOffset 1.75 linking + // to a mainline with 0. The reference lines are 1.75 m apart by design. + expect(geomRules(pair(101.75, 1.75, 0))).toEqual([]) + }) + }) + + describe('false-positive calibration', () => { + it('leaves every fixture free of geometry errors', () => { + for (const entry of loadFixtureCorpus()) { + const errors = findingsFor(entry.xml).filter( + f => f.rule.startsWith('geom.') && f.severity === 'error' + ) + expect(errors, `${entry.name}: ${JSON.stringify(errors)}`).toEqual([]) + } + }) + + it('keeps the documented default thresholds', () => { + // These values are calibrated against measured corpus statistics (see the + // note in layers/geometry.ts). Changing one without re-measuring is how a + // validator starts reddening real maps, so the defaults are pinned. + expect(DEFAULT_GEOMETRY_THRESHOLDS).toEqual({ + planViewGapMeters: 0.02, + planViewHeadingRad: 0.005, + roadLinkGapMeters: 0.5, + lengthMismatchRatio: 0.01, + negativeWidthToleranceMeters: 0.001, + }) + }) + }) +}) diff --git a/packages/drawtonomy-sdk/__tests__/validator/junctions.test.ts b/packages/drawtonomy-sdk/__tests__/validator/junctions.test.ts index 0664d62..4cc27b7 100644 --- a/packages/drawtonomy-sdk/__tests__/validator/junctions.test.ts +++ b/packages/drawtonomy-sdk/__tests__/validator/junctions.test.ts @@ -78,7 +78,7 @@ describe('checkJunctions', () => { - + ${laneBlock} ` ) diff --git a/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts b/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts index d7db7a4..a4688c1 100644 --- a/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts +++ b/packages/drawtonomy-sdk/__tests__/validator/odrValidator.test.ts @@ -95,7 +95,7 @@ describe('mutation harness self-check', () => { * layer lands, so a detection the code cannot yet make is a `todo` rather than * a failure — while the mutation itself is still proven to apply above. */ -const IMPLEMENTED_LAYERS: readonly string[] = ['xml.', 'ref.', 'junction.'] +const IMPLEMENTED_LAYERS: readonly string[] = ['xml.', 'ref.', 'junction.', 'geom.'] const isImplemented = (rule: string): boolean => IMPLEMENTED_LAYERS.some(prefix => rule.startsWith(prefix)) diff --git a/packages/drawtonomy-sdk/src/validator/layers/geometry.ts b/packages/drawtonomy-sdk/src/validator/layers/geometry.ts index 164a9e3..1102977 100644 --- a/packages/drawtonomy-sdk/src/validator/layers/geometry.ts +++ b/packages/drawtonomy-sdk/src/validator/layers/geometry.ts @@ -1,10 +1,251 @@ // Layer 4: geometric continuity (`geom.*`). // -// Filled in by a later commit. +// Reference-line arithmetic, reusing the exporter's geometry evaluation +// (`evalGeometry`, which covers line / arc / spiral / paramPoly3 / poly3) so +// the validator and the exporter agree by construction about what a geometry +// record means. +// +// Checks: +// geom.plan-view-gap consecutive geometries of one road do not meet +// geom.plan-view-heading ... or meet at a heading discontinuity +// geom.road-length-mismatch road@length disagrees with the plan-view sum +// geom.negative-lane-width a record evaluates negative +// geom.road-link-gap two linked roads do not touch at their contact +// +// --------------------------------------------------------------------------- +// Threshold calibration (measured 2026-08-22 over 6 fixtures + 26 esmini maps, +// 401 geometry joints / 182 roads / 242 road links) +// --------------------------------------------------------------------------- +// +// Within one road, real maps are near-exact and the defaults have wide margin: +// +// plan-view position gap observed max 4.0e-4 m default 0.02 m (50x) +// plan-view heading step observed max 2.3e-5 rad default 0.005 rad (200x) +// road length vs sum observed max 4.0e-16 default 1 % (huge) +// +// Between roads it is a different story, and the reason is structural rather +// than a matter of precision. `road@length`-scale gaps at a link are normal: +// +// 1. A road carrying a has its reference line laterally shifted +// from the lane geometry. Two roads with different offsets meet along +// their *lanes* while their reference lines stay apart by exactly the +// offset difference. fabriksgatan's connecting roads (laneOffset a=1.75) +// linking to mainlines (a=0) produce a uniform 1.75 m; multi_intersections +// produces 3.75 m the same way. Both maps are correct. +// 2. Some shipped maps simply contain link records their geometry does not +// honour: soderleden road 7 declares its predecessor to be road 2 at that +// road's end, 66 m from where road 7 actually begins. esmini drives this +// map regardless, because it routes through the junction rather than the +// stray link. +// +// So the road-link check is reported as a *warning*: it is real evidence worth +// surfacing, but it must not redden a map that ships and works. The lane offset +// difference is subtracted before comparing, which removes cause (1) exactly +// and leaves cause (2) visible. +// +// Lane widths need a tolerance for the opposite reason — not structure, but +// arithmetic. Merge and exit lanes are authored to close at exactly zero, e.g. +// `3.5 - 0.0042*ds^2 + 0.000056*ds^3` reaching 0 at ds = 50. In IEEE-754 those +// land a few ulps below zero: all 8 negative evaluations across the two corpora +// fall between -8.9e-16 and -1.8e-15 m, in soderleden, two_plus_one and +// multi_intersections. The 1 mm default is twelve orders of magnitude above +// that noise and still far below any width a map could intend, so it separates +// the two cleanly rather than splitting a continuum. -import type { OdrMap } from '../../exporter/opendriveParser.js' +import { evalGeometry } from '../../exporter/odrGeometry.js' +import type { OdrMap, OdrRoad, OdrWidth } from '../../exporter/opendriveParser.js' import type { OdrFinding, ResolvedGeometryThresholds } from '../types.js' -export function checkGeometry(_map: OdrMap, _thresholds: ResolvedGeometryThresholds): OdrFinding[] { - return [] +/** Wrap an angle to (-pi, pi]. */ +function normalizeAngle(a: number): number { + let x = a + while (x > Math.PI) x -= 2 * Math.PI + while (x <= -Math.PI) x += 2 * Math.PI + return x +} + +/** Evaluate a cubic `a + b*ds + c*ds^2 + d*ds^3`. */ +function evalCubic(rec: { a: number; b: number; c: number; d: number }, ds: number): number { + return rec.a + rec.b * ds + rec.c * ds * ds + rec.d * ds * ds * ds +} + +/** The road's lateral lane offset at station `s` (0 when it declares none). */ +function laneOffsetAt(road: OdrRoad, s: number): number { + const records = road.laneOffsets + if (records.length === 0) return 0 + let applicable = records[0] + for (const rec of records) { + if (rec.s <= s) applicable = rec + else break + } + return evalCubic(applicable, s - applicable.s) +} + +interface Pose { + x: number + y: number + hdg: number +} + +/** Pose at a road's start or end, on its reference line. */ +function poseAtEnd(road: OdrRoad, at: 'start' | 'end'): Pose | null { + if (road.planView.length === 0) return null + if (at === 'start') { + const g = road.planView[0] + return evalGeometry(g, 0) + } + const g = road.planView[road.planView.length - 1] + return evalGeometry(g, g.length) +} + +/** Station of a road's start or end. */ +const stationAtEnd = (road: OdrRoad, at: 'start' | 'end'): number => + at === 'start' ? 0 : road.length + +export function checkGeometry( + map: OdrMap, + thresholds: ResolvedGeometryThresholds +): OdrFinding[] { + const findings: OdrFinding[] = [] + const roadById = new Map() + for (const road of map.roads) { + if (!roadById.has(road.id)) roadById.set(road.id, road) + } + + for (const road of map.roads) { + // --- plan-view continuity --------------------------------------------- + for (let i = 0; i + 1 < road.planView.length; i++) { + const current = road.planView[i] + const next = road.planView[i + 1] + const end = evalGeometry(current, current.length) + + const gap = Math.hypot(end.x - next.x, end.y - next.y) + if (gap > thresholds.planViewGapMeters) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'geom.plan-view-gap', + message: `road ${road.id}: geometry ${i} ends at (${end.x.toFixed(3)}, ${end.y.toFixed(3)}) but geometry ${i + 1} starts at (${next.x.toFixed(3)}, ${next.y.toFixed(3)}) — a gap of ${gap.toFixed(3)} m`, + location: { roadId: road.id, s: next.s }, + }) + } + + const dHdg = Math.abs(normalizeAngle(end.hdg - next.hdg)) + if (dHdg > thresholds.planViewHeadingRad) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'geom.plan-view-heading', + message: `road ${road.id}: heading jumps by ${dHdg.toFixed(5)} rad between geometry ${i} and ${i + 1}`, + location: { roadId: road.id, s: next.s }, + }) + } + } + + // --- road length vs plan-view sum -------------------------------------- + if (road.planView.length > 0 && road.length > 0) { + const sum = road.planView.reduce((acc, g) => acc + g.length, 0) + const relative = Math.abs(sum - road.length) / road.length + if (relative > thresholds.lengthMismatchRatio) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'geom.road-length-mismatch', + message: `road ${road.id}: length="${road.length}" but its plan-view geometries sum to ${sum.toFixed(3)} m (${(relative * 100).toFixed(1)} % off)`, + location: { roadId: road.id }, + }) + } + } + + if (road.planView.length === 0 && road.length > 0) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'geom.no-plan-view', + message: `road ${road.id} has length ${road.length} but no geometry`, + location: { roadId: road.id }, + }) + } + + // --- lane widths ------------------------------------------------------- + // + // A width record spans from its own sOffset to the next one (or to the end + // of the lane section). Evaluating both ends of that span catches a plain + // negative constant as well as a ramp that crosses zero, without the false + // alarms that sampling a cubic's interior would produce on records whose + // higher-order terms only matter outside their own span. + for (const [sectionIndex, section] of road.laneSections.entries()) { + const sectionEnd = + sectionIndex + 1 < road.laneSections.length + ? road.laneSections[sectionIndex + 1].s + : road.length + const sectionLength = Math.max(sectionEnd - section.s, 0) + + for (const lane of [...section.left, ...section.right]) { + const widths: OdrWidth[] = lane.widths + for (const [wi, width] of widths.entries()) { + const spanEnd = wi + 1 < widths.length ? widths[wi + 1].sOffset : sectionLength + const ds = Math.max(spanEnd - width.sOffset, 0) + const atStart = evalCubic(width, 0) + const atEnd = evalCubic(width, ds) + const worst = Math.min(atStart, atEnd) + if (worst < -thresholds.negativeWidthToleranceMeters) { + findings.push({ + severity: 'error', + category: 'MAP_DEFECT', + rule: 'geom.negative-lane-width', + message: `road ${road.id} lane section ${sectionIndex} lane ${lane.id}: record ${wi} evaluates to ${worst.toFixed(3)} m, which is negative`, + location: { roadId: road.id, laneId: lane.id, s: section.s + width.sOffset }, + }) + } + } + } + } + } + + // --- road-to-road contact ------------------------------------------------ + // + // Warning, not error: see the calibration note above. Reported once per + // ordered link, which means a mutually declared pair is reported twice — + // deliberately, since either road may be the one at fault and a reader + // filtering by road id must see it. + for (const road of map.roads) { + for (const [which, link] of [ + ['successor', road.successor], + ['predecessor', road.predecessor], + ] as const) { + if (!link || link.elementType !== 'road') continue + const target = roadById.get(link.elementId) + if (!target) continue // layer 2 reported the dangling link + + const myEnd = which === 'successor' ? 'end' : 'start' + const theirEnd = link.contactPoint ?? (which === 'successor' ? 'start' : 'end') + const mine = poseAtEnd(road, myEnd) + const theirs = poseAtEnd(target, theirEnd) + if (!mine || !theirs) continue + + // Subtract the lane-offset difference: two roads whose lanes meet can + // have reference lines apart by exactly that amount, and flagging it + // would redden fabriksgatan and multi_intersections, which are correct. + const myOffset = laneOffsetAt(road, stationAtEnd(road, myEnd)) + const theirOffset = laneOffsetAt(target, stationAtEnd(target, theirEnd)) + const offsetSlack = Math.abs(myOffset - theirOffset) + + const raw = Math.hypot(mine.x - theirs.x, mine.y - theirs.y) + const gap = Math.max(raw - offsetSlack, 0) + if (gap > thresholds.roadLinkGapMeters) { + findings.push({ + severity: 'warning', + category: 'MAP_DEFECT', + rule: 'geom.road-link-gap', + message: + `road ${road.id} <${which}> declares road ${target.id} at its ${theirEnd}, but the two reference lines are ${gap.toFixed(3)} m apart` + + (offsetSlack > 0 ? ` (after allowing ${offsetSlack.toFixed(3)} m of lane offset)` : ''), + location: { roadId: road.id }, + }) + } + } + } + + return findings } diff --git a/packages/drawtonomy-sdk/src/validator/types.ts b/packages/drawtonomy-sdk/src/validator/types.ts index 4a0d87d..0997977 100644 --- a/packages/drawtonomy-sdk/src/validator/types.ts +++ b/packages/drawtonomy-sdk/src/validator/types.ts @@ -99,6 +99,12 @@ export interface OdrGeometryThresholds { * plan-view geometry lengths. Default 0.01 (1 %). */ lengthMismatchRatio?: number + /** + * How far below zero a lane width may evaluate before it is reported (m). + * Default 0.001 — see the calibration note in layers/geometry.ts: lanes that + * taper to exactly zero land a few 1e-16 below it in floating point. + */ + negativeWidthToleranceMeters?: number } /** Options for {@link validateOpenDrive}. */ @@ -119,6 +125,7 @@ export interface ResolvedGeometryThresholds { planViewHeadingRad: number roadLinkGapMeters: number lengthMismatchRatio: number + negativeWidthToleranceMeters: number } export const DEFAULT_GEOMETRY_THRESHOLDS: ResolvedGeometryThresholds = { @@ -126,6 +133,7 @@ export const DEFAULT_GEOMETRY_THRESHOLDS: ResolvedGeometryThresholds = { planViewHeadingRad: 0.005, roadLinkGapMeters: 0.5, lengthMismatchRatio: 0.01, + negativeWidthToleranceMeters: 0.001, } export function resolveGeometryThresholds( @@ -138,6 +146,9 @@ export function resolveGeometryThresholds( roadLinkGapMeters: overrides?.roadLinkGapMeters ?? DEFAULT_GEOMETRY_THRESHOLDS.roadLinkGapMeters, lengthMismatchRatio: overrides?.lengthMismatchRatio ?? DEFAULT_GEOMETRY_THRESHOLDS.lengthMismatchRatio, + negativeWidthToleranceMeters: + overrides?.negativeWidthToleranceMeters ?? + DEFAULT_GEOMETRY_THRESHOLDS.negativeWidthToleranceMeters, } } From 25f7660db022bd7dba3410353d507e607a094b05 Mon Sep 17 00:00:00 2001 From: kosuke55 Date: Sat, 22 Aug 2026 01:22:00 +0900 Subject: [PATCH 6/6] feat(validator): esmini adapter and odr-validate CLI Layer 6 is split so the validator core stays pure: src/validator/ esminiAdapter.ts only turns captured road-manager output into findings (no fs, no child_process, unit-testable without the binary installed), while scripts/odr-validate.mts does the spawning. A missing or unrunnable binary downgrades to an info finding rather than failing the run. esmini findings are attributed TOOL_LIMITATION: the road manager disagreeing with a document is evidence about the tool/map pair, not proof the map is at fault, so it can never redden a report by itself. Repeated lines collapse with a count, since esmini repeats a complaint per lane and an unfiltered dump would bury every other layer. The CLI prints a rule histogram followed by worst-first detail rows with locations, and writes the full report with --json. Exit codes are the CI contract: 0 green, 1 yellow, 2 red, and 3 for a usage or IO error so a broken invocation is never mistaken for a clean map. --- .../__tests__/validator/cli.test.ts | 102 +++++++++ .../__tests__/validator/esminiAdapter.test.ts | 76 +++++++ .../drawtonomy-sdk/scripts/odr-validate.mts | 197 ++++++++++++++++++ .../src/validator/esminiAdapter.ts | 111 ++++++++++ .../drawtonomy-sdk/src/validator/index.ts | 5 + 5 files changed, 491 insertions(+) create mode 100644 packages/drawtonomy-sdk/__tests__/validator/cli.test.ts create mode 100644 packages/drawtonomy-sdk/__tests__/validator/esminiAdapter.test.ts create mode 100644 packages/drawtonomy-sdk/scripts/odr-validate.mts create mode 100644 packages/drawtonomy-sdk/src/validator/esminiAdapter.ts diff --git a/packages/drawtonomy-sdk/__tests__/validator/cli.test.ts b/packages/drawtonomy-sdk/__tests__/validator/cli.test.ts new file mode 100644 index 0000000..bf89b95 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/cli.test.ts @@ -0,0 +1,102 @@ +// CLI tests: scripts/odr-validate.mts. +// +// The exit code is the contract a CI job depends on, so it is asserted for +// every verdict as well as for a broken invocation. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { FIXTURE_DIR, readFixture } from './corpus' + +const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const SCRIPT = join(PKG_ROOT, 'scripts', 'odr-validate.mts') + +let workDir: string + +beforeAll(() => { + workDir = mkdtempSync(join(tmpdir(), 'odr-validate-')) +}) + +afterAll(() => { + rmSync(workDir, { recursive: true, force: true }) +}) + +function runCli(args: string[]): { status: number; stdout: string; stderr: string } { + const proc = spawnSync('npx', ['tsx', SCRIPT, ...args], { + cwd: PKG_ROOT, + encoding: 'utf8', + timeout: 120_000, + env: { ...process.env, ODR_VALIDATE_ESMINI: '' }, + }) + return { status: proc.status ?? -1, stdout: proc.stdout ?? '', stderr: proc.stderr ?? '' } +} + +describe('odr-validate CLI', () => { + it('exits 0 and reports green on a clean map', () => { + const result = runCli([join(FIXTURE_DIR, 'fabriksgatan.xodr')]) + expect(result.status).toBe(0) + expect(result.stdout).toContain('verdict: GREEN') + }, 120_000) + + it('exits 1 and reports yellow on an excerpted map', () => { + const result = runCli([join(FIXTURE_DIR, 'town04-junction106.xodr')]) + expect(result.status).toBe(1) + expect(result.stdout).toContain('verdict: YELLOW') + expect(result.stdout).toContain('ref.unresolved-junction-link') + }, 120_000) + + it('exits 2 and reports red on a defective map', () => { + const truncated = join(workDir, 'truncated.xodr') + const xml = readFixture('fabriksgatan.xodr') + writeFileSync(truncated, xml.slice(0, Math.floor(xml.length * 0.6))) + const result = runCli([truncated]) + expect(result.status).toBe(2) + expect(result.stdout).toContain('verdict: RED') + expect(result.stdout).toContain('xml.truncated') + }, 120_000) + + it('exits 3 on a usage error, distinctly from any verdict', () => { + // A broken invocation must never be mistaken for a clean map. + expect(runCli([]).status).toBe(3) + expect(runCli([join(workDir, 'does-not-exist.xodr')]).status).toBe(3) + expect(runCli(['--nonsense', join(FIXTURE_DIR, 'fabriksgatan.xodr')]).status).toBe(3) + }, 120_000) + + it('writes a machine-readable report with --json', () => { + const out = join(workDir, 'report.json') + const result = runCli([join(FIXTURE_DIR, 'town04-junction106.xodr'), '--json', out, '--quiet']) + expect(result.status).toBe(1) + expect(result.stdout.trim()).toBe('') + + const parsed = JSON.parse(readFileSync(out, 'utf8')) + expect(parsed.verdict).toBe('yellow') + expect(parsed.counts.warning).toBe(4) + expect(Array.isArray(parsed.findings)).toBe(true) + expect(parsed.findings[0]).toHaveProperty('rule') + }, 120_000) + + it('groups findings by rule and truncates the detail list', () => { + const result = runCli([join(FIXTURE_DIR, 'town04-junction106.xodr'), '--limit', '2']) + expect(result.stdout).toContain('findings by rule:') + expect(result.stdout).toContain('detail (2 of') + expect(result.stdout).toContain('more (use --limit 0 for all)') + }, 120_000) + + it('reports the external check as skipped when no binary is given', () => { + const result = runCli([join(FIXTURE_DIR, 'fabriksgatan.xodr')]) + expect(result.stdout).toContain('esmini.skipped') + }, 120_000) + + it('does not fail the run when the esmini binary is missing', () => { + const result = runCli([ + join(FIXTURE_DIR, 'fabriksgatan.xodr'), + '--esmini', + join(workDir, 'no-such-binary'), + ]) + expect(result.status).toBe(0) + expect(result.stdout).toContain('binary not found') + }, 120_000) +}) diff --git a/packages/drawtonomy-sdk/__tests__/validator/esminiAdapter.test.ts b/packages/drawtonomy-sdk/__tests__/validator/esminiAdapter.test.ts new file mode 100644 index 0000000..c24bd50 --- /dev/null +++ b/packages/drawtonomy-sdk/__tests__/validator/esminiAdapter.test.ts @@ -0,0 +1,76 @@ +// Layer 6 unit tests: the external road-manager adapter. +// +// The binary is not assumed to exist. These tests drive the parser with +// captured output, which is the whole reason the spawning lives in the CLI and +// only the text handling lives in src/. + +import { describe, it, expect } from 'vitest' +import { parseEsminiOutput, esminiSkipped, validateOpenDrive } from '../../src/validator/index' +import { readFixture } from './corpus' + +describe('parseEsminiOutput', () => { + it('reports a clean run', () => { + const found = parseEsminiOutput({ stdout: 'Loading odr\nOpenDRIVE loaded\n', exitCode: 0 }) + expect(found.map(f => f.rule)).toEqual(['esmini.clean']) + expect(found[0].severity).toBe('info') + }) + + it('picks error lines out of the chatter', () => { + const found = parseEsminiOutput({ + stdout: 'Loading odr\n', + stderr: 'Error: Failed to locate road 5\n', + exitCode: 1, + }) + expect(found.map(f => f.rule)).toEqual(['esmini.error']) + expect(found[0].message).toContain('road 5') + }) + + it('recognizes a missing connection', () => { + const found = parseEsminiOutput({ stderr: 'No connection from road 1 to road 2\n' }) + expect(found.map(f => f.rule)).toEqual(['esmini.no-connection']) + }) + + it('collapses repeated lines with a count', () => { + const line = 'Error: lane link missing\n' + const found = parseEsminiOutput({ stderr: line.repeat(50), exitCode: 1 }) + expect(found).toHaveLength(1) + expect(found[0].message).toContain('(x50)') + }) + + it('keeps the full text of a line', () => { + // Regression: an early version keyed findings by " " and then + // recovered the line by splitting on a space, truncating every message to + // its first word. + const found = parseEsminiOutput({ stderr: 'Error: something went badly wrong here\n' }) + expect(found[0].message).toBe('Error: something went badly wrong here') + }) + + it('notes a nonzero exit with no recognizable output', () => { + const found = parseEsminiOutput({ stdout: 'nothing useful', exitCode: 9 }) + expect(found.map(f => f.rule)).toEqual(['esmini.nonzero-exit']) + }) + + it('attributes findings to the tool, never the map', () => { + // esmini disagreeing with a document is evidence about the pair, so it + // must not be able to redden a report on its own. + const found = parseEsminiOutput({ stderr: 'Error: cannot follow geometry\n', exitCode: 1 }) + expect(found.every(f => f.category === 'TOOL_LIMITATION')).toBe(true) + + const report = validateOpenDrive(readFixture('fabriksgatan.xodr'), { + externalFindings: found, + }) + expect(report.verdict).toBe('yellow') + expect(report.counts.MAP_DEFECT).toBe(0) + }) +}) + +describe('esminiSkipped', () => { + it('produces a single info finding that keeps a report green', () => { + const found = esminiSkipped('no binary supplied') + expect(found).toHaveLength(1) + expect(found[0]).toMatchObject({ rule: 'esmini.skipped', severity: 'info', category: 'INFO' }) + + const report = validateOpenDrive(readFixture('fabriksgatan.xodr'), { externalFindings: found }) + expect(report.verdict).toBe('green') + }) +}) diff --git a/packages/drawtonomy-sdk/scripts/odr-validate.mts b/packages/drawtonomy-sdk/scripts/odr-validate.mts new file mode 100644 index 0000000..65360c2 --- /dev/null +++ b/packages/drawtonomy-sdk/scripts/odr-validate.mts @@ -0,0 +1,197 @@ +// Strict OpenDRIVE validation from the command line. +// +// Usage: +// npx tsx scripts/odr-validate.mts [--json out.json] +// [--esmini ] [--limit N] [--quiet] +// +// Exit codes double as the CI gate: +// 0 green informational findings only +// 1 yellow warnings, or constructs drawtonomy cannot represent +// 2 red the document has a defect +// 3 usage / IO error (distinct from a verdict, so a broken invocation is +// never mistaken for a clean map) +// +// This is where the Node-only parts live: reading the file and spawning the +// external road manager. src/validator/ itself stays pure so it also runs in +// the browser. + +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { validateOpenDrive } from '../src/validator/index' +import { parseEsminiOutput, esminiSkipped } from '../src/validator/esminiAdapter' +import type { OdrFinding, OdrValidationReport, OdrVerdict } from '../src/validator/index' + +interface CliOptions { + file: string + jsonOut: string | null + esminiBinary: string | null + limit: number + quiet: boolean +} + +function usage(message?: string): never { + if (message) console.error(`odr-validate: ${message}`) + console.error( + 'usage: odr-validate.mts [--json ] [--esmini ] [--limit N] [--quiet]' + ) + process.exit(3) +} + +function parseArgs(argv: string[]): CliOptions { + let file: string | null = null + let jsonOut: string | null = null + // The binary may also come from the environment, so a CI job can enable the + // external check for every invocation without editing each call site. + let esminiBinary: string | null = process.env.ODR_VALIDATE_ESMINI ?? null + let limit = 20 + let quiet = false + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + switch (arg) { + case '--json': + jsonOut = argv[++i] ?? usage('--json needs a path') + break + case '--esmini': + esminiBinary = argv[++i] ?? usage('--esmini needs a path') + break + case '--limit': { + const raw = argv[++i] ?? usage('--limit needs a number') + limit = Number(raw) + if (!Number.isFinite(limit) || limit < 0) usage(`--limit "${raw}" is not a count`) + break + } + case '--quiet': + quiet = true + break + case '-h': + case '--help': + usage() + break + default: + if (arg.startsWith('-')) usage(`unknown option "${arg}"`) + if (file !== null) usage('more than one input file given') + file = arg + } + } + + if (file === null) usage('no input file given') + return { file, jsonOut, esminiBinary, limit, quiet } +} + +/** + * Run the external road manager over the file, if a binary was supplied. + * Never throws: a missing or broken binary downgrades to an info finding + * rather than failing the validation run. + */ +function runEsmini(binary: string | null, file: string): OdrFinding[] { + if (!binary) return esminiSkipped('no binary supplied (--esmini or ODR_VALIDATE_ESMINI)') + if (!existsSync(binary)) return esminiSkipped(`binary not found at ${binary}`) + try { + const proc = spawnSync(binary, ['--odr', file, '--headless'], { + encoding: 'utf8', + timeout: 60_000, + }) + if (proc.error) return esminiSkipped(`could not run ${binary}: ${proc.error.message}`) + return parseEsminiOutput({ + stdout: proc.stdout ?? '', + stderr: proc.stderr ?? '', + exitCode: proc.status, + }) + } catch (err) { + return esminiSkipped(`could not run ${binary}: ${err instanceof Error ? err.message : err}`) + } +} + +const VERDICT_EXIT: Record = { green: 0, yellow: 1, red: 2 } + +function formatLocation(finding: OdrFinding): string { + const loc = finding.location + if (!loc) return '' + const parts: string[] = [] + if (loc.roadId !== undefined) parts.push(`road ${loc.roadId}`) + if (loc.junctionId !== undefined) parts.push(`junction ${loc.junctionId}`) + if (loc.laneId !== undefined) parts.push(`lane ${loc.laneId}`) + if (loc.s !== undefined) parts.push(`s=${loc.s.toFixed(2)}`) + return parts.length > 0 ? ` [${parts.join(', ')}]` : '' +} + +/** Human summary: counts per rule first, then the detail rows. */ +function printReport(report: OdrValidationReport, file: string, limit: number): void { + const { counts, verdict, findings } = report + + console.log(`\n${file}`) + console.log(` verdict: ${verdict.toUpperCase()}`) + console.log( + ` ${counts.error} error / ${counts.warning} warning / ${counts.info} info` + + ` (${counts.MAP_DEFECT} map defect, ${counts.TOOL_LIMITATION} tool limitation, ${counts.INFO} info)` + ) + + if (findings.length === 0) { + console.log(' no findings') + return + } + + const byRule = new Map() + for (const f of findings) { + const entry = byRule.get(f.rule) + if (entry) entry.count += 1 + else byRule.set(f.rule, { count: 1, severity: f.severity, category: f.category }) + } + + console.log('\n findings by rule:') + const sorted = [...byRule.entries()].sort((a, b) => b[1].count - a[1].count) + for (const [rule, entry] of sorted) { + console.log( + ` ${String(entry.count).padStart(5)} ${rule.padEnd(34)} ${entry.severity}/${entry.category}` + ) + } + + // Detail rows, worst first, so a truncated list still shows what matters. + const rank = { error: 0, warning: 1, info: 2 } as const + const detail = [...findings].sort((a, b) => rank[a.severity] - rank[b.severity]) + const shown = limit === 0 ? detail : detail.slice(0, limit) + if (shown.length > 0) { + console.log(`\n detail (${shown.length} of ${findings.length}):`) + for (const f of shown) { + console.log(` ${f.severity.padEnd(7)} ${f.rule}${formatLocation(f)}`) + console.log(` ${f.message}`) + } + if (shown.length < findings.length) { + console.log(`\n ... ${findings.length - shown.length} more (use --limit 0 for all)`) + } + } +} + +function main(): void { + const opts = parseArgs(process.argv.slice(2)) + + let xml: string + try { + xml = readFileSync(opts.file, 'utf8') + } catch (err) { + console.error(`odr-validate: cannot read ${opts.file}: ${err instanceof Error ? err.message : err}`) + process.exit(3) + } + + const externalFindings = runEsmini(opts.esminiBinary, opts.file) + const report = validateOpenDrive(xml, { externalFindings }) + + if (!opts.quiet) printReport(report, opts.file, opts.limit) + + if (opts.jsonOut) { + try { + writeFileSync(opts.jsonOut, `${JSON.stringify({ file: opts.file, ...report }, null, 2)}\n`) + if (!opts.quiet) console.log(`\n wrote ${opts.jsonOut}`) + } catch (err) { + console.error( + `odr-validate: cannot write ${opts.jsonOut}: ${err instanceof Error ? err.message : err}` + ) + process.exit(3) + } + } + + process.exit(VERDICT_EXIT[report.verdict]) +} + +main() diff --git a/packages/drawtonomy-sdk/src/validator/esminiAdapter.ts b/packages/drawtonomy-sdk/src/validator/esminiAdapter.ts new file mode 100644 index 0000000..a78b700 --- /dev/null +++ b/packages/drawtonomy-sdk/src/validator/esminiAdapter.ts @@ -0,0 +1,111 @@ +// Layer 6: esmini road-manager adapter (`esmini.*`). +// +// esmini's road manager loads an OpenDRIVE file and complains about things a +// static reader cannot easily see — unroutable connections, lanes it refuses +// to link, geometry it cannot follow. Running it is a useful second opinion, +// but it is an external binary, so the split here is deliberate: +// +// this module pure: turns captured stdout/stderr text into findings. +// No fs, no child_process, so the validator core still runs +// in a browser and the parsing is unit-testable without the +// binary being installed. +// scripts/ spawns the process and feeds the output in here. +// +// The findings are attributed TOOL_LIMITATION rather than MAP_DEFECT: esmini +// disagreeing with a document is evidence about the pair, not proof the map is +// at fault, so it never turns a report red on its own. + +import type { OdrFinding } from './types.js' + +/** Result of an external road-manager run, as captured by the caller. */ +export interface EsminiRunOutput { + /** Combined or separate process output. Both are scanned. */ + stdout?: string + stderr?: string + /** Process exit code, when the caller has one. */ + exitCode?: number | null +} + +/** + * Lines worth reporting. esmini prefixes real problems with these markers; + * everything else it prints is progress chatter. + */ +const INTERESTING = [ + { pattern: /\berror\b/i, rule: 'esmini.error' }, + { pattern: /\bfailed\b/i, rule: 'esmini.error' }, + { pattern: /no connection/i, rule: 'esmini.no-connection' }, + { pattern: /\bwarning\b/i, rule: 'esmini.warning' }, +] as const + +/** Findings reported when no binary was available. */ +export function esminiSkipped(reason: string): OdrFinding[] { + return [ + { + severity: 'info', + category: 'INFO', + rule: 'esmini.skipped', + message: `esmini road-manager check skipped: ${reason}`, + }, + ] +} + +/** + * Turn captured road-manager output into findings. + * + * Duplicate lines are collapsed with a count, because esmini repeats the same + * complaint once per lane and an unfiltered dump buries every other layer's + * findings under hundreds of identical rows. + */ +export function parseEsminiOutput(output: EsminiRunOutput): OdrFinding[] { + const text = [output.stdout ?? '', output.stderr ?? ''].join('\n') + const seen = new Map< + string, + { rule: string; severity: 'error' | 'warning'; line: string; count: number } + >() + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim() + if (line === '') continue + const match = INTERESTING.find(entry => entry.pattern.test(line)) + if (!match) continue + const severity = match.rule === 'esmini.warning' ? 'warning' : 'error' + const key = `${match.rule}|${line}` + const existing = seen.get(key) + if (existing) existing.count += 1 + else seen.set(key, { rule: match.rule, severity, line, count: 1 }) + } + + const findings: OdrFinding[] = [] + for (const entry of seen.values()) { + const { line } = entry + findings.push({ + severity: entry.severity, + // Attribution: esmini's opinion is evidence about the tool/map pair, not + // a verdict on the map, so it must not redden a report by itself. + category: 'TOOL_LIMITATION', + rule: entry.rule, + message: entry.count > 1 ? `${line} (x${entry.count})` : line, + }) + } + + if (findings.length === 0) { + const exit = output.exitCode + if (exit !== undefined && exit !== null && exit !== 0) { + findings.push({ + severity: 'warning', + category: 'TOOL_LIMITATION', + rule: 'esmini.nonzero-exit', + message: `esmini exited with code ${exit} but printed nothing recognizable`, + }) + } else { + findings.push({ + severity: 'info', + category: 'INFO', + rule: 'esmini.clean', + message: 'esmini road manager loaded the document without complaint', + }) + } + } + + return findings +} diff --git a/packages/drawtonomy-sdk/src/validator/index.ts b/packages/drawtonomy-sdk/src/validator/index.ts index 0076885..be792d9 100644 --- a/packages/drawtonomy-sdk/src/validator/index.ts +++ b/packages/drawtonomy-sdk/src/validator/index.ts @@ -6,6 +6,11 @@ // external road-manager layer is fed in through `opts.externalFindings`. export { validateOpenDrive, buildReport } from './validateOpenDrive.js' +export { + parseEsminiOutput, + esminiSkipped, + type EsminiRunOutput, +} from './esminiAdapter.js' export { DEFAULT_GEOMETRY_THRESHOLDS, countFindings,