UCDL Accessibility Use Case Definition Language
Contents

Accessibility Use Case Definition Language (UCDL) and Runner

This section is normative.

A conforming processor MUST implement (or behave as if it implements) the following pipeline:

YAML files → Schema validation → Parse step strings → Apply extension chain
          → Interpolate templates → Use case AST
          → either:
              ├─ Code generation → Playwright .spec.ts
              └─ Direct execution → StepResult[] → Report

Each stage's responsibilities are listed below.

The schema in Appendix B MUST be applied to each input document. Documents that fail validation MUST be rejected with an error that identifies the offending field or step.

The reference implementation uses [Zod] for validation.

Extension cases use extends: <id> to inherit from a parent. Resolution proceeds as follows:

  1. Detect cycles. If processing case C requires resolving its own ancestor chain and that chain contains C, the processor MUST raise an error.
  2. Resolve the parent first (recursively).
  3. Merge data: merged = { ...parent.data, ...child.data }.
  4. Inherit start_location, expected_result, and preconditions from the parent if and only if the child does not provide them.
  5. Compute steps:
    • If the child supplies steps_override, take the parent's first from_step − 1 steps re-interpolated against merged data, then append the parsed override steps.
    • Else if the child supplies its own steps, parse those.
    • Else inherit the parent's steps re-interpolated against merged data.
  6. Compute before (§4.6):
    • If the child supplies its own before, parse those. The parent's block is replaced, not merged into or appended to — a child that declares setup is stating the setup it needs, not adding to the parent's.
    • Else inherit the parent's before re-interpolated against merged data.

Re-interpolation is REQUIRED so that data overridden by the child takes effect even in inherited steps, in the before block as much as in steps.

Ids are unique within a project (§4). Where a processor is asked for one named document and resolves an id its chain names against other documents in the project, however it locates them, and more than one document declares that id, the processor MUST raise an error naming each declaring document rather than choosing one of them: the choice would otherwise depend on the order the processor happened to visit files, and would be invisible to an author who asked about one file. This check precedes step 1 above, so an ambiguous id is reported as such rather than as a cycle.

Two cases sit outside the requirement. A duplicate id the chain does not name is never resolved, and does not arise. And a processor asked for a whole directory is resolving the set it is already parsing rather than answering about one document, so it MAY keep whichever declaration its own order arrives at; an author who names a folder has not asked about a document in it. A processor MAY reject duplicates there too, and one that does SHOULD say so in its documentation.

The Step Language uses {{ name }} template variables. Names MAY use dot notation ({{ a.b.c }}) to address nested mapping fields. Whitespace inside the braces is permitted.

If a referenced variable cannot be resolved against the effective data map, the processor MUST raise an error (Undefined template variable: {{ … }}). Falling back to an empty string is non-conforming.

Object values are JSON-stringified; null becomes the empty string; primitives are stringified by their natural string form.

A modifier whose argument is a regular expression — matches on the attribute predicate (§5.7.5), on read_image (§9A.3) and on sr_says (§9A.4) — MUST be compiled at parse time and MUST be a parse error if it does not compile. The pattern is otherwise carried to the point of use and constructed there, which is inside a browser step, a recognition pass, or a comparison against a spoken log — a long way from the line that is wrong, and reached only after validate has called the document conforming. The error MUST carry the engine's own reason.

A modifier MUST NOT appear more than once in one step, and a processor MUST reject a step in which one does. Modifiers are assigned into a single mapping, so an accepted repeat would overwrite the earlier clause and the step would apply half of what it reads as saying: enter: field "E" value "a" value "b" would enter b, and activate: link "R" new_tab true new_tab false would expect no new tab, with nothing to distinguish either from the check passing. This applies to a predicate keyword consumed as part of a larger clause too — a second is after attribute "X" is "Y" would otherwise land as a stray modifier beside the real one rather than replacing it.

Each step string is parsed against the grammar in Appendix A. A modifier not listed for the step's keyword in the table below, and any input the grammar leaves unconsumed, MUST be rejected as a parse error. The error MUST name the offending token, the running version, and the modifiers the keyword accepts.

Reporting these as warnings is not sufficient: warnings collected into a list no caller reads let a step carrying an unrecognised clause validate cleanly and then run as though the clause had never been written. A step that reads as an assertion and asserts nothing is worse than one that is rejected, because no diagnostic distinguishes it from the check passing.

A scope clause (within / inside) is part of the target production, not of the modifier list:

target      = role-and-name [ SP scope-kw SP target ]
locate-rest = target [ SP "level" SP 1*DIGIT ]

so it MUST follow the target directly and precede every modifier. A scope clause appearing after a modifier is unconsumed input under the rule above and MUST be rejected. A processor MUST NOT skip such a clause and continue: the step then resolves against the whole page while reading as though it were scoped, and — because scanning stops at the clause — any modifier written after it is lost as well. Both are the failure this section exists to prevent, and both are invisible at runtime, since a page-wide match usually succeeds.

A modifier whose grammar takes a boolean — type_slowly, clear_first, new_tab, force — MUST be given a literal true or false, matched case-insensitively. A modifier whose grammar takes a fixed keyword — via, whose only argument is keyboard — MUST be given that keyword, likewise matched case-insensitively. In both cases any other token, and an absent argument, MUST be rejected as a parse error naming the modifier, the offending token, and the step.

Coercing an unrecognised token to false, or discarding it and leaving the modifier unset, is the same defect in a different place: the step reads as expressing one behaviour and executes another, and nothing distinguishes that from the check passing. via is the sharpest case — a processor that ignores via keybord runs a mouse click for a step whose whole purpose is to prove the control is operable from the keyboard.

The unconsumed-input half of this requirement is not limited to keywords whose tail is a modifier list. wait, wait_for, scroll and lang_check take a closed production (a duration, a target, or the literal page) with nothing optional left at the end, and a processor MUST reject anything following it rather than parse the head and discard the remainder. Doing otherwise makes wait: 2s within region "Sidebar", wait_for: button "Save" within_ms 500, scroll: main to bottom and lang_check: main lang "fr" valid documents whose steps do none of what they say — and within_ms, lang and level are real modifiers on other keywords, so writing one here is a mistake made from memory rather than from carelessness. The error MUST name the unconsumed text and the step.

The modifiers below are the tokens an author writes, not the keys the parser emits — via, not via_keyboard; attribute, not attribute_name. The table covers every keyword; those whose whole value is a single production (wait, navigate, screenshot, keyboard, type, note, viewport) are listed with that production rather than with a modifier list.

within appears here for every keyword that takes a target, because it is a token an author writes. It is a scope clause bound to the target rather than a modifier in the Appendix A sense, which is why §5.1 — reading "modifier" narrowly — answers "no" for keywords whose only entry here is within.

Keyword Recognized modifiers
locate level, within
focus within
enter value, type_slowly, clear_first, within
select option, force, within
deselect within
activate via, new_tab, force, within
toggle attribute, within
verify attribute, level, has_value, is, within
scroll within
wait duration
wait_for within
navigate url
screenshot name
keyboard keys
type text
note text
audit level, within
contrast level, within
hover within
hover_out within
read_image has_text, matches, lang, within
lang_check within
sr_says matches, after, within
viewport preset or <width>x<height>
anchor label

The reference implementation exports getParseWarnings() as a stable API. It returns an empty array: every condition it could report is a parse error.

The result of parsing one step is:

interface Step {
  keyword: StepKeyword; // §5.1
  target?: TargetDescriptor; // §5.4
  modifiers: Record<string, string | number | boolean>;
  raw: string; // the step's source line, after interpolation (§10.1)
  raw_template?: string; // the pre-interpolation form, for extension reuse (§7.3)
  line_number: number; // 1-based, for diagnostics
}

interface TargetDescriptor {
  role: string; // role token or custom role
  name: string; // accessible name
  scope?: TargetDescriptor; // recursive
}

The AST is informational; processors MAY use a different internal representation provided that it captures the same information.