From daa0a2415f0f3a276e624b45c4e4e801b793bc71 Mon Sep 17 00:00:00 2001 From: sax3l Date: Mon, 2 Nov 2026 02:03:48 +0100 Subject: [PATCH] feat: add evidence-checked document exports --- .../export-validated-output.ts | 52 +++++++++++++++++++ .../export-validated-output.test.ts | 41 +++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/document-workbench/export-validated-output.ts create mode 100644 tests/document-workbench/export-validated-output.test.ts diff --git a/src/document-workbench/export-validated-output.ts b/src/document-workbench/export-validated-output.ts new file mode 100644 index 0000000..ff25ec6 --- /dev/null +++ b/src/document-workbench/export-validated-output.ts @@ -0,0 +1,52 @@ +import { validateStructuredOutput, type FieldEvidence, type OutputFieldContract, type TextSource, type ValidationIssue } from './structured-output-validation'; + +export type ValidatedExport = + | { status: 'ready'; mediaType: 'application/json' | 'text/csv'; content: string } + | { status: 'needs_review'; issues: ValidationIssue[] }; + +/** + * Serialize a single, flat record after rechecking its evidence against the + * supplied source text. This is an internal serializer, not an authorization, + * completeness, semantic-correctness or source-authenticity check. + * CSV rejects formula-like text rather than silently changing customer data; + * JSON preserves it exactly. CSV also rejects absent optional fields so that + * absence is never silently converted into an empty string. + */ +export function exportValidatedOutput(input: { + format: 'json' | 'csv'; + fields: readonly OutputFieldContract[]; + value: unknown; + sources: readonly TextSource[]; + evidence: readonly FieldEvidence[]; +}): ValidatedExport { + if (input.format !== 'json' && input.format !== 'csv') { + return { status: 'needs_review', issues: [{ field: '', code: 'unsupported_export_format' }] }; + } + const validation = validateStructuredOutput(input); + if (validation.status !== 'mechanically_validated') return validation; + + const value = input.value as Record; + const ordered: Record = Object.create(null) as Record; + for (const field of input.fields) { + if (Object.prototype.hasOwnProperty.call(value, field.name)) ordered[field.name] = value[field.name]!; + else if (input.format === 'csv') { + return { status: 'needs_review', issues: [{ field: field.name, code: 'csv_missing_value' }] }; + } + } + if (input.format === 'json') { + return { status: 'ready', mediaType: 'application/json', content: `${JSON.stringify(ordered, null, 2)}\n` }; + } + + // Spreadsheet applications may interpret these prefixes as formulas even + // in quoted CSV cells. Treat leading control characters as unsafe as well. + const unsafe = (cell: string): boolean => /^[\t\r\n]/u.test(cell) || /^\s*[=+@-]/u.test(cell); + for (const field of input.fields) { + if (unsafe(field.name) || (field.type === 'string' && unsafe(ordered[field.name] as string))) { + return { status: 'needs_review', issues: [{ field: field.name, code: 'csv_unsafe_cell' }] }; + } + } + const quote = (cell: string): string => `"${cell.replaceAll('"', '""')}"`; + const header = input.fields.map((field) => quote(field.name)).join(','); + const row = input.fields.map((field) => quote(String(ordered[field.name]))).join(','); + return { status: 'ready', mediaType: 'text/csv', content: `${header}\r\n${row}\r\n` }; +} diff --git a/tests/document-workbench/export-validated-output.test.ts b/tests/document-workbench/export-validated-output.test.ts new file mode 100644 index 0000000..287180c --- /dev/null +++ b/tests/document-workbench/export-validated-output.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { exportValidatedOutput } from '../../src/document-workbench/export-validated-output'; + +const fields = [ + { name: 'name', type: 'string' as const, required: true }, + { name: 'count', type: 'integer' as const, required: true }, +]; +const sources = [{ id: 'source', text: 'Acme, "North"\n-2' }]; +const evidence = [ + { field: 'name', sourceId: 'source', start: 0, end: 13, transform: 'exact' as const }, + { field: 'count', sourceId: 'source', start: 14, end: 16, transform: 'integer' as const }, +]; +const input = { fields, sources, evidence, value: { name: 'Acme, "North"', count: -2 } }; + +describe('validated document exports', () => { + it('preserves JSON values and produces correctly escaped, ordered CSV', () => { + const json = exportValidatedOutput({ ...input, format: 'json' }); + expect(json.status).toBe('ready'); + if (json.status === 'ready') expect(JSON.parse(json.content)).toEqual(input.value); + expect(exportValidatedOutput({ ...input, format: 'csv' })).toEqual({ + status: 'ready', mediaType: 'text/csv', content: '"name","count"\r\n"Acme, ""North""","-2"\r\n', + }); + }); + + it('revalidates evidence and refuses unsupported or absent data', () => { + expect(exportValidatedOutput({ ...input, value: { ...input.value, name: 'invented' }, format: 'json' }).status).toBe('needs_review'); + expect(exportValidatedOutput({ ...input, evidence: [], format: 'csv' }).status).toBe('needs_review'); + const optional = { ...input, fields: [...fields, { name: 'note', type: 'string' as const, required: false }] }; + expect(exportValidatedOutput({ ...optional, format: 'csv' })).toMatchObject({ status: 'needs_review', issues: [{ field: 'note', code: 'csv_missing_value' }] }); + expect(exportValidatedOutput({ ...optional, format: 'json' }).status).toBe('ready'); + expect(exportValidatedOutput({ ...input, format: 'xml' as 'json' })).toMatchObject({ status: 'needs_review', issues: [{ code: 'unsupported_export_format' }] }); + }); + + it('rejects formula-like CSV values and headers without changing JSON', () => { + const bad = { fields: [{ name: 'cell', type: 'string' as const, required: true }], sources: [{ id: 's', text: ' =1+1' }], evidence: [{ field: 'cell', sourceId: 's', start: 0, end: 6, transform: 'exact' as const }], value: { cell: ' =1+1' } }; + expect(exportValidatedOutput({ ...bad, format: 'csv' })).toMatchObject({ status: 'needs_review', issues: [{ code: 'csv_unsafe_cell' }] }); + expect(exportValidatedOutput({ ...bad, format: 'json' }).status).toBe('ready'); + const header = { fields: [{ name: '=header', type: 'string' as const, required: true }], sources: [{ id: 's', text: 'safe' }], evidence: [{ field: '=header', sourceId: 's', start: 0, end: 4, transform: 'exact' as const }], value: { '=header': 'safe' } }; + expect(exportValidatedOutput({ ...header, format: 'csv' })).toMatchObject({ status: 'needs_review', issues: [{ code: 'csv_unsafe_cell' }] }); + }); +}); -- 2.54.0