Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,10 @@ validates against this subset, rejecting:
- **Out-of-subset names** — identifiers must be `[a-z][a-z0-9_]*` under 17 chars,
codes under 6, groups under 21
- **Deep nesting** — max 3 levels (`group/group/question`)
- **Missing `list_name`** — selects require explicit lists
- **Unresolvable answer options** — a `select_one`/`select_multiple` needs a
list name with rows on the choices sheet; a `select_*_from_file` needs a
registered vocabulary (e.g. `iso_3166_1.csv`) or a CSV passed as
`fileChoices` (the CLI reads CSVs beside the form)
- **Reserved words** — `relevance`, `validation`, `text`, etc. (LimeSurvey internals)

LimeSurvey's reverse-subset check (`lstsv2xlsform`) is narrower — no arrays,
Expand Down
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,13 @@ function cmdValidate(argv: string[]): void {
const bytes = readInput(positionals, validateHelp);
// Parse without the built-in strict gate so we can report all findings.
const data = loadXlsform(bytes, true);
// CSVs beside the workbook count, as they do for xlsform2lstsv.
const violations = XLSValidator.validateSubset(
data.surveyData,
data.choicesData,
{
fileChoices: resolveFileChoices(data.surveyData, dirname(positionals[0])),
},
);

if (violations.length === 0) {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
export { XLSLoader } from './xlsform/loader.js';
export { XLSFormParser } from './xlsform/parser.js';
export { XLSValidator } from './xlsform/validate.js';
export type { SubsetViolation } from './xlsform/validate.js';
export type { SubsetViolation, SubsetOptions } from './xlsform/validate.js';
export { FieldSanitizer } from './xlsform/sanitize.js';

export { parseLstsv } from './lstsv/parser.js';
Expand Down
76 changes: 59 additions & 17 deletions src/pipelines/xlsform2lstsv/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { AnswerEmitter, AnswerHelpers } from './answerEmitter.js';
import { TranspilerHelper } from './transpilerHelper.js';
import { FieldNameHandler } from './fieldNameHandler.js';
import { AppearanceHandler } from './appearanceHandler.js';
import { registeredFileChoices } from '../../vocab.js';
import { registeredFileChoices, registeredVocabFiles } from '../../vocab.js';
import { parameterAttributes } from './parameters.js';

// Registry appearances are an allowlist: only 'handled' entries are
Expand Down Expand Up @@ -267,7 +267,7 @@ export class XLSFormToTSVConverter {
// Validate the type is registered and emittable. Two failure modes:
// 1. registered but unsupported by LimeSurvey TSV (no native slot)
// 2. not registered at all (convention:unregisteredRows)
this.validateRowType(xfType, baseType);
this.validateRowType(xfType, baseType, row.name);

if (xfType === 'begin_group' || xfType === 'begin group') {
await this.handleBeginGroup(row);
Expand All @@ -288,22 +288,24 @@ export class XLSFormToTSVConverter {
}

/**
* Throws if the row's type is not emittable. Two checks: registered-but-
* unsupported (with an exception for select_*_from_file when the
* referenced CSV is supplied), and not-registered-at-all.
* Throws if the row's type is not emittable: registered but unsupported,
* not registered at all, or a select whose options don't resolve.
*/
private validateRowType(xfType: string, baseType: string): void {
if (UNIMPLEMENTED_TYPES.includes(baseType)) {
const filename = xfType.split(/\s+/)[1];
const canInline =
baseType in FROM_FILE_BASE &&
!!filename &&
(this.fileChoices[filename]?.length ?? 0) > 0;
if (!canInline) {
throw new Error(
`Unimplemented XLSForm type: '${baseType}'. This type is not currently supported.`,
);
}
private validateRowType(
xfType: string,
baseType: string,
name: string | undefined,
): void {
const where = name ? ` (question "${name}")` : '';
const target = xfType.split(/\s+/)[1];
if (baseType in FROM_FILE_BASE) {
this.assertFileChoices(xfType, baseType, target, where);
} else if (UNIMPLEMENTED_TYPES.includes(baseType)) {
throw new Error(
`Unimplemented XLSForm type: '${baseType}'. This type is not currently supported.`,
);
} else if (TYPE_MAPPINGS[baseType]?.requiresListName) {
this.assertChoiceList(xfType, baseType, target, where);
}

if (
Expand All @@ -318,6 +320,46 @@ export class XLSFormToTSVConverter {
}
}

/** `select_*_from_file` is supported whenever its options resolve; say which part is missing. */
private assertFileChoices(
xfType: string,
baseType: string,
file: string | undefined,
where: string,
): void {
if (!file) {
throw new Error(
`'${baseType}'${where} needs a vocabulary file: '${baseType} <file>.csv'`,
);
}
if ((this.fileChoices[file]?.length ?? 0) === 0) {
throw new Error(
`'${xfType}'${where}: '${file}' is not a registered vocabulary ` +
`(registered: ${registeredVocabFiles().join(', ')}) and no ` +
`fileChoices were supplied for it`,
);
}
}

/** A select without options would import as a question nobody can answer. */
private assertChoiceList(
xfType: string,
baseType: string,
list: string | undefined,
where: string,
): void {
if (!list || list === 'or_other') {
throw new Error(
`'${baseType}'${where} needs a choice list: '${baseType} <list_name>'`,
);
}
if ((this.choiceManager.getChoices(list)?.length ?? 0) === 0) {
throw new Error(
`'${xfType}'${where}: list '${list}' has no rows on the choices sheet`,
);
}
}

private async handleBeginGroup(row: SurveyRow): Promise<void> {
this.matrixHandler.flushMatrix(this.matrixHelpers());
const originalName = (row.name || '').trim();
Expand Down
5 changes: 5 additions & 0 deletions src/vocab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export function parseVocabCsv(csvText: string, listName: string): ChoiceRow[] {
return rows;
}

/** Filenames of the registered vocabularies (`registry/vocab/`). */
export function registeredVocabFiles(): string[] {
return Object.keys(VOCABULARY_OPTIONS);
}

/**
* Choices for every registered vocabulary the survey references, keyed by
* filename. Unregistered filenames are left out; the caller supplies those.
Expand Down
96 changes: 78 additions & 18 deletions src/xlsform/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { APPEARANCES } from '../generated/Appearances.js';
import { TYPE_MAPPINGS } from '../generated/TypeMappings.js';

import { SurveyRow, ChoiceRow } from '../config/types.js';
import { registeredVocabFiles } from '../vocab.js';

const NAME_RULES = conventions.conventions.sanitization.name;
const CHOICE_RULES = conventions.conventions.sanitization.choiceCode;
Expand Down Expand Up @@ -31,6 +32,16 @@ export interface SubsetViolation {
message: string;
}

/** Options for {@link XLSValidator.validateSubset}. */
export interface SubsetOptions {
/**
* Choices for `select_*_from_file` CSVs the caller will pass to `convert()`,
* keyed by filename. These count as resolvable next to the registered
* vocabularies.
*/
fileChoices?: Record<string, ChoiceRow[]>;
}

/** Inputs for {@link XLSValidator.validateAll}. */
export interface ValidateAllOpts {
surveyData: SurveyRow[];
Expand Down Expand Up @@ -343,13 +354,18 @@ export class XLSValidator {
static validateSubset(
surveyData: SurveyRow[],
choicesData: ChoiceRow[],
options: SubsetOptions = {},
): SubsetViolation[] {
const violations: SubsetViolation[] = [];

for (const msg of this.collectNameCodeErrors(surveyData, choicesData)) {
violations.push({ severity: 'error', message: msg });
}

const listNames = new Set(
choicesData.map((c) => String(c.list_name ?? '').trim()),
);

for (const row of surveyData) {
const rawType = (row.type || '').trim();
if (!rawType) continue;
Expand All @@ -358,31 +374,75 @@ export class XLSValidator {

const mapping = TYPE_MAPPINGS[baseType];
const where = row.name ? ` (question "${row.name}")` : '';
if (!mapping) {
violations.push({
severity: 'error',
message: `type "${baseType}"${where} is not in the registry — not part of the supported XLSForm subset`,
});
} else if (
mapping.supported === false &&
mapping.limeSurveyType === null
) {
// select_*_from_file is registered-but-not-natively-expressible; it is
// still supported (inlined from the CSV), so only flag other such types.
if (!baseType.endsWith('_from_file')) {
violations.push({
severity: 'error',
message: `type "${baseType}"${where} is registered but not expressible in LimeSurvey TSV`,
});
}
}
const problem =
this.typeProblem(baseType, where) ??
(mapping?.requiresListName
? this.choiceListProblem(
rawType,
baseType,
where,
listNames,
options.fileChoices ?? {},
)
: null);
if (problem) violations.push({ severity: 'error', message: problem });

this.collectAppearanceViolations(row, baseType, violations);
}

return violations;
}

/** Why a type is outside the subset, or `null` if it's in it. */
private static typeProblem(baseType: string, where: string): string | null {
const mapping = TYPE_MAPPINGS[baseType];
if (!mapping) {
return `type "${baseType}"${where} is not in the registry — not part of the supported XLSForm subset`;
}
// select_*_from_file is registered-but-not-natively-expressible; it is
// still supported (inlined from the CSV), so only flag other such types.
if (
mapping.supported === false &&
mapping.limeSurveyType === null &&
!baseType.endsWith('_from_file')
) {
return `type "${baseType}"${where} is registered but not expressible in LimeSurvey TSV`;
}
return null;
}

/**
* Why a select's answer options can't be resolved, or `null` if they can.
* `select_one`/`select_multiple` need a list name with rows on the choices
* sheet; `select_*_from_file` needs a registered vocabulary or a file in
* `fileChoices`. Without options the converter emits a question with no
* answers, or fails.
*/
private static choiceListProblem(
rawType: string,
baseType: string,
where: string,
listNames: Set<string>,
fileChoices: Record<string, ChoiceRow[]>,
): string | null {
const target = rawType.split(/\s+/)[1];
if (baseType.endsWith('_from_file')) {
if (!target) {
return `"${baseType}"${where} needs a vocabulary file: "${baseType} <file>.csv"`;
}
if (registeredVocabFiles().includes(target)) return null;
if ((fileChoices[target]?.length ?? 0) > 0) return null;
return `"${rawType}"${where}: "${target}" is not a registered vocabulary (registered: ${registeredVocabFiles().join(', ')})`;
}
if (!target || target === 'or_other') {
return `"${baseType}"${where} needs a choice list: "${baseType} <list_name>"`;
}
if (!listNames.has(target)) {
return `"${rawType}"${where}: list "${target}" has no rows on the choices sheet`;
}
return null;
}

/** Flag appearances outside the registry allowlist or wrong for the type. */
private static collectAppearanceViolations(
row: SurveyRow,
Expand Down
8 changes: 8 additions & 0 deletions tests/fixtures/surveys/validation_relevance_survey/ddi.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@
<qstn responseDomainType="category">
<qstnLit>Do you consent?</qstnLit>
</qstn>
<catgry>
<catValu>yes</catValu>
<labl>Yes</labl>
</catgry>
<catgry>
<catValu>no</catValu>
<labl>No</labl>
</catgry>
<concept>Do you consent?</concept>
<varFormat type="numeric" schema="other"/>
</var>
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/surveys/validation_relevance_survey/tsv.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Q S username 1 Username en self != '' 1
Q N age 1 Age en self >= 18 and self <= 120 1
Q N price 1 Price en self > 0 and self < 1000 1
Q L consent 1 Do you consent? en 1
A yes Yes en
A no No en
Q S adultinfo age >= 18 Adult Information en self != '' 1
Q S seniordiscount age >= 65 Senior Discount Code en 1
Q S consentrequiredinfo consent == 'yes' Additional Information en 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,9 @@
"constraint_message": "Price must be between 0 and 1000"
},
{
"type": "select_one",
"type": "select_one yes_no",
"name": "consent",
"label": "Do you consent?",
"list_name": "yes_no"
"label": "Do you consent?"
},
{
"type": "text",
Expand Down Expand Up @@ -96,4 +95,4 @@
"default_language": "en"
}
]
}
}
24 changes: 10 additions & 14 deletions tests/ts/unit/questionTypes/select_one.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,15 @@ describe('Select One Question Type', () => {
expect(question?.mandatory).toBe('Y');
});

test('handles missing choice list gracefully', async () => {
test('rejects a choice list with no rows (#41)', async () => {
const survey = [
{ type: 'select_one missing_list', name: 'q1', label: 'Question' },
];

// Don't provide the choice list
const rows = await convertAndParse(survey, []);
const question = findRowByName(rows, 'q1');

expect(question).toBeDefined();
expect(question?.['type/scale']).toBe('L');
// Without choices LimeSurvey would get a question nobody can answer.
await expect(convertAndParse(survey, [])).rejects.toThrow(
/list 'missing_list' has no rows on the choices sheet/,
);
});

test('converts select_one with relevance', async () => {
Expand Down Expand Up @@ -160,17 +158,15 @@ describe('Select One Question Type', () => {
expect(question?.mandatory).toBe('Y');
});

test('handles missing choice list gracefully', async () => {
test('rejects a choice list with no rows (#41)', async () => {
const survey = [
{ type: 'select_one missing_list', name: 'q1', label: 'Question' },
];

// Don't provide the choice list
const rows = await convertAndParse(survey, []);
const question = findRowByName(rows, 'q1');

expect(question).toBeDefined();
expect(question?.['type/scale']).toBe('L');
// Without choices LimeSurvey would get a question nobody can answer.
await expect(convertAndParse(survey, [])).rejects.toThrow(
/list 'missing_list' has no rows on the choices sheet/,
);
});

test('converts select_one with relevance', async () => {
Expand Down
Loading
Loading