Where the gap is
XLSValidator.validateSubset checks the registry-driven subset: type allowlist, appearances, name/code uniqueness, choice-list resolvability. It does not look at the relevant or constraint columns — both can hold anything, including references to question names that don't exist or literals that aren't one of the question's choices.
That gap is the silent failure mode behind a real bug found in CorrelAid/formulaid#47 follow-up: a follow-up text question was written without a relevant expression (and with the label of a parent Sonstiges option), so Kobo showed it unconditionally. The wizard's own subset check approved the file, and only the user noticed in the field.
Proposed rule
For every row whose relevant or constraint is non-empty, validate:
- Referenced names exist. Every
${name} reference inside the expression points to a name that appears elsewhere in the survey sheet (case-insensitive, exact match after sanitization). Unknown names → error.
- Literals are valid choices. For
${x} = 'literal' and selected(${x}, 'literal'), literal must be one of x's choice codes (for select_*) or one of x's allowed value forms (for integer/decimal/text). Mismatched literal → warning (the file may still convert, but the predicate can never be true).
- Empty expression is fine. Absence of
relevant / constraint is not a finding — XLSForm allows it. The rule fires only when the column is present.
Why it belongs in formtransform
- It's a registry-driven structural check, like the existing name/uniqueness/choice-list rules.
- It generalises to every XLSForm author, not just formulaid. Bad
relevant expressions are a common silent failure across the Kobo/LimeSurvey/DDI ecosystems.
sanitize.ts in formulaid already rewrites relevant for renames (@${name} → @${renamed}); a real parser/validator closes the loop.
API shape
Reuse the existing SubsetViolation:
{ severity: 'error', message: "relevant references unknown question: ${nonexistent}" }
{ severity: 'warning', message: "constraint literal 'foo' is not one of question 'x' choices" }
No change to the public surface; just more findings out of validateSubset. Same target: 'lstsv' | 'ddi' semantics: warn on rules that don't apply to one target.
Tests
- A row whose
relevant references an unknown name → error.
- A row whose
relevant has a literal that isn't in the referenced question's choices → warning.
- A row with no
relevant / constraint → no finding.
- The fixture from the formulaid bug (real file, broken follow-up) → relevant findings.
Out of scope
- Parsing the XLSForm expression language (boolean logic, function calls). The check stays structural: name + literal, like the sanitizer already does at
sanitize.ts:107. A full parser belongs in a follow-up if the structural rule proves noisy.
- Anything semantic ("this
relevant doesn't make sense for the question's intent"). That's wizard-side.
Follow-up in formulaid
A wizard-specific quality check (Sonstiges-follow-up heuristic) will land in formulaid regardless; it complements this rule, doesn't depend on it. The upstream check here is the long-term answer for non-formulaid users.
Where the gap is
XLSValidator.validateSubsetchecks the registry-driven subset: type allowlist, appearances, name/code uniqueness, choice-list resolvability. It does not look at therelevantorconstraintcolumns — both can hold anything, including references to question names that don't exist or literals that aren't one of the question's choices.That gap is the silent failure mode behind a real bug found in
CorrelAid/formulaid#47follow-up: a follow-up text question was written without arelevantexpression (and with the label of a parent Sonstiges option), so Kobo showed it unconditionally. The wizard's own subset check approved the file, and only the user noticed in the field.Proposed rule
For every row whose
relevantorconstraintis non-empty, validate:${name}reference inside the expression points to a name that appears elsewhere in the survey sheet (case-insensitive, exact match after sanitization). Unknown names →error.${x} = 'literal'andselected(${x}, 'literal'),literalmust be one ofx's choice codes (forselect_*) or one ofx's allowed value forms (forinteger/decimal/text). Mismatched literal →warning(the file may still convert, but the predicate can never be true).relevant/constraintis not a finding — XLSForm allows it. The rule fires only when the column is present.Why it belongs in formtransform
relevantexpressions are a common silent failure across the Kobo/LimeSurvey/DDI ecosystems.sanitize.tsin formulaid already rewritesrelevantfor renames (@${name}→@${renamed}); a real parser/validator closes the loop.API shape
Reuse the existing
SubsetViolation:No change to the public surface; just more findings out of
validateSubset. Sametarget: 'lstsv' | 'ddi'semantics: warn on rules that don't apply to one target.Tests
relevantreferences an unknown name → error.relevanthas a literal that isn't in the referenced question's choices → warning.relevant/constraint→ no finding.Out of scope
sanitize.ts:107. A full parser belongs in a follow-up if the structural rule proves noisy.relevantdoesn't make sense for the question's intent"). That's wizard-side.Follow-up in formulaid
A wizard-specific quality check (Sonstiges-follow-up heuristic) will land in formulaid regardless; it complements this rule, doesn't depend on it. The upstream check here is the long-term answer for non-formulaid users.