diff --git a/README.md b/README.md index 82d736b..cbf4291 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,30 @@ codebook describes a *dataset*, not an *instrument* — it carries no relevance, constraint, required, default or appearance, so reversing it would emit a survey that looks right and behaves wrongly. +## Errors and warnings + +Every error the library throws is a `ConversionError` with a stable `code` +(`type-unregistered`, `choice-list-empty`, `xpath-syntax`, …; see +`DiagnosticCode`), a human `message`, and `subject`, the question it concerns. +Branch on `code`, not on the message text. + +Warnings (a truncated name, an ignored appearance, a constraint that can't be +converted) go to an `onWarning` callback. The default prints them to the +console: + +```ts +const warnings: Diagnostic[] = []; +await new XLSFormToTSVConverter({ onWarning: (w) => warnings.push(w) }).convert( + survey, + choices, + settings, +); +XLSLoader.parseXLSData(buffer, { onWarning: (w) => warnings.push(w) }); +``` + +`validateSubset` returns the same `Diagnostic` objects for every finding at +once, instead of throwing on the first. + ## Supported XLSForm Subset Not everything XLSForm allows is registered (supported). The library strictly diff --git a/src/config/resolveConfig.ts b/src/config/resolveConfig.ts index 774116a..b7d5e87 100644 --- a/src/config/resolveConfig.ts +++ b/src/config/resolveConfig.ts @@ -1,6 +1,7 @@ import { deepMerge } from '../utils/helpers.js'; import { defaultConfig, LstsvConfig } from './types.js'; +import { ConversionError } from '../diagnostics.js'; const REPEAT_MODES = ['warn', 'error', 'ignore']; @@ -14,13 +15,19 @@ export function resolveConfig( const config = deepMerge(structuredClone(defaultConfig), partial); if (config.handleRepeats && !REPEAT_MODES.includes(config.handleRepeats)) { - throw new Error(`Invalid handleRepeats option: ${config.handleRepeats}`); + throw new ConversionError( + 'config-invalid', + `Invalid handleRepeats option: ${config.handleRepeats}`, + ); } // The whole xlsform2lstsv pipeline handles 2-letter codes only // (isValidLanguageCode), although convention:languageTagging allows BCP 47 // tags such as fr-BE; tracked separately. if (!config.defaults.language || config.defaults.language.length !== 2) { - throw new Error('defaults.language must be a 2-character language code'); + throw new ConversionError( + 'config-invalid', + 'defaults.language must be a 2-character language code', + ); } return Object.freeze(config); diff --git a/src/config/types.ts b/src/config/types.ts index dff545f..b4103a6 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -4,6 +4,8 @@ * The XLSForm row types used to live here; they are in `src/xlsform/types.ts` * now and re-exported below for compatibility. */ +import type { WarningHandler } from '../diagnostics.js'; + export type { SurveyRow, ChoiceRow, @@ -56,6 +58,13 @@ export interface LstsvConfig { */ hideQuestionTips?: boolean; + /** + * Receives the conversion's non-fatal findings (truncated names, ignored + * appearances, a dropped constraint, …) with a stable `code`. Defaults to + * printing them with `console.warn`. + */ + onWarning?: WarningHandler; + /** * Default values for survey elements */ diff --git a/src/conventions/grid.ts b/src/conventions/grid.ts index 26019ab..a3decf9 100644 --- a/src/conventions/grid.ts +++ b/src/conventions/grid.ts @@ -4,9 +4,14 @@ * DDI: ``. */ import conventions from '../generated/conventions.js'; +import { ConversionError } from '../diagnostics.js'; const GRID = conventions.composites.find((c) => c.id === 'grid'); -if (!GRID) throw new Error('registry has no grid composite'); +if (!GRID) + throw new ConversionError( + 'registry-invalid', + 'registry has no grid composite', + ); /** The `begin_group` appearance that makes a group a grid. */ export const GRID_APPEARANCE: string = GRID.trigger.appearance; diff --git a/src/diagnostics.ts b/src/diagnostics.ts index c5efd35..817d54f 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -1,10 +1,130 @@ /** - * Findings the validators report. Shared by every format module, so it lives - * outside all of them. + * Structured findings: what the validators report and the converters throw or + * warn with. Every finding carries a stable `code`, so a consumer can translate + * it or point the user at the offending question instead of parsing messages. + * Shared by every format module and pipeline, so it lives outside all of them. */ -/** One finding: an `error` blocks a lossless conversion, a `warning` doesn't. */ -export interface SubsetViolation { - severity: 'error' | 'warning'; +/** Stable identifiers for every finding the library produces. */ +export type DiagnosticCode = + // XLSForm structure and subset + | 'sheet-missing' + | 'sheet-empty' + | 'column-missing' + | 'column-unexpected' + | 'language-invalid' + | 'name-invalid' + | 'name-too-long' + | 'name-duplicate' + | 'name-empty-after-sanitize' + | 'name-truncated' + | 'name-collision' + | 'code-invalid' + | 'code-too-long' + | 'code-duplicate' + | 'code-missing' + | 'code-empty-after-sanitize' + | 'code-truncated' + | 'label-missing' + | 'type-unregistered' + | 'type-unsupported' + | 'choice-list-missing' + | 'choice-list-empty' + | 'vocab-file-missing' + | 'vocab-unregistered' + | 'vocab-csv-invalid' + | 'appearance-unregistered' + | 'appearance-invalid-for-type' + | 'exclusive-invalid' + | 'exclusive-no-effect' + | 'other-label-noncanonical' + | 'parameter-invalid' + // expressions + | 'xpath-syntax' + | 'xpath-unsupported' + | 'constraint-dropped' + // LimeSurvey TSV (reverse) + | 'lstsv-invalid' + | 'lstsv-outside-subset' + | 'em-unsupported' + // responses + | 'responses-invalid' + | 'response-ambiguous' + // configuration and registry + | 'config-invalid' + | 'registry-invalid'; + +export type Severity = 'error' | 'warning'; + +/** One finding. `name` is the question (or choice list) it concerns, if any. */ +export interface Diagnostic { + code: DiagnosticCode; + severity: Severity; message: string; + name?: string; +} + +/** + * What `validateSubset` and `validateLstsvSubset` return: an `error` blocks a + * lossless conversion, a `warning` doesn't. + */ +export type SubsetViolation = Diagnostic; + +/** Receives the warnings a conversion produces. */ +export type WarningHandler = (warning: Diagnostic) => void; + +/** + * The default handler: prints to the console, as the library always did. The + * only place the library writes to the console; pass your own handler (e.g. to + * collect warnings in a UI) via the `onWarning` options. + */ +export const consoleWarning: WarningHandler = (w) => { + console.warn(w.message); +}; + +/** Build a warning. */ +export function warning( + code: DiagnosticCode, + message: string, + name?: string, +): Diagnostic { + return { code, severity: 'warning', message, ...(name ? { name } : {}) }; +} + +/** + * The error every library function throws. `code` is stable; `message` is for + * humans and may change. + */ +export class ConversionError extends Error implements Diagnostic { + readonly severity = 'error' as const; + readonly code: DiagnosticCode; + readonly name: string; + /** The question (or choice list) concerned, if any. */ + readonly subject?: string; + /** Every finding, when the error summarises several. */ + readonly details: readonly Diagnostic[]; + + constructor( + code: DiagnosticCode, + message: string, + options: { + subject?: string; + cause?: unknown; + details?: readonly Diagnostic[]; + } = {}, + ) { + super(message); + this.name = 'ConversionError'; + this.code = code; + this.details = options.details ?? []; + if (options.subject) this.subject = options.subject; + if (options.cause !== undefined) { + (this as { cause?: unknown }).cause = options.cause; + } + } + + /** Rethrow a finding as an error. */ + static from(d: Diagnostic): ConversionError { + return new ConversionError(d.code, d.message, { subject: d.name }); + } } diff --git a/src/index.ts b/src/index.ts index 0516b7f..675e30f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,15 @@ +// ── Diagnostics ──────────────────────────────────────────────────────── +export { ConversionError, consoleWarning } from './diagnostics.js'; +export type { + Diagnostic, + DiagnosticCode, + Severity, + WarningHandler, +} from './diagnostics.js'; + // ── Format modules ───────────────────────────────────────────────────── export { XLSLoader } from './xlsform/loader.js'; +export type { LoadOptions } from './xlsform/loader.js'; export { XLSFormParser } from './pipelines/xlsform2lstsv/xlsformParser.js'; export { XLSValidator } from './xlsform/validate.js'; export type { diff --git a/src/lstsv/validate.ts b/src/lstsv/validate.ts index e25e2b6..d8ad910 100644 --- a/src/lstsv/validate.ts +++ b/src/lstsv/validate.ts @@ -46,8 +46,10 @@ export function validateLstsvSubset(rows: Row[]): SubsetViolation[] { if (!code || SUPPORTED_LS_CODES.has(code)) continue; const where = row.name?.trim() ? ` (question "${row.name.trim()}")` : ''; violations.push({ + code: 'lstsv-outside-subset', severity: 'error', message: `unsupported LimeSurvey question type "${code}"${where} — not in the transformable subset (${[...SUPPORTED_LS_CODES].sort().join(', ')})`, + ...(row.name?.trim() ? { name: row.name.trim() } : {}), }); } return violations; diff --git a/src/pipelines/lstsv2ddi/data.ts b/src/pipelines/lstsv2ddi/data.ts index dbbcf18..6d955c5 100644 --- a/src/pipelines/lstsv2ddi/data.ts +++ b/src/pipelines/lstsv2ddi/data.ts @@ -26,6 +26,7 @@ import type { Variable } from '../../ddi/types.js'; import type { Submission } from '../../ddi/data.js'; import { OTHER_CODE, OTHER_SUFFIX } from '../../conventions/other.js'; +import { ConversionError } from '../../diagnostics.js'; /** LimeSurvey's stored value for the "other" option of a list question. */ const LS_OTHER_VALUE = '-oth-'; @@ -93,7 +94,8 @@ function matchChoice(subkey: string, variable: Variable): string | null { ); if (prefixed.length === 1) return prefixed[0]; if (prefixed.length > 1) { - throw new Error( + throw new ConversionError( + 'response-ambiguous', `ambiguous LimeSurvey option column ${variable.name}[${subkey}]: matches ` + `choice codes ${prefixed.join(', ')}. Codes must be unique in their ` + 'first 5 characters for a LimeSurvey export to be unambiguous.', diff --git a/src/pipelines/lstsv2ddi/index.ts b/src/pipelines/lstsv2ddi/index.ts index ae2d0e2..0720622 100644 --- a/src/pipelines/lstsv2ddi/index.ts +++ b/src/pipelines/lstsv2ddi/index.ts @@ -17,6 +17,7 @@ import type { Submission } from '../../ddi/data.js'; import { normalizeLimeSurveyResponses } from './data.js'; import type { NormalizeResponsesOptions } from './data.js'; import { lstsvToVariables } from './toVariables.js'; +import { ConversionError } from '../../diagnostics.js'; export { parseLstsv } from '../../lstsv/parser.js'; export { lstsvToVariables } from './toVariables.js'; @@ -43,7 +44,8 @@ function parseChecked( const violations = validateLstsvSubset(rows); const errors = violations.filter((v) => v.severity === 'error'); if (errors.length > 0) { - throw new Error( + throw new ConversionError( + 'lstsv-outside-subset', `LimeSurvey TSV uses ${errors.length} feature(s) outside the transformable subset:\n - ` + errors.map((e) => e.message).join('\n - '), ); diff --git a/src/pipelines/lstsv2xlsform/emParser.ts b/src/pipelines/lstsv2xlsform/emParser.ts index 387c54c..0fbccd2 100644 --- a/src/pipelines/lstsv2xlsform/emParser.ts +++ b/src/pipelines/lstsv2xlsform/emParser.ts @@ -1,3 +1,4 @@ +import { ConversionError } from '../../diagnostics.js'; /** * Parse the (bounded) LimeSurvey Expression Manager dialect the forward * transpiler emits (`src/converters/xpathTranspiler.ts`) and serialize it back @@ -68,7 +69,10 @@ function scanString( let j = i + 1; while (j < src.length && src[j] !== quote) j++; if (j >= src.length) { - throw new Error(`unterminated string literal in: ${src}`); + throw new ConversionError( + 'em-unsupported', + `unterminated string literal in: ${src}`, + ); } return { token: { type: 'str', value: src.slice(i + 1, j) }, next: j + 1 }; } @@ -96,7 +100,10 @@ function scanOperator(src: string, i: number): { token: Token; next: number } { if (SINGLE_CHAR_OPS.includes(src[i])) { return { token: { type: 'op', value: src[i] }, next: i + 1 }; } - throw new Error(`unsupported character "${src[i]}" in expression: ${src}`); + throw new ConversionError( + 'em-unsupported', + `unsupported character "${src[i]}" in expression: ${src}`, + ); } // ── AST ─────────────────────────────────────────────────────────────────── @@ -136,7 +143,11 @@ class Parser { private next(): Token { const tok = this.tokens[this.pos]; - if (!tok) throw new Error('unexpected end of expression'); + if (!tok) + throw new ConversionError( + 'em-unsupported', + 'unexpected end of expression', + ); this.pos++; return tok; } @@ -144,7 +155,10 @@ class Parser { private expect(type: TokenType): Token { const tok = this.next(); if (tok.type !== type) { - throw new Error(`expected ${type}, got "${tok.value}"`); + throw new ConversionError( + 'em-unsupported', + `expected ${type}, got "${tok.value}"`, + ); } return tok; } @@ -152,7 +166,10 @@ class Parser { parse(): EmNode { const node = this.parseBinary(1); if (this.pos < this.tokens.length) { - throw new Error(`unexpected trailing token "${this.peek()!.value}"`); + throw new ConversionError( + 'em-unsupported', + `unexpected trailing token "${this.peek()!.value}"`, + ); } return node; } @@ -213,7 +230,10 @@ class Parser { const name = naok ? tok.value.slice(0, -'.NAOK'.length) : tok.value; return { t: 'ident', name, naok }; } - throw new Error(`unexpected token "${tok.value}"`); + throw new ConversionError( + 'em-unsupported', + `unexpected token "${tok.value}"`, + ); } } diff --git a/src/pipelines/lstsv2xlsform/index.ts b/src/pipelines/lstsv2xlsform/index.ts index 3d2f488..87d4c65 100644 --- a/src/pipelines/lstsv2xlsform/index.ts +++ b/src/pipelines/lstsv2xlsform/index.ts @@ -13,6 +13,7 @@ import type { SubsetViolation } from '../../diagnostics.js'; import { lstsvRowsToXlsform } from './toXlsform.js'; import type { XlsformOutput } from './toXlsform.js'; +import { ConversionError } from '../../diagnostics.js'; export { lstsvRowsToXlsform } from './toXlsform.js'; export type { XlsformOutput } from './toXlsform.js'; @@ -38,7 +39,8 @@ export function lstsvToXlsform( (v) => v.severity === 'error', ); if (errors.length > 0) { - throw new Error( + throw new ConversionError( + 'lstsv-outside-subset', `LimeSurvey TSV uses ${errors.length} feature(s) outside the transformable subset:\n - ` + errors.map((e) => e.message).join('\n - '), ); diff --git a/src/pipelines/lstsv2xlsform/reverseExpressions.ts b/src/pipelines/lstsv2xlsform/reverseExpressions.ts index eca6934..da36e4b 100644 --- a/src/pipelines/lstsv2xlsform/reverseExpressions.ts +++ b/src/pipelines/lstsv2xlsform/reverseExpressions.ts @@ -38,6 +38,7 @@ */ import { parseEm, EmNode } from './emParser.js'; +import { ConversionError } from '../../diagnostics.js'; export interface SelectContext { /** `${qname}_${code}` → the select_multiple question + choice it refers to. */ @@ -106,7 +107,8 @@ function nodeToXPath(node: EmNode, ctx: SelectContext): string { return quote(node.v); case 'ident': if (node.naok) { - throw new Error( + throw new ConversionError( + 'em-unsupported', `"${node.name}.NAOK" only supported directly inside a selected()-style "==" comparison`, ); } @@ -117,7 +119,10 @@ function nodeToXPath(node: EmNode, ctx: SelectContext): string { case 'call': { const xfName = FUNCTION_NAME_TO_XPATH[node.name]; if (!xfName) { - throw new Error(`unsupported function "${node.name}()"`); + throw new ConversionError( + 'em-unsupported', + `unsupported function "${node.name}()"`, + ); } const args = node.args.map((a) => nodeToXPath(a, ctx)).join(', '); return `${xfName}(${args})`; @@ -133,14 +138,16 @@ function binToXPath(node: EmNode & { t: 'bin' }, ctx: SelectContext): string { // `(name.NAOK=='code')` / `(name_code.NAOK=='Y')` → selected(${...}, '...'). if (op === '==' && left.t === 'ident' && left.naok) { if (right.t !== 'str') { - throw new Error( + throw new ConversionError( + 'em-unsupported', `"${left.name}.NAOK == ..." must compare against a string literal`, ); } const compound = ctx.multipleCompounds.get(left.name); if (compound) { if (right.v !== 'Y') { - throw new Error( + throw new ConversionError( + 'em-unsupported', `select_multiple selected-marker "${left.name}.NAOK" must compare against 'Y', got '${right.v}'`, ); } @@ -151,11 +158,15 @@ function binToXPath(node: EmNode & { t: 'bin' }, ctx: SelectContext): string { if (right.t === 'ident' && right.naok) { // Forward only ever puts the NAOK marker on the left side; a right-side // marker is outside the dialect we invert. - throw new Error(`"${right.name}.NAOK" is only supported on the left side`); + throw new ConversionError( + 'em-unsupported', + `"${right.name}.NAOK" is only supported on the left side`, + ); } const xfOp = BINARY_OP_TO_XPATH[op]; - if (!xfOp) throw new Error(`unsupported operator "${op}"`); + if (!xfOp) + throw new ConversionError('em-unsupported', `unsupported operator "${op}"`); return `${nodeToXPath(left, ctx)} ${xfOp} ${nodeToXPath(right, ctx)}`; } diff --git a/src/pipelines/xlsform2lstsv/answerEmitter.ts b/src/pipelines/xlsform2lstsv/answerEmitter.ts index bc2f72e..ce1d09b 100644 --- a/src/pipelines/xlsform2lstsv/answerEmitter.ts +++ b/src/pipelines/xlsform2lstsv/answerEmitter.ts @@ -5,6 +5,8 @@ import { ChoiceManager } from './choiceManager.js'; import { GroupEmitter } from './groupEmitter.js'; import { Counters } from './counters.js'; import { deduplicateNames } from '../../utils/helpers.js'; +import { consoleWarning, warning } from '../../diagnostics.js'; +import type { WarningHandler } from '../../diagnostics.js'; export interface AnswerHelpers { sanitizeAnswerCode(code: string): string; @@ -16,13 +18,28 @@ export interface AnswerHelpers { * (relevance based on the current group). */ export class AnswerEmitter { - constructor( - private rowEmitter: RowEmitter, - private languageHandler: LanguageHandler, - private choiceManager: ChoiceManager, - private groupEmitter: GroupEmitter, - private counters: Counters, - ) {} + private rowEmitter: RowEmitter; + private languageHandler: LanguageHandler; + private choiceManager: ChoiceManager; + private groupEmitter: GroupEmitter; + private counters: Counters; + private onWarning: WarningHandler; + + constructor(deps: { + rowEmitter: RowEmitter; + languageHandler: LanguageHandler; + choiceManager: ChoiceManager; + groupEmitter: GroupEmitter; + counters: Counters; + onWarning?: WarningHandler; + }) { + this.rowEmitter = deps.rowEmitter; + this.languageHandler = deps.languageHandler; + this.choiceManager = deps.choiceManager; + this.groupEmitter = deps.groupEmitter; + this.counters = deps.counters; + this.onWarning = deps.onWarning ?? consoleWarning; + } addAnswers( xfTypeInfo: TypeInfo, @@ -31,7 +48,13 @@ export class AnswerEmitter { ): void { const choices = this.choiceManager.getChoices(xfTypeInfo.listName!); if (!choices) { - console.warn(`Choice list not found: ${xfTypeInfo.listName}`); + this.onWarning( + warning( + 'choice-list-empty', + `Choice list not found: ${xfTypeInfo.listName}`, + xfTypeInfo.listName ?? undefined, + ), + ); return; } @@ -53,8 +76,12 @@ export class AnswerEmitter { const choiceNames = deduplicateNames(rawNames, 5); for (let i = 0; i < rawNames.length; i++) { if (choiceNames[i] !== rawNames[i]) { - console.warn( - `Duplicate answer code "${rawNames[i]}" resolved to "${choiceNames[i]}"`, + this.onWarning( + warning( + 'code-duplicate', + `Duplicate answer code "${rawNames[i]}" resolved to "${choiceNames[i]}"`, + xfTypeInfo.listName ?? undefined, + ), ); } } diff --git a/src/pipelines/xlsform2lstsv/appearanceHandler.ts b/src/pipelines/xlsform2lstsv/appearanceHandler.ts index 88d0b0a..c5d85f5 100644 --- a/src/pipelines/xlsform2lstsv/appearanceHandler.ts +++ b/src/pipelines/xlsform2lstsv/appearanceHandler.ts @@ -1,11 +1,17 @@ import { APPEARANCES } from '../../generated/Appearances.js'; import { LSType } from './typeMapper.js'; +import { consoleWarning } from '../../diagnostics.js'; +import type { WarningHandler } from '../../diagnostics.js'; +import { XLSValidator } from '../../xlsform/validate.js'; /** * Surfaces appearance-attribute handling: validates against the registry * allowlist and applies any type overrides the registry declares. */ export class AppearanceHandler { + /** @param onWarning receives ignored-appearance notices (default: console). */ + constructor(private readonly onWarning: WarningHandler = consoleWarning) {} + /** * Warn on appearances that aren't in the registry allowlist, or are * registered but not valid for this question type. @@ -16,15 +22,14 @@ export class AppearanceHandler { base: string, ): void { if (!appearance) return; - for (const part of appearance.split(/\s+/)) { - const spec = APPEARANCES[part]; - const isUnsupported = - !spec || (spec.validForTypes && !spec.validForTypes.includes(base)); - if (isUnsupported) { - console.warn( - `Unsupported appearance "${part}" on question "${rowName}" will be ignored`, - ); - } + // The validator's check; `base` is the emitted type (a from_file select + // is already its base select here). + for (const found of XLSValidator.appearanceDiagnostics({ + type: base, + name: rowName, + appearance, + })) { + this.onWarning(found); } } diff --git a/src/pipelines/xlsform2lstsv/constants.ts b/src/pipelines/xlsform2lstsv/constants.ts index 14af589..510943b 100644 --- a/src/pipelines/xlsform2lstsv/constants.ts +++ b/src/pipelines/xlsform2lstsv/constants.ts @@ -1,17 +1,9 @@ -import { TYPE_MAPPINGS } from './typeMapper.js'; import { METADATA_ROW_TYPES } from '../../conventions/metadata.js'; // Derived from registry convention:unregisteredRows — device/session metadata // rows are silently skipped; any other unregistered type is an error. export const SKIP_TYPES: readonly string[] = METADATA_ROW_TYPES; -// Derived from registry: registered types LimeSurvey TSV cannot express -// (supported: false) throw an error. begin_group/end_group are special-cased -// in processRow before this check. -export const UNIMPLEMENTED_TYPES: string[] = Object.entries(TYPE_MAPPINGS) - .filter(([k, v]) => !v.supported && k !== 'begin_group' && k !== 'end_group') - .map(([k]) => k); - /** A TSV row as the converter builds it (the serializer's row type). */ export type { TSVRow as TSVRowData } from '../../lstsv/columns.js'; diff --git a/src/pipelines/xlsform2lstsv/index.ts b/src/pipelines/xlsform2lstsv/index.ts index 1016fff..a2f69e5 100644 --- a/src/pipelines/xlsform2lstsv/index.ts +++ b/src/pipelines/xlsform2lstsv/index.ts @@ -4,10 +4,10 @@ import type { LstsvConfig } from '../../config/types.js'; import { SurveyRow, ChoiceRow, SettingsRow } from '../../xlsform/types.js'; import { FieldSanitizer } from '../../xlsform/sanitize.js'; import { TSVGenerator } from '../../lstsv/serialize.js'; -import { TypeMapper, TYPE_MAPPINGS } from './typeMapper.js'; +import { TypeMapper } from './typeMapper.js'; // Import extracted constants -import { SKIP_TYPES, UNIMPLEMENTED_TYPES, TSVRowData } from './constants.js'; +import { SKIP_TYPES, TSVRowData } from './constants.js'; import { ChoiceManager } from './choiceManager.js'; import { GroupProcessor } from './groupProcessor.js'; import { LanguageHandler } from './languageHandler.js'; @@ -21,7 +21,11 @@ import { AnswerEmitter, AnswerHelpers } from './answerEmitter.js'; import { TranspilerHelper } from './transpilerHelper.js'; import { FieldNameHandler } from './fieldNameHandler.js'; import { AppearanceHandler } from './appearanceHandler.js'; -import { registeredFileChoices, registeredVocabFiles } from '../../vocab.js'; +import { registeredFileChoices } from '../../vocab.js'; +import { XLSValidator } from '../../xlsform/validate.js'; +import type { RowCheckContext } from '../../xlsform/validate.js'; +import { ConversionError, consoleWarning } from '../../diagnostics.js'; +import type { WarningHandler } from '../../diagnostics.js'; import { parameterAttributes } from './parameters.js'; import { EXCLUSIVE_RULE, isExclusive } from '../../conventions/exclusive.js'; import { @@ -66,21 +70,25 @@ class Conversion { private fileChoices: Record; private surveySettingsEmitter: SurveySettingsEmitter; private surveyDataCache: SurveyRow[] = []; + private rowCheck: RowCheckContext = { listNames: new Set() }; + private readonly warn: WarningHandler; constructor(config: Readonly) { this.configManager = new ConfigManager(config); this.fileChoices = {}; + this.warn = config.onWarning ?? consoleWarning; - this.fieldSanitizer = new FieldSanitizer(); + this.fieldSanitizer = new FieldSanitizer(this.warn); this.choiceManager = new ChoiceManager(this.fieldSanitizer); this.groupProcessor = new GroupProcessor(this.configManager); this.languageHandler = new LanguageHandler(this.configManager); this.otherPatternDetector = new OtherPatternDetector( this.choiceManager, this.languageHandler, + this.warn, ); - this.typeMapper = new TypeMapper(); + this.typeMapper = new TypeMapper(this.warn); this.tsvGenerator = new TSVGenerator(); this.rowEmitter = new RowEmitter(this.tsvGenerator, this.languageHandler); @@ -103,19 +111,21 @@ class Conversion { this.choiceManager, this.counters, ); - this.answerEmitter = new AnswerEmitter( - this.rowEmitter, - this.languageHandler, - this.choiceManager, - this.groupEmitter, - this.counters, - ); + this.answerEmitter = new AnswerEmitter({ + rowEmitter: this.rowEmitter, + languageHandler: this.languageHandler, + choiceManager: this.choiceManager, + groupEmitter: this.groupEmitter, + counters: this.counters, + onWarning: this.warn, + }); this.transpilerHelper = new TranspilerHelper( this.fieldSanitizer, this.choiceManager, + this.warn, ); this.fieldNameHandler = new FieldNameHandler(this.fieldSanitizer); - this.appearanceHandler = new AppearanceHandler(); + this.appearanceHandler = new AppearanceHandler(this.warn); } // ── Row helpers ────────────────────────────────────────────────────── @@ -147,6 +157,10 @@ class Conversion { fileChoices: Record, ): string { this.fileChoices = { ...registeredFileChoices(surveyData), ...fileChoices }; + this.rowCheck = { + listNames: XLSValidator.listNamesOf(choicesData), + fileChoices: this.fileChoices, + }; // Pre-scan for welcome/end notes (must happen before group identification) this.surveySettingsEmitter.captureNotes(surveyData); @@ -247,7 +261,7 @@ class Conversion { // 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, row.name); + this.validateRow(row); if (xfType === 'begin_group' || xfType === 'begin group') { this.handleBeginGroup(row); @@ -268,36 +282,13 @@ class Conversion { } /** - * Throws if the row's type is not emittable: registered but unsupported, - * not registered at all, or a select whose options don't resolve. + * Throws the validator's finding if the row is outside the subset (an + * unregistered or unsupported type, or a select whose options don't + * resolve). The same check {@link XLSValidator.validateSubset} reports. */ - 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 ( - !(baseType in TYPE_MAPPINGS) && - baseType !== 'begin_group' && - baseType !== 'begin' && - baseType !== 'end_group' - ) { - throw new Error( - `Unimplemented XLSForm type: '${baseType}'. This type is not registered in the survey type registry.`, - ); - } + private validateRow(row: SurveyRow): void { + const problem = XLSValidator.rowDiagnostic(row, this.rowCheck); + if (problem) throw ConversionError.from(problem); } /** @@ -319,47 +310,6 @@ class Conversion { ), }; } - - /** `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} .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} '`, - ); - } - if ((this.choiceManager.getChoices(list)?.length ?? 0) === 0) { - throw new Error( - `'${xfType}'${where}: list '${list}' has no rows on the choices sheet`, - ); - } - } - private handleBeginGroup(row: SurveyRow): void { this.matrixHandler.flushMatrix(this.matrixHelpers()); const originalName = (row.name || '').trim(); diff --git a/src/pipelines/xlsform2lstsv/otherPatternDetector.ts b/src/pipelines/xlsform2lstsv/otherPatternDetector.ts index 0863663..42827e2 100644 --- a/src/pipelines/xlsform2lstsv/otherPatternDetector.ts +++ b/src/pipelines/xlsform2lstsv/otherPatternDetector.ts @@ -7,6 +7,8 @@ import { OTHER_LABELS, OTHER_SUFFIX, } from '../../conventions/other.js'; +import { consoleWarning, warning } from '../../diagnostics.js'; +import type { WarningHandler } from '../../diagnostics.js'; /** * Detects the "X_other" pattern: a follow-up question with relevance @@ -18,6 +20,7 @@ export class OtherPatternDetector { constructor( private choiceManager: ChoiceManager, private languageHandler: LanguageHandler, + private onWarning: WarningHandler = consoleWarning, ) {} /** @@ -71,9 +74,6 @@ export class OtherPatternDetector { const filteredChoices = choices.filter((choice) => !isOther(choice)); if (filteredChoices.length < choices.length) { - console.log( - `Removed "other" choice(s) from list "${typeInfo.listName}" for question "${row.name}" when using _other question pattern`, - ); this.verifyOtherLabel(removed, row); this.choiceManager.setChoices(typeInfo.listName, filteredChoices); } @@ -93,8 +93,12 @@ export class OtherPatternDetector { this.languageHandler.getBaseLanguage(), ); if (label && label.trim() && label.trim() !== expected) { - console.warn( - `"other" choice label "${label}" on "${row.name}" is not the canonical ${this.languageHandler.getBaseLanguage()} label "${expected}"; the DDI round-trip will use "${expected}".`, + this.onWarning( + warning( + 'other-label-noncanonical', + `"other" choice label "${label}" on "${row.name}" is not the canonical ${this.languageHandler.getBaseLanguage()} label "${expected}"; the DDI round-trip will use "${expected}".`, + row.name, + ), ); } } diff --git a/src/pipelines/xlsform2lstsv/parameters.ts b/src/pipelines/xlsform2lstsv/parameters.ts index 404a124..41ae327 100644 --- a/src/pipelines/xlsform2lstsv/parameters.ts +++ b/src/pipelines/xlsform2lstsv/parameters.ts @@ -5,6 +5,7 @@ * `min_num_value_n=0`, `max_num_value_n=100`, `num_value_int_only=1`. */ import { TYPE_MAPPINGS } from '../../generated/TypeMappings.js'; +import { ConversionError } from '../../diagnostics.js'; /** Parse `key=value` pairs separated by spaces, commas or semicolons. */ export function parseParameters(cell: unknown): Record { @@ -36,7 +37,8 @@ export function parameterAttributes( const values = { ...mapping.parameters, ...parseParameters(cell) }; for (const key of Object.keys(mapping.parameters)) { if (!isNumber(values[key])) { - throw new Error( + throw new ConversionError( + 'parameter-invalid', `${baseType} '${questionName}': parameter ${key}=${values[key]} is not a number`, ); } diff --git a/src/pipelines/xlsform2lstsv/transpilerHelper.ts b/src/pipelines/xlsform2lstsv/transpilerHelper.ts index d081b43..58d64e4 100644 --- a/src/pipelines/xlsform2lstsv/transpilerHelper.ts +++ b/src/pipelines/xlsform2lstsv/transpilerHelper.ts @@ -6,6 +6,8 @@ import { xpathToLimeSurveySync, TranspilerContext, } from './xpathTranspiler.js'; +import { consoleWarning } from '../../diagnostics.js'; +import type { WarningHandler } from '../../diagnostics.js'; /** * Wraps expression transpilation. The XLSForm XPath/LimeSurvey EM bridge @@ -17,6 +19,7 @@ export class TranspilerHelper { constructor( private fieldSanitizer: FieldSanitizer, private choiceManager: ChoiceManager, + private onWarning: WarningHandler = consoleWarning, ) {} buildTranspilerContext(): TranspilerContext { @@ -54,6 +57,6 @@ export class TranspilerHelper { } convertConstraint(constraint: string): string { - return convertConstraintSync(constraint); + return convertConstraintSync(constraint, this.onWarning); } } diff --git a/src/pipelines/xlsform2lstsv/typeMapper.ts b/src/pipelines/xlsform2lstsv/typeMapper.ts index 215d13a..f2b17ab 100644 --- a/src/pipelines/xlsform2lstsv/typeMapper.ts +++ b/src/pipelines/xlsform2lstsv/typeMapper.ts @@ -7,6 +7,8 @@ export { TYPE_MAPPINGS, TypeMapping } from '../../generated/TypeMappings.js'; import { TYPE_MAPPINGS } from '../../generated/TypeMappings.js'; +import { consoleWarning, warning } from '../../diagnostics.js'; +import type { WarningHandler } from '../../diagnostics.js'; export interface TypeInfo { base: string; @@ -22,6 +24,9 @@ export interface LSType { } export class TypeMapper { + /** @param onWarning receives fallback notices (default: console). */ + constructor(private readonly onWarning: WarningHandler = consoleWarning) {} + parseType(typeStr: string): TypeInfo { const parts = typeStr.split(/\s+/); const base = parts[0]; @@ -42,15 +47,21 @@ export class TypeMapper { const mapping = TYPE_MAPPINGS[typeInfo.base]; if (!mapping) { - console.warn( - `No type mapping found for "${typeInfo.base}", defaulting to text type`, + this.onWarning( + warning( + 'type-unregistered', + `No type mapping found for "${typeInfo.base}", defaulting to text type`, + ), ); return { type: 'S' }; } if (!mapping.limeSurveyType) { - console.warn( - `No LimeSurvey type for "${typeInfo.base}", defaulting to text type`, + this.onWarning( + warning( + 'type-unsupported', + `No LimeSurvey type for "${typeInfo.base}", defaulting to text type`, + ), ); return { type: 'S' }; } diff --git a/src/pipelines/xlsform2lstsv/xpathParser.ts b/src/pipelines/xlsform2lstsv/xpathParser.ts index 78a9498..132272a 100644 --- a/src/pipelines/xlsform2lstsv/xpathParser.ts +++ b/src/pipelines/xlsform2lstsv/xpathParser.ts @@ -1,3 +1,4 @@ +import { ConversionError } from '../../diagnostics.js'; /** * Parser for the XPath 1.0 subset XLSForm expressions use. * @@ -42,14 +43,17 @@ export type BinaryOp = | 'div' | 'mod'; -export class XPathSyntaxError extends Error { +export class XPathSyntaxError extends ConversionError { constructor( message: string, readonly expression: string, readonly position: number, ) { - super(`${message} at position ${position} in: ${expression}`); - this.name = 'XPathSyntaxError'; + super( + 'xpath-syntax', + `${message} at position ${position} in: ${expression}`, + ); + Object.defineProperty(this, 'name', { value: 'XPathSyntaxError' }); } } diff --git a/src/pipelines/xlsform2lstsv/xpathTranspiler.ts b/src/pipelines/xlsform2lstsv/xpathTranspiler.ts index d0d73cf..ea786b8 100644 --- a/src/pipelines/xlsform2lstsv/xpathTranspiler.ts +++ b/src/pipelines/xlsform2lstsv/xpathTranspiler.ts @@ -21,6 +21,9 @@ import { type BinaryOp, type XPathNode, } from './xpathParser.js'; +import { consoleWarning, warning } from '../../diagnostics.js'; +import type { WarningHandler } from '../../diagnostics.js'; +import { ConversionError } from '../../diagnostics.js'; /** * Callback to look up a sanitized answer code given a question name and original choice value. @@ -98,7 +101,11 @@ function wrapArgs( function arg(args: XPathNode[], i: number, fn: string): XPathNode { const node = args[i]; - if (!node) throw new Error(`${fn}() needs at least ${i + 1} argument(s)`); + if (!node) + throw new ConversionError( + 'xpath-syntax', + `${fn}() needs at least ${i + 1} argument(s)`, + ); return node; } @@ -173,7 +180,8 @@ function rewriteWithAnswerLookup( } function transpileSelected(args: XPathNode[], ctx?: TranspilerContext): string { - if (args.length !== 2) throw new Error('selected() needs 2 arguments'); + if (args.length !== 2) + throw new ConversionError('xpath-syntax', 'selected() needs 2 arguments'); const fieldName = transpile(args[0], ctx); const value = transpile(args[1], ctx).replace(/^['"]|['"]$/g, ''); const sanitizedField = sanitizeName(fieldName); @@ -187,7 +195,8 @@ function transpileSubstring( args: XPathNode[], ctx?: TranspilerContext, ): string { - if (args.length < 2) throw new Error('substring() needs ≥2 arguments'); + if (args.length < 2) + throw new ConversionError('xpath-syntax', 'substring() needs ≥2 arguments'); const stringArg = transpile(args[0], ctx); const startArg = transpile(args[1], ctx); const lengthArg = args.length > 2 ? transpile(args[2], ctx) : ''; @@ -206,7 +215,10 @@ function transpileFunctionCall( if (name === 'if' && args.length === 3) { return `if(${transpile(args[0], ctx)}, ${transpile(args[1], ctx)}, ${transpile(args[2], ctx)})`; } - throw new Error(`Unsupported function: ${name}()`); + throw new ConversionError( + 'xpath-unsupported', + `Unsupported function: ${name}()`, + ); } function transpileBinaryOp( @@ -293,11 +305,11 @@ export function xpathToLimeSurveySync( try { return transpile(parseXPath(processedExpr), ctx); } catch (error: unknown) { - const wrapped = new Error( + throw new ConversionError( + error instanceof ConversionError ? error.code : 'xpath-syntax', `Cannot convert XPath expression "${xpathExpr}" to LimeSurvey: ${(error as Error).message}`, + { cause: error }, ); - (wrapped as Error & { cause?: unknown }).cause = error; - throw wrapped; } } @@ -349,12 +361,18 @@ function reconstructRegexMatch( * @param constraint - The XPath constraint expression * @returns Validation pattern (regex or EM equation) */ -export function convertConstraint(constraint: string): Promise { - return Promise.resolve(convertConstraintSync(constraint)); +export function convertConstraint( + constraint: string, + onWarning: WarningHandler = consoleWarning, +): Promise { + return Promise.resolve(convertConstraintSync(constraint, onWarning)); } /** Synchronous core of {@link convertConstraint}. */ -export function convertConstraintSync(constraint: string): string { +export function convertConstraintSync( + constraint: string, + onWarning: WarningHandler = consoleWarning, +): string { if (!constraint) return ''; const processedExpr = preprocessExpression(constraint); @@ -376,7 +394,12 @@ export function convertConstraintSync(constraint: string): string { // Documented fallback: a constraint that isn't XPath (e.g. a bare regex) // is dropped rather than failing the conversion — the form then accepts // more input, it never hides questions. - console.error(`Constraint conversion error: ${(error as Error).message}`); + onWarning( + warning( + 'constraint-dropped', + `Constraint "${constraint}" can't be converted and is dropped (the question accepts any answer): ${(error as Error).message}`, + ), + ); return ''; } } diff --git a/src/responseFile.ts b/src/responseFile.ts index 4445024..70bf9cf 100644 --- a/src/responseFile.ts +++ b/src/responseFile.ts @@ -15,6 +15,7 @@ */ import type { Submission } from './ddi/data.js'; +import { ConversionError } from './diagnostics.js'; type Format = 'json' | 'csv'; @@ -37,13 +38,17 @@ function parseJson(text: string): Submission[] { ? (data as { results?: unknown }).results : data; if (!Array.isArray(records)) { - throw new Error( + throw new ConversionError( + 'responses-invalid', 'expected a JSON array of submissions or an object with a "results" array', ); } records.forEach((r, i) => { if (!r || typeof r !== 'object' || Array.isArray(r)) { - throw new Error(`submission ${i} is not a JSON object`); + throw new ConversionError( + 'responses-invalid', + `submission ${i} is not a JSON object`, + ); } }); return records as Submission[]; @@ -96,7 +101,8 @@ export function parseCsvRecords(text: string, delim: string): string[][] { field += c; } } - if (inQuotes) throw new Error('unterminated quoted field'); + if (inQuotes) + throw new ConversionError('responses-invalid', 'unterminated quoted field'); if (field !== '' || record.length > 0) { record.push(field); records.push(record); @@ -110,7 +116,8 @@ function parseCsv(text: string): Submission[] { if (!header) return []; return rows.map((cells, i) => { if (cells.length > header.length) { - throw new Error( + throw new ConversionError( + 'responses-invalid', `row ${i + 2} has ${cells.length} fields, header has ${header.length}`, ); } diff --git a/src/vocab.ts b/src/vocab.ts index 93781be..b566c62 100644 --- a/src/vocab.ts +++ b/src/vocab.ts @@ -9,6 +9,7 @@ import type { ChoiceRow, SurveyRow } from './xlsform/types.js'; import { VOCABULARY_OPTIONS } from './generated/VocabularyOptions.js'; import { parseCsvRecords } from './responseFile.js'; +import { ConversionError } from './diagnostics.js'; const FROM_FILE_RE = /^select_(?:one|multiple)_from_file\s+(\S+)/; @@ -38,7 +39,8 @@ export function parseVocabCsv(csvText: string, listName: string): ChoiceRow[] { const codeIdx = columns.indexOf('code'); const labelIdx = columns.indexOf('label'); if (codeIdx === -1 || labelIdx === -1) { - throw new Error( + throw new ConversionError( + 'vocab-csv-invalid', `Vocabulary CSV ${listName} must have 'code' and 'label' columns; got: ${columns.join(',')}`, ); } diff --git a/src/xlsform/loader.ts b/src/xlsform/loader.ts index 96a0fcd..becff31 100644 --- a/src/xlsform/loader.ts +++ b/src/xlsform/loader.ts @@ -10,9 +10,19 @@ import { } from '../utils/languageUtils.js'; import { XLSValidator } from './validate.js'; +import { consoleWarning, warning } from '../diagnostics.js'; +import type { WarningHandler } from '../diagnostics.js'; type RowData = Record; +/** Options for the {@link XLSLoader} parse methods. */ +export interface LoadOptions { + /** Skip the sheet/column/name checks (the LimeSurvey name gate). */ + skipValidation?: boolean; + /** Receives non-fatal findings (invalid language codes, unexpected columns). */ + onWarning?: WarningHandler; +} + export class XLSLoader { /** * Parse XLS/XLSX file and extract survey data with validation @@ -23,7 +33,7 @@ export class XLSLoader { */ static parseXLSFile( filePath: string, - options: { skipValidation?: boolean } = {}, + options: LoadOptions = {}, ): XLSFormData { const workbook = XLSX.readFile(filePath); return this.parseWorkbook(workbook, options); @@ -38,7 +48,7 @@ export class XLSLoader { */ static parseXLSData( data: Buffer | ArrayBuffer, - options: { skipValidation?: boolean } = {}, + options: LoadOptions = {}, ): XLSFormData { const workbook = XLSX.read(data); return this.parseWorkbook(workbook, options); @@ -53,7 +63,7 @@ export class XLSLoader { */ static parseWorkbook( workbook: XLSX.WorkBook, - options: { skipValidation?: boolean } = {}, + options: LoadOptions = {}, ): XLSFormData { const surveyData: SurveyRow[] = []; const choicesData: ChoiceRow[] = []; @@ -83,8 +93,11 @@ export class XLSLoader { // Validate language codes const invalidLanguageCodes = validateLanguageCodes(languageCodes); if (invalidLanguageCodes.length > 0) { - console.warn( - `Warning: Invalid language codes detected in sheet "${sheetName}": ${invalidLanguageCodes.join(', ')}. These will be ignored. Valid language codes should be 2-letter IANA subtags (e.g., 'en', 'es', 'fr').`, + (options.onWarning ?? consoleWarning)( + warning( + 'language-invalid', + `Invalid language codes detected in sheet "${sheetName}": ${invalidLanguageCodes.join(', ')}. These will be ignored. Valid language codes should be 2-letter IANA subtags (e.g., 'en', 'es', 'fr').`, + ), ); } @@ -148,6 +161,7 @@ export class XLSLoader { choicesData, hasSurveySheet, hasChoicesSheet, + onWarning: options.onWarning, }); } diff --git a/src/xlsform/sanitize.ts b/src/xlsform/sanitize.ts index bb4957e..ec08f74 100644 --- a/src/xlsform/sanitize.ts +++ b/src/xlsform/sanitize.ts @@ -1,6 +1,8 @@ import conventions from '../generated/conventions.js'; import { normalizeCode, normalizeName } from './identifiers.js'; +import { ConversionError, consoleWarning, warning } from '../diagnostics.js'; +import type { DiagnosticCode, WarningHandler } from '../diagnostics.js'; const NAME_RULES = conventions.conventions.sanitization.name; const CHOICE_RULES = conventions.conventions.sanitization.choiceCode; @@ -12,11 +14,14 @@ function normalizeOrThrow( value: string, normalize: (s: string) => string, what: string, + code: DiagnosticCode, ): string { const out = normalize(value); if (out === '') { - throw new Error( + throw new ConversionError( + code, `${what} "${value}" has no letters or digits left after sanitization (${NAME_RULES.pattern})`, + { subject: value }, ); } return out; @@ -32,7 +37,8 @@ export class FieldSanitizer { */ private strippedToUnique: Map = new Map(); - constructor() {} + /** @param onWarning receives truncation and collision notices (default: console). */ + constructor(private readonly onWarning: WarningHandler = consoleWarning) {} /** * Basic sanitization: transliterate, strip everything outside @@ -40,11 +46,20 @@ export class FieldSanitizer { * sanitizeNameUnique for that. Throws when nothing usable is left. */ sanitizeName(name: string): string { - const result = normalizeOrThrow(name, normalizeName, 'Field name'); + const result = normalizeOrThrow( + name, + normalizeName, + 'Field name', + 'name-empty-after-sanitize', + ); if (result.length > MAX_FIELD_LENGTH) { const truncated = result.substring(0, MAX_FIELD_LENGTH); - console.warn( - `Field name "${name}" exceeds maximum length of ${MAX_FIELD_LENGTH} characters and will be truncated to "${truncated}"`, + this.onWarning( + warning( + 'name-truncated', + `Field name "${name}" exceeds maximum length of ${MAX_FIELD_LENGTH} characters and will be truncated to "${truncated}"`, + name, + ), ); return truncated; } @@ -57,7 +72,12 @@ export class FieldSanitizer { * a numeric suffix is appended (e.g. "fieldname1"). */ sanitizeNameUnique(name: string): string { - const stripped = normalizeOrThrow(name, normalizeName, 'Field name'); + const stripped = normalizeOrThrow( + name, + normalizeName, + 'Field name', + 'name-empty-after-sanitize', + ); const truncated = stripped.length > MAX_FIELD_LENGTH ? stripped.substring(0, MAX_FIELD_LENGTH) @@ -81,8 +101,12 @@ export class FieldSanitizer { this.usedNames.add(candidate); this.strippedToUnique.set(stripped, candidate); - console.warn( - `Field name "${name}" collides with an existing name after sanitization; renamed to "${candidate}"`, + this.onWarning( + warning( + 'name-collision', + `Field name "${name}" collides with an existing name after sanitization; renamed to "${candidate}"`, + name, + ), ); return candidate; } @@ -110,13 +134,21 @@ export class FieldSanitizer { } sanitizeAnswerCode(code: string): string { - const result = normalizeOrThrow(code, normalizeCode, 'Answer code'); + const result = normalizeOrThrow( + code, + normalizeCode, + 'Answer code', + 'code-empty-after-sanitize', + ); const maxLength = MAX_CHOICE_LENGTH; if (result.length > maxLength) { const truncated = result.substring(0, maxLength); - console.warn( - `Answer code "${code}" exceeds maximum length of ${maxLength} characters and will be truncated to "${truncated}"`, + this.onWarning( + warning( + 'code-truncated', + `Answer code "${code}" exceeds maximum length of ${maxLength} characters and will be truncated to "${truncated}"`, + ), ); return truncated; } diff --git a/src/xlsform/validate.ts b/src/xlsform/validate.ts index e32c117..9939f9b 100644 --- a/src/xlsform/validate.ts +++ b/src/xlsform/validate.ts @@ -1,5 +1,11 @@ import conventions from '../generated/conventions.js'; -import type { SubsetViolation } from '../diagnostics.js'; +import { ConversionError, consoleWarning, warning } from '../diagnostics.js'; +import type { + Diagnostic, + DiagnosticCode, + SubsetViolation, + WarningHandler, +} from '../diagnostics.js'; import { APPEARANCES } from '../generated/Appearances.js'; import { TYPE_MAPPINGS } from '../generated/TypeMappings.js'; @@ -65,8 +71,30 @@ export interface ValidateAllOpts { hasChoicesSheet: boolean; surveySheetName?: string; choicesSheetName?: string; + /** Receives non-fatal findings (empty sheet, unexpected column). */ + onWarning?: WarningHandler; } +/** What {@link XLSValidator.rowDiagnostic} needs to know about the form. */ +export interface RowCheckContext { + /** List names that have at least one row on the choices sheet. */ + listNames: ReadonlySet; + /** `select_*_from_file` CSVs the caller supplies, keyed by filename. */ + fileChoices?: Record; + target?: SubsetTarget; +} + +const error = ( + code: DiagnosticCode, + message: string, + name?: string, +): Diagnostic => ({ + code, + severity: 'error', + message, + ...(name ? { name } : {}), +}); + // The semi-open `_other` follow-up (convention:other): LimeSurvey // carries "other" via its native `other=Y` setting, so the suffix's underscore // is a source-side marker, not a literal LS code — validate only ``. @@ -76,7 +104,7 @@ export class XLSValidator { * Validate that required sheets are present * @param hasSurveySheet Whether survey sheet was found * @param hasChoicesSheet Whether choices sheet was found - * @throws Error if required sheets are missing + * @throws ConversionError (`sheet-missing`) if required sheets are missing */ static validateRequiredSheets( hasSurveySheet: boolean, @@ -87,7 +115,8 @@ export class XLSValidator { if (!hasChoicesSheet) missingSheets.push('choices'); if (missingSheets.length > 0) { - throw new Error( + throw new ConversionError( + 'sheet-missing', `XLSX file is missing required sheets: ${missingSheets.join(', ')}. An XLSForm must contain survey and choices sheets.`, ); } @@ -102,9 +131,12 @@ export class XLSValidator { static validateSurveySheetColumns( data: SurveyRow[], sheetName: string, + onWarning: WarningHandler = consoleWarning, ): void { if (data.length === 0) { - console.warn(`Warning: Survey sheet "${sheetName}" is empty.`); + onWarning( + warning('sheet-empty', `Survey sheet "${sheetName}" is empty.`), + ); return; } @@ -119,8 +151,11 @@ export class XLSValidator { } if (allColumns.size === 0) { - console.warn( - `Warning: Survey sheet "${sheetName}" has no valid data rows.`, + onWarning( + warning( + 'sheet-empty', + `Survey sheet "${sheetName}" has no valid data rows.`, + ), ); return; } @@ -132,7 +167,8 @@ export class XLSValidator { ); if (missingColumns.length > 0) { - throw new Error( + throw new ConversionError( + 'column-missing', `Survey sheet "${sheetName}" is missing required columns: ${missingColumns.join(', ')}. A survey sheet must contain type, name, and label columns.`, ); } @@ -157,8 +193,11 @@ export class XLSValidator { ); if (unexpectedColumns.length > 0) { - console.warn( - `Warning: Survey sheet "${sheetName}" contains unexpected columns: ${unexpectedColumns.join(', ')}. These columns will be ignored.`, + onWarning( + warning( + 'column-unexpected', + `Survey sheet "${sheetName}" contains unexpected columns: ${unexpectedColumns.join(', ')}. These columns will be ignored.`, + ), ); } } @@ -172,9 +211,12 @@ export class XLSValidator { static validateChoicesSheetColumns( data: ChoiceRow[], sheetName: string, + onWarning: WarningHandler = consoleWarning, ): void { if (data.length === 0) { - console.warn(`Warning: Choices sheet "${sheetName}" is empty.`); + onWarning( + warning('sheet-empty', `Choices sheet "${sheetName}" is empty.`), + ); return; } @@ -189,8 +231,11 @@ export class XLSValidator { } if (allColumns.size === 0) { - console.warn( - `Warning: Choices sheet "${sheetName}" has no valid data rows.`, + onWarning( + warning( + 'sheet-empty', + `Choices sheet "${sheetName}" has no valid data rows.`, + ), ); return; } @@ -207,7 +252,8 @@ export class XLSValidator { if (!hasLabel) missingColumns.push('label'); if (missingColumns.length > 0) { - throw new Error( + throw new ConversionError( + 'column-missing', `Choices sheet "${sheetName}" is missing required columns: ${missingColumns.join(', ')}. A choices sheet must contain list_name, name, and label columns.`, ); } @@ -226,8 +272,11 @@ export class XLSValidator { ); if (unexpectedColumns.length > 0) { - console.warn( - `Warning: Choices sheet "${sheetName}" contains unexpected columns: ${unexpectedColumns.join(', ')}. These columns will be ignored.`, + onWarning( + warning( + 'column-unexpected', + `Choices sheet "${sheetName}" contains unexpected columns: ${unexpectedColumns.join(', ')}. These columns will be ignored.`, + ), ); } } @@ -246,6 +295,7 @@ export class XLSValidator { hasChoicesSheet, surveySheetName = 'survey', choicesSheetName = 'choices', + onWarning = consoleWarning, } = opts; // Validate required sheets @@ -253,12 +303,16 @@ export class XLSValidator { // Validate survey sheet columns if (hasSurveySheet && surveyData.length > 0) { - this.validateSurveySheetColumns(surveyData, surveySheetName); + this.validateSurveySheetColumns(surveyData, surveySheetName, onWarning); } // Validate choices sheet columns if (hasChoicesSheet && choicesData.length > 0) { - this.validateChoicesSheetColumns(choicesData, choicesSheetName); + this.validateChoicesSheetColumns( + choicesData, + choicesSheetName, + onWarning, + ); } // Reject names/codes LimeSurvey cannot represent (strict by default). @@ -271,29 +325,46 @@ export class XLSValidator { * rather than silently sanitize so the LimeSurvey/DDI round-trip is lossless: * a rejected form must be fixed at the source, not quietly renamed. * - * @throws Error listing every offending name/code. + * @throws ConversionError listing every offending name/code; its `code` is + * the first finding's, and `details` holds all of them. */ static validateNamesAndCodes( surveyData: SurveyRow[], choicesData: ChoiceRow[], ): void { - const errors = this.collectNameCodeErrors(surveyData, choicesData); - if (errors.length > 0) { - throw new Error( - `XLSForm uses ${errors.length} name(s)/code(s) that LimeSurvey cannot represent. ` + + const found = this.collectNameCodeDiagnostics(surveyData, choicesData); + if (found.length > 0) { + throw new ConversionError( + found[0].code, + `XLSForm uses ${found.length} name(s)/code(s) that LimeSurvey cannot represent. ` + `Fix them at the source (or pass skipValidation to sanitize instead, losing round-trip fidelity):\n - ` + - errors.join('\n - '), + found.map((d) => d.message).join('\n - '), + { subject: found[0].name, details: found }, ); } } - /** Collect (without throwing) every name/code that breaks the LS rules. */ + /** + * @deprecated Use {@link collectNameCodeDiagnostics}, which also returns + * each finding's `code`. + */ static collectNameCodeErrors( surveyData: SurveyRow[], choicesData: ChoiceRow[], target: SubsetTarget = 'lstsv', ): string[] { - const errors: string[] = []; + return this.collectNameCodeDiagnostics(surveyData, choicesData, target).map( + (d) => d.message, + ); + } + + /** Every name/code that breaks the rules for `target` (does not throw). */ + static collectNameCodeDiagnostics( + surveyData: SurveyRow[], + choicesData: ChoiceRow[], + target: SubsetTarget = 'lstsv', + ): Diagnostic[] { + const errors: Diagnostic[] = []; const seen = new Set(); const lsRules = target === 'lstsv'; @@ -312,7 +383,7 @@ export class XLSValidator { row: SurveyRow, seen: Set, lsRules: boolean, - errors: string[], + errors: Diagnostic[], ): void { const type = (row.type || '').trim(); if (NO_NAME_TYPES.has(type)) return; @@ -331,16 +402,30 @@ export class XLSValidator { // DDI keeps names as authored; only uniqueness below applies. } else if (!NAME_RE.test(base)) { errors.push( - `field name "${name}" must match ${NAME_RULES.pattern} (letters/digits only — no underscores, hyphens or spaces)`, + error( + 'name-invalid', + `field name "${name}" must match ${NAME_RULES.pattern} (letters/digits only — no underscores, hyphens or spaces)`, + name, + ), ); } else if (lsLength > NAME_RULES.maxLength) { errors.push( - `field name "${name}" exceeds the ${NAME_RULES.maxLength}-character limit`, + error( + 'name-too-long', + `field name "${name}" exceeds the ${NAME_RULES.maxLength}-character limit`, + name, + ), ); } if (seen.has(name)) { - errors.push(`field name "${name}" is used more than once`); + errors.push( + error( + 'name-duplicate', + `field name "${name}" is used more than once`, + name, + ), + ); } seen.add(name); } @@ -349,14 +434,20 @@ export class XLSValidator { choice: ChoiceRow, codesByList: Map>, lsRules: boolean, - errors: string[], + errors: Diagnostic[], ): void { const code = (choice.name ?? '').toString().trim(); const listName = String(choice.list_name ?? '').trim(); if (!code) { // A row with a list but no code would be dropped from the question. if (listName) { - errors.push(`a choice in list "${listName}" has no code (name)`); + errors.push( + error( + 'code-missing', + `a choice in list "${listName}" has no code (name)`, + listName, + ), + ); } return; } @@ -364,7 +455,11 @@ export class XLSValidator { const seen = codesByList.get(listName) ?? new Set(); if (seen.has(code)) { errors.push( - `answer code "${code}" is used more than once in list "${listName}"`, + error( + 'code-duplicate', + `answer code "${code}" is used more than once in list "${listName}"`, + listName, + ), ); } seen.add(code); @@ -372,11 +467,19 @@ export class XLSValidator { if (!lsRules) return; if (!CHOICE_RE.test(code)) { errors.push( - `answer code "${code}" (list "${listName}") must match ${CHOICE_RULES.pattern} (letters/digits only)`, + error( + 'code-invalid', + `answer code "${code}" (list "${listName}") must match ${CHOICE_RULES.pattern} (letters/digits only)`, + listName, + ), ); } else if (code.length > CHOICE_RULES.maxLength) { errors.push( - `answer code "${code}" (list "${listName}") exceeds the ${CHOICE_RULES.maxLength}-character limit`, + error( + 'code-too-long', + `answer code "${code}" (list "${listName}") exceeds the ${CHOICE_RULES.maxLength}-character limit`, + listName, + ), ); } } @@ -400,29 +503,26 @@ export class XLSValidator { const violations: SubsetViolation[] = []; const target = options.target ?? 'lstsv'; - for (const msg of this.collectNameCodeErrors( - surveyData, - choicesData, - target, - )) { - violations.push({ severity: 'error', message: msg }); - } - - const listNames = new Set( - choicesData.map((c) => String(c.list_name ?? '').trim()), + violations.push( + ...this.collectNameCodeDiagnostics(surveyData, choicesData, target), ); + const ctx: RowCheckContext = { + listNames: this.listNamesOf(choicesData), + fileChoices: options.fileChoices, + target, + }; for (const row of surveyData) { - this.collectRowViolations(row, listNames, options, violations); + const problem = this.rowDiagnostic(row, ctx); + if (problem) violations.push(problem); + this.collectAppearanceViolations(row, violations); } for (const choice of choicesData) { - const message = this.emptyChoiceLabel(choice); - if (message) violations.push({ severity: 'warning', message }); - } - for (const message of this.exclusiveProblems(surveyData, choicesData)) { - violations.push({ severity: 'warning', message }); + const found = this.emptyChoiceLabel(choice); + if (found) violations.push(found); } + violations.push(...this.exclusiveProblems(surveyData, choicesData)); return violations; } @@ -431,18 +531,26 @@ export class XLSValidator { * A choice whose label is empty, in every language or in some, shows as a * blank option. A warning: the form still converts. */ - private static emptyChoiceLabel(choice: ChoiceRow): string | null { + private static emptyChoiceLabel(choice: ChoiceRow): Diagnostic | null { const code = (choice.name ?? '').toString().trim(); if (!code) return null; // reported as a missing code - const where = `choice "${code}" (list "${String(choice.list_name ?? '').trim()}")`; + const listName = String(choice.list_name ?? '').trim(); + const where = `choice "${code}" (list "${listName}")`; const labels = this.choiceLabels(choice); - if (labels.size === 0) return `${where} has no label`; const missing = [...labels] .filter(([, text]) => text === '') .map(([k]) => k); - if (missing.length === 0) return null; - if (missing.length === labels.size) return `${where} has no label`; - return `${where} has no label in: ${missing.join(', ')}`; + if (labels.size > 0 && missing.length === 0) return null; + const message = + labels.size === 0 || missing.length === labels.size + ? `${where} has no label` + : `${where} has no label in: ${missing.join(', ')}`; + return warning('label-missing', message, listName); + } + + /** List names with at least one row on the choices sheet. */ + static listNamesOf(choicesData: ChoiceRow[]): Set { + return new Set(choicesData.map((c) => String(c.list_name ?? '').trim())); } /** @@ -470,33 +578,29 @@ export class XLSValidator { return out; } - /** Type, choice-list and appearance findings for one survey row. */ - private static collectRowViolations( + /** + * The error that keeps one survey row out of the subset (an unregistered or + * unsupported type, or a select whose answer options can't be resolved), or + * `null`. The single row check: {@link validateSubset} collects it for every + * row, and the converters throw it. + */ + static rowDiagnostic( row: SurveyRow, - listNames: Set, - options: SubsetOptions, - violations: SubsetViolation[], - ): void { + ctx: RowCheckContext, + ): Diagnostic | null { const rawType = (row.type || '').trim(); - if (!rawType) return; + if (!rawType) return null; const baseType = rawType.split(/\s+/)[0]; - if (STRUCTURAL.has(rawType) || METADATA_TYPES.has(baseType)) return; + if (STRUCTURAL.has(rawType) || METADATA_TYPES.has(baseType)) return null; - const where = row.name ? ` (question "${row.name}")` : ''; - const problem = - this.typeProblem(baseType, where, options.target ?? 'lstsv') ?? + const name = typeof row.name === 'string' ? row.name.trim() : ''; + const where = name ? ` (question "${name}")` : ''; + return ( + this.typeProblem(baseType, where, name, ctx.target ?? 'lstsv') ?? (TYPE_MAPPINGS[baseType]?.requiresListName - ? this.choiceListProblem( - rawType, - baseType, - where, - listNames, - options.fileChoices ?? {}, - ) - : null); - if (problem) violations.push({ severity: 'error', message: problem }); - - this.collectAppearanceViolations(row, baseType, violations); + ? this.choiceListProblem(rawType, baseType, where, name, ctx) + : null) + ); } /** @@ -506,7 +610,7 @@ export class XLSValidator { private static exclusiveProblems( surveyData: SurveyRow[], choicesData: ChoiceRow[], - ): string[] { + ): Diagnostic[] { const multiLists = new Set(); for (const row of surveyData) { const [base, list] = String(row.type ?? '') @@ -515,18 +619,27 @@ export class XLSValidator { if (list && EXCLUSIVE_RULE.appliesTo.includes(base)) multiLists.add(list); } const col = EXCLUSIVE_RULE.choicesColumn; - const problems: string[] = []; + const problems: Diagnostic[] = []; for (const choice of choicesData) { const cell = exclusiveCell(choice); if (!cell || cell === 'no' || cell === 'false' || cell === '0') continue; - const where = `choice "${String(choice.name ?? '').trim()}" (list "${String(choice.list_name ?? '').trim()}")`; + const listName = String(choice.list_name ?? '').trim(); + const where = `choice "${String(choice.name ?? '').trim()}" (list "${listName}")`; if (!isExclusive(choice)) { problems.push( - `${where}: "${col}" value "${cell}" isn't recognised (use ${EXCLUSIVE_RULE.trueValues.join('/')}) and is ignored`, + warning( + 'exclusive-invalid', + `${where}: "${col}" value "${cell}" isn't recognised (use ${EXCLUSIVE_RULE.trueValues.join('/')}) and is ignored`, + listName, + ), ); - } else if (!multiLists.has(String(choice.list_name ?? '').trim())) { + } else if (!multiLists.has(listName)) { problems.push( - `${where} is marked "${col}", but no ${EXCLUSIVE_RULE.appliesTo.join('/')} uses this list, so it has no effect`, + warning( + 'exclusive-no-effect', + `${where} is marked "${col}", but no ${EXCLUSIVE_RULE.appliesTo.join('/')} uses this list, so it has no effect`, + listName, + ), ); } } @@ -537,11 +650,16 @@ export class XLSValidator { private static typeProblem( baseType: string, where: string, + name: string, target: SubsetTarget, - ): string | null { + ): Diagnostic | null { const mapping = TYPE_MAPPINGS[baseType]; if (!mapping) { - return `type "${baseType}"${where} is not in the registry — not part of the supported XLSForm subset`; + return error( + 'type-unregistered', + `type "${baseType}"${where} is not in the registry — not part of the supported XLSForm subset`, + name, + ); } // select_*_from_file is registered-but-not-natively-expressible; it is // still supported (inlined from the CSV), so only flag other such types. @@ -551,7 +669,11 @@ export class XLSValidator { mapping.limeSurveyType === null && !isFromFileType(baseType) ) { - return `type "${baseType}"${where} is registered but not expressible in LimeSurvey TSV`; + return error( + 'type-unsupported', + `type "${baseType}"${where} is registered but not expressible in LimeSurvey TSV`, + name, + ); } return null; } @@ -567,48 +689,81 @@ export class XLSValidator { rawType: string, baseType: string, where: string, - listNames: Set, - fileChoices: Record, - ): string | null { + name: string, + ctx: RowCheckContext, + ): Diagnostic | null { const target = rawType.split(/\s+/)[1]; if (isFromFileType(baseType)) { if (!target) { - return `"${baseType}"${where} needs a vocabulary file: "${baseType} .csv"`; + return error( + 'vocab-file-missing', + `"${baseType}"${where} needs a vocabulary file: "${baseType} .csv"`, + name, + ); } 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 ((ctx.fileChoices?.[target]?.length ?? 0) > 0) return null; + return error( + 'vocab-unregistered', + `"${rawType}"${where}: "${target}" is not a registered vocabulary (registered: ${registeredVocabFiles().join(', ')})`, + name, + ); } if (!target || target === 'or_other') { - return `"${baseType}"${where} needs a choice list: "${baseType} "`; + return error( + 'choice-list-missing', + `"${baseType}"${where} needs a choice list: "${baseType} "`, + name, + ); } - if (!listNames.has(target)) { - return `"${rawType}"${where}: list "${target}" has no rows on the choices sheet`; + if (!ctx.listNames.has(target)) { + return error( + 'choice-list-empty', + `"${rawType}"${where}: list "${target}" has no rows on the choices sheet`, + name, + ); } return null; } - /** Flag appearances outside the registry allowlist or wrong for the type. */ + /** + * Warnings for appearances outside the registry allowlist or wrong for the + * row's type (the converter ignores them). Shared by {@link validateSubset} + * and the converter. + */ + static appearanceDiagnostics(row: SurveyRow): Diagnostic[] { + const found: Diagnostic[] = []; + this.collectAppearanceViolations(row, found); + return found; + } + private static collectAppearanceViolations( row: SurveyRow, - baseType: string, violations: SubsetViolation[], ): void { const appearance = typeof row['appearance'] === 'string' ? row['appearance'].trim() : ''; if (!appearance) return; + const baseType = (row.type || '').trim().split(/\s+/)[0]; + const name = typeof row.name === 'string' ? row.name : ''; for (const part of appearance.split(/\s+/)) { const spec = APPEARANCES[part]; if (!spec) { - violations.push({ - severity: 'warning', - message: `appearance "${part}" on "${row.name}" is not in the registry allowlist and will be ignored`, - }); + violations.push( + warning( + 'appearance-unregistered', + `appearance "${part}" on "${name}" is not in the registry allowlist and will be ignored`, + name, + ), + ); } else if (spec.validForTypes && !spec.validForTypes.includes(baseType)) { - violations.push({ - severity: 'warning', - message: `appearance "${part}" on "${row.name}" is not valid for type "${baseType}" and will be ignored`, - }); + violations.push( + warning( + 'appearance-invalid-for-type', + `appearance "${part}" on "${name}" is not valid for type "${baseType}" and will be ignored`, + name, + ), + ); } } } diff --git a/tests/ts/integration/converterConcurrency.test.ts b/tests/ts/integration/converterConcurrency.test.ts index 5786d5d..852a205 100644 --- a/tests/ts/integration/converterConcurrency.test.ts +++ b/tests/ts/integration/converterConcurrency.test.ts @@ -51,6 +51,6 @@ describe('XLSFormToTSVConverter concurrency', () => { [], [], ); - await expect(p).rejects.toThrow(/list 'missing' has no rows/); + await expect(p).rejects.toMatchObject({ code: 'choice-list-empty' }); }); }); diff --git a/tests/ts/unit/appearance.test.ts b/tests/ts/unit/appearance.test.ts index e93d32e..4e9cdaa 100644 --- a/tests/ts/unit/appearance.test.ts +++ b/tests/ts/unit/appearance.test.ts @@ -90,8 +90,7 @@ describe('Appearance Handling', () => { await convertAndParse(survey, choices); const appearanceWarnings = warnSpy.mock.calls.filter( (call) => - typeof call[0] === 'string' && - call[0].includes('Unsupported appearance'), + typeof call[0] === 'string' && call[0].includes('will be ignored'), ); expect(appearanceWarnings).toHaveLength(0); warnSpy.mockRestore(); @@ -135,8 +134,7 @@ describe('Appearance Handling', () => { await convertAndParse(survey, choices); const appearanceWarnings = warnSpy.mock.calls.filter( (call) => - typeof call[0] === 'string' && - call[0].includes('Unsupported appearance'), + typeof call[0] === 'string' && call[0].includes('will be ignored'), ); expect(appearanceWarnings).toHaveLength(0); warnSpy.mockRestore(); @@ -151,7 +149,7 @@ describe('Appearance Handling', () => { const appearanceWarnings = warnSpy.mock.calls.filter( (call) => typeof call[0] === 'string' && - call[0].includes('Unsupported appearance "minimal"'), + call[0].includes('appearance "minimal"'), ); expect(appearanceWarnings).toHaveLength(1); warnSpy.mockRestore(); @@ -176,8 +174,7 @@ describe('Appearance Handling', () => { await convertAndParse(survey, choices); const appearanceWarnings = warnSpy.mock.calls.filter( (call) => - typeof call[0] === 'string' && - call[0].includes('Unsupported appearance'), + typeof call[0] === 'string' && call[0].includes('will be ignored'), ); expect(appearanceWarnings).toHaveLength(1); expect(appearanceWarnings[0][0]).toContain('"horizontal"'); @@ -197,8 +194,7 @@ describe('Appearance Handling', () => { await convertAndParse(survey); const appearanceWarnings = warnSpy.mock.calls.filter( (call) => - typeof call[0] === 'string' && - call[0].includes('Unsupported appearance'), + typeof call[0] === 'string' && call[0].includes('will be ignored'), ); expect(appearanceWarnings).toHaveLength(1); expect(appearanceWarnings[0][0]).toContain('"compact"'); @@ -211,8 +207,7 @@ describe('Appearance Handling', () => { await convertAndParse(survey); const appearanceWarnings = warnSpy.mock.calls.filter( (call) => - typeof call[0] === 'string' && - call[0].includes('Unsupported appearance'), + typeof call[0] === 'string' && call[0].includes('will be ignored'), ); expect(appearanceWarnings).toHaveLength(0); warnSpy.mockRestore(); diff --git a/tests/ts/unit/diagnostics.test.ts b/tests/ts/unit/diagnostics.test.ts new file mode 100644 index 0000000..3975d97 --- /dev/null +++ b/tests/ts/unit/diagnostics.test.ts @@ -0,0 +1,143 @@ +/** #63: structured errors and warnings instead of console output and plain Errors. */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; + +import { describe, expect, test, vi } from 'vitest'; + +import { + ConversionError, + FieldSanitizer, + XLSFormToTSVConverter, + xpathToLimeSurvey, + parseResponses, + resolveConfig, +} from '../../../src/index.js'; +import type { Diagnostic } from '../../../src/index.js'; + +describe('warnings go to onWarning, with a code', () => { + test('the converter reports through config.onWarning and not the console', async () => { + const seen: Diagnostic[] = []; + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await new XLSFormToTSVConverter({ onWarning: (w) => seen.push(w) }).convert( + [ + { + type: 'text', + name: 'q', + label: 'Q', + appearance: 'no-such-appearance', + }, + { type: 'integer', name: 'n', label: 'N', constraint: '^[0-9]+$' }, + ], + [], + [], + ); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + expect(seen.map((w) => w.code)).toEqual( + expect.arrayContaining(['appearance-unregistered', 'constraint-dropped']), + ); + expect(seen.every((w) => w.severity === 'warning')).toBe(true); + }); + + test('FieldSanitizer takes a handler too', () => { + const seen: Diagnostic[] = []; + new FieldSanitizer((w) => seen.push(w)).sanitizeAnswerCode('verylong'); + expect(seen).toEqual([expect.objectContaining({ code: 'code-truncated' })]); + }); +}); + +describe('errors are ConversionErrors with a stable code', () => { + const codeOf = async (fn: () => unknown) => { + try { + await fn(); + } catch (e) { + expect(e).toBeInstanceOf(ConversionError); + return (e as ConversionError).code; + } + throw new Error('did not throw'); + }; + + test.each([ + [ + 'unregistered type', + () => + new XLSFormToTSVConverter().convert( + [{ type: 'geopoint', name: 'g' }], + [], + [], + ), + 'type-unregistered', + ], + [ + 'empty choice list', + () => + new XLSFormToTSVConverter().convert( + [{ type: 'select_one x', name: 'q' }], + [], + [], + ), + 'choice-list-empty', + ], + ['XPath syntax', () => xpathToLimeSurvey('${a} = = 1'), 'xpath-syntax'], + [ + 'XPath function', + () => xpathToLimeSurvey('count-selected(.)'), + 'xpath-unsupported', + ], + [ + 'response file', + () => parseResponses('[1, 2]', 'r.json'), + 'responses-invalid', + ], + [ + 'config', + () => resolveConfig({ defaults: { language: 'deu' } as never }), + 'config-invalid', + ], + [ + 'empty sanitized name', + () => new FieldSanitizer().sanitizeName('日本'), + 'name-empty-after-sanitize', + ], + ])('%s', async (_, fn, code) => { + expect(await codeOf(fn)).toBe(code); + }); + + test('the error names the question it concerns', async () => { + const p = new XLSFormToTSVConverter().convert( + [{ type: 'geopoint', name: 'wo' }] as never[], + [], + [], + ); + await expect(p).rejects.toMatchObject({ subject: 'wo' }); + }); +}); + +describe('the library writes to the console in one place only', () => { + const SRC = resolve(__dirname, '../../../src'); + // CLI and node-only helpers print on purpose; the sink is diagnostics.ts. + const ALLOWED = new Set([ + 'cli.ts', + 'cliShared.ts', + 'fileChoices.ts', + 'generateFixtures.ts', + 'diagnostics.ts', + ]); + const files = (dir: string): string[] => + readdirSync(dir).flatMap((n) => { + const p = join(dir, n); + if (statSync(p).isDirectory()) return n === 'generated' ? [] : files(p); + return p.endsWith('.ts') && !ALLOWED.has(n) ? [p] : []; + }); + + test('no console.* outside diagnostics.ts and the CLI', () => { + const code = (f: string) => + readFileSync(f, 'utf-8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); + const hits = files(SRC).filter((f) => + /\bconsole\.(log|warn|error|info)\(/.test(code(f)), + ); + expect(hits.map((f) => relative(SRC, f))).toEqual([]); + }); +}); diff --git a/tests/ts/unit/questionTypes/select_one.test.ts b/tests/ts/unit/questionTypes/select_one.test.ts index dc01890..de30682 100644 --- a/tests/ts/unit/questionTypes/select_one.test.ts +++ b/tests/ts/unit/questionTypes/select_one.test.ts @@ -87,9 +87,10 @@ describe('Select One Question Type', () => { ]; // 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/, - ); + await expect(convertAndParse(survey, [])).rejects.toMatchObject({ + code: 'choice-list-empty', + message: expect.stringContaining('list "missing_list" has no rows'), + }); }); test('converts select_one with relevance', async () => { @@ -164,9 +165,10 @@ describe('Select One Question Type', () => { ]; // 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/, - ); + await expect(convertAndParse(survey, [])).rejects.toMatchObject({ + code: 'choice-list-empty', + message: expect.stringContaining('list "missing_list" has no rows'), + }); }); test('converts select_one with relevance', async () => { diff --git a/tests/ts/unit/questionTypes/special.test.ts b/tests/ts/unit/questionTypes/special.test.ts index cd0687e..6a79a2b 100644 --- a/tests/ts/unit/questionTypes/special.test.ts +++ b/tests/ts/unit/questionTypes/special.test.ts @@ -6,9 +6,10 @@ describe('Special Question Types', () => { test('throws for unknown type (registry allowlist)', async () => { const survey = [{ type: 'unknown_type', name: 'q1', label: 'Question' }]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'unknown_type'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "unknown_type"'), + }); }); test('handles empty type as short text', async () => { diff --git a/tests/ts/unit/unimplementedTypes.test.ts b/tests/ts/unit/unimplementedTypes.test.ts index b2e1a49..a1dfc12 100644 --- a/tests/ts/unit/unimplementedTypes.test.ts +++ b/tests/ts/unit/unimplementedTypes.test.ts @@ -8,25 +8,28 @@ describe('Unimplemented XLSForm Types Validation', () => { { type: 'geopoint', name: 'location', label: 'Location' }, ]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'geopoint'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "geopoint"'), + }); }); test('throws error for unimplemented geotrace type', async () => { const survey = [{ type: 'geotrace', name: 'path', label: 'Path' }]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'geotrace'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "geotrace"'), + }); }); test('throws error for unimplemented geoshape type', async () => { const survey = [{ type: 'geoshape', name: 'area', label: 'Area' }]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'geoshape'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "geoshape"'), + }); }); test('throws error for unimplemented start-geopoint type', async () => { @@ -34,9 +37,10 @@ describe('Unimplemented XLSForm Types Validation', () => { { type: 'start-geopoint', name: 'start_loc', label: 'Start Location' }, ]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'start-geopoint'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "start-geopoint"'), + }); }); }); @@ -44,41 +48,46 @@ describe('Unimplemented XLSForm Types Validation', () => { test('throws error for unimplemented image type', async () => { const survey = [{ type: 'image', name: 'photo', label: 'Photo' }]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'image'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "image"'), + }); }); test('throws error for unimplemented audio type', async () => { const survey = [{ type: 'audio', name: 'recording', label: 'Recording' }]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'audio'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "audio"'), + }); }); test('throws error for unimplemented video type', async () => { const survey = [{ type: 'video', name: 'clip', label: 'Video' }]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'video'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "video"'), + }); }); test('throws error for unimplemented file type', async () => { const survey = [{ type: 'file', name: 'attachment', label: 'File' }]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'file'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "file"'), + }); }); test('throws error for unimplemented barcode type', async () => { const survey = [{ type: 'barcode', name: 'code', label: 'Barcode' }]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'barcode'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "barcode"'), + }); }); }); @@ -92,9 +101,10 @@ describe('Unimplemented XLSForm Types Validation', () => { }, ]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'background-audio'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "background-audio"'), + }); }); test('throws error for unimplemented csv-external type', async () => { @@ -102,25 +112,28 @@ describe('Unimplemented XLSForm Types Validation', () => { { type: 'csv-external', name: 'csv_data', label: 'CSV Data' }, ]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'csv-external'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "csv-external"'), + }); }); test('throws error for unimplemented phonenumber type', async () => { const survey = [{ type: 'phonenumber', name: 'phone', label: 'Phone' }]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'phonenumber'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "phonenumber"'), + }); }); test('throws error for unimplemented email type', async () => { const survey = [{ type: 'email', name: 'email_addr', label: 'Email' }]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'email'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "email"'), + }); }); }); @@ -193,9 +206,10 @@ describe('Unimplemented XLSForm Types Validation', () => { { type: 'text', name: 'q1', label: 'Question 1' }, ]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'calculate'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "calculate"'), + }); }); test('silently skips hidden type', async () => { @@ -218,9 +232,10 @@ describe('Unimplemented XLSForm Types Validation', () => { test('throws error for unknown type (registry allowlist)', async () => { const survey = [{ type: 'unknown_type', name: 'q1', label: 'Question' }]; - await expect(() => convertAndParse(survey)).rejects.toThrow( - "Unimplemented XLSForm type: 'unknown_type'. This type is not registered in the survey type registry.", - ); + await expect(() => convertAndParse(survey)).rejects.toMatchObject({ + code: 'type-unregistered', + message: expect.stringContaining('type "unknown_type"'), + }); }); }); }); diff --git a/tests/ts/unit/vocab.test.ts b/tests/ts/unit/vocab.test.ts index cba3b01..5af4d24 100644 --- a/tests/ts/unit/vocab.test.ts +++ b/tests/ts/unit/vocab.test.ts @@ -94,7 +94,7 @@ describe('convert with select_*_from_file', () => { [], ), ).rejects.toThrow( - /'own.csv' is not a registered vocabulary \(registered: iso_3166_1.csv\)/, + /"own.csv" is not a registered vocabulary \(registered: iso_3166_1.csv\)/, ); }); });