diff --git a/typescript/package.json b/typescript/package.json index 902d08b2..a6f4f9d4 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -22,6 +22,7 @@ "scripts": { "build": "tsc -p src", "build-all": "npm run build --workspaces", + "test": "tsc -p test && node --test out/validate.test.js", "prepare": "npm run build-all", "prepublishOnly": "node -e \"require('fs').copyFileSync('../SECURITY.md','SECURITY.md')\"", "postpublish": "node -e \"require('fs').unlinkSync('SECURITY.md')\"", diff --git a/typescript/src/ts/validate.ts b/typescript/src/ts/validate.ts index 89aebffa..02e6a13a 100644 --- a/typescript/src/ts/validate.ts +++ b/typescript/src/ts/validate.ts @@ -58,12 +58,53 @@ export function createTypeScriptJsonValidator(schema: const syntacticDiagnostics = program.getSyntacticDiagnostics(); const programDiagnostics = syntacticDiagnostics.length ? syntacticDiagnostics : program.getSemanticDiagnostics(); if (programDiagnostics.length) { - const diagnostics = programDiagnostics.map(d => typeof d.messageText === "string" ? d.messageText : d.messageText.messageText).join("\n"); + const checker = program.getTypeChecker(); + const diagnostics = programDiagnostics.map(d => { + const message = ts.flattenDiagnosticMessageText(d.messageText, "\n"); + // TS error 2740 truncates the missing-properties list to 4 items ("and N more"). + // Use the type checker to reconstruct the full list of missing required properties. + if (d.code === 2740 && d.file && d.start !== undefined) { + return expandMissingPropertiesMessage(checker, d.file, d.start) ?? message; + } + return message; + }).join("\n"); return error(diagnostics); } return success(jsonObject as T); } + /** + * For TypeScript error 2740 (missing required properties, truncated with "and N more"), + * uses the type checker to compute the full list of missing required properties from the + * variable declaration at `position` in `file`. Returns `undefined` if the declaration + * cannot be located or yields no missing properties (fallback to the original message). + */ + function expandMissingPropertiesMessage(checker: ts.TypeChecker, file: ts.SourceFile, position: number): string | undefined { + for (const stmt of file.statements) { + if (ts.isVariableStatement(stmt)) { + for (const decl of stmt.declarationList.declarations) { + // Match the specific declaration that spans the diagnostic position. + // Use getStart() to exclude leading trivia from the range check. + if (decl.getStart(file) <= position && position <= decl.end && + decl.type && ts.isTypeReferenceNode(decl.type) && decl.initializer) { + const targetType = checker.getTypeAtLocation(decl.type); + const sourceType = checker.getTypeAtLocation(decl.initializer); + const sourceProps = new Set(sourceType.getProperties().map(p => p.name)); + const missingProps = targetType.getProperties() + .filter(p => !(p.flags & ts.SymbolFlags.Optional) && !sourceProps.has(p.name)) + .map(p => p.name); + if (missingProps.length > 0) { + const srcStr = checker.typeToString(sourceType, undefined, ts.TypeFormatFlags.NoTruncation); + const tgtStr = checker.typeToString(targetType, undefined, ts.TypeFormatFlags.NoTruncation); + return `Type '${srcStr}' is missing the following properties from type '${tgtStr}': ${missingProps.join(", ")}`; + } + } + } + } + } + return undefined; + } + function createModuleTextFromJson(jsonObject: object) { return success(`import { ${typeName} } from './schema';\nconst json: ${typeName} = ${JSON.stringify(jsonObject, undefined, 2)};\n`); } diff --git a/typescript/test/tsconfig.json b/typescript/test/tsconfig.json new file mode 100644 index 00000000..c98e759b --- /dev/null +++ b/typescript/test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "module": "node16", + "moduleResolution": "node16", + "types": ["node"], + "esModuleInterop": true, + "outDir": "../out", + "rootDir": ".", + "skipLibCheck": true, + "strict": true + }, + "include": ["./**/*.ts"] +} diff --git a/typescript/test/validate.test.ts b/typescript/test/validate.test.ts new file mode 100644 index 00000000..31ef89fa --- /dev/null +++ b/typescript/test/validate.test.ts @@ -0,0 +1,196 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { createTypeScriptJsonValidator } from "../dist/ts/index.js"; + +// --------------------------------------------------------------------------- +// Shared schemas +// --------------------------------------------------------------------------- + +// Schema with 8 required properties — enough to trigger TS error 2740 +// ("and N more") when several are absent. +const largeSchema = ` +export interface Doc { + id: number; + title: string; + slug: string; + abstract: string; + description: string; + author: string; + date: string; + category: string; +}`; + +// Schema with a mix of required and optional properties. +const mixedSchema = ` +export interface Item { + id: number; + name: string; + tag?: string; + extra1: string; + extra2: string; + extra3: string; + extra4: string; + extra5: string; +}`; + +// Small schema for simpler error cases. +const smallSchema = ` +export interface Point { + x: number; + y: number; +}`; + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- +function validatorFor(schema: string, typeName: string) { + return createTypeScriptJsonValidator(schema, typeName); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("createTypeScriptJsonValidator", () => { + + describe("valid objects", () => { + it("accepts an object that satisfies all required properties", () => { + const v = validatorFor(largeSchema, "Doc"); + const result = v.validate({ + id: 1, title: "T", slug: "s", abstract: "a", + description: "d", author: "au", date: "2024-01-01", category: "c" + }); + assert.ok(result.success, "expected validation to succeed"); + }); + + it("accepts an object that supplies optional properties", () => { + const v = validatorFor(mixedSchema, "Item"); + const result = v.validate({ + id: 1, name: "n", tag: "t", + extra1: "a", extra2: "b", extra3: "c", extra4: "d", extra5: "e" + }); + assert.ok(result.success, "expected validation to succeed with optional props"); + }); + + it("accepts an object that omits optional properties", () => { + const v = validatorFor(mixedSchema, "Item"); + const result = v.validate({ + id: 1, name: "n", + extra1: "a", extra2: "b", extra3: "c", extra4: "d", extra5: "e" + }); + assert.ok(result.success, "expected validation to succeed without optional props"); + }); + }); + + describe("type mismatch errors (backward-compatible: unchanged code path)", () => { + it("reports a type error when a property has the wrong type", () => { + const v = validatorFor(smallSchema, "Point"); + const result = v.validate({ x: "not-a-number", y: 2 }); + assert.ok(!result.success, "expected validation to fail"); + assert.ok( + result.message.includes("not assignable to type"), + `expected type-mismatch message, got: ${result.message}` + ); + }); + }); + + describe("single missing required property (TS error 2741, unchanged code path)", () => { + it("reports the missing property by name", () => { + const v = validatorFor(smallSchema, "Point"); + const result = v.validate({ x: 1 }); + assert.ok(!result.success, "expected validation to fail"); + assert.ok( + result.message.includes("'y'") && result.message.includes("missing"), + `expected missing-property message, got: ${result.message}` + ); + }); + }); + + describe("2–5 missing required properties (TS error 2739, unchanged code path)", () => { + it("lists all missing properties without truncation for 4 missing", () => { + const schema = `export interface T { a: number; b: string; c: boolean; d: number; e: string; }`; + const v = validatorFor(schema, "T"); + // Provide only 'a', missing b/c/d/e + const result = v.validate({ a: 1 }); + assert.ok(!result.success, "expected validation to fail"); + for (const prop of ["b", "c", "d", "e"]) { + assert.ok( + result.message.includes(prop), + `expected '${prop}' in error message, got: ${result.message}` + ); + } + }); + }); + + describe("6+ missing required properties (TS error 2740 — the truncation fix)", () => { + it("returns the full list of missing properties without truncation", () => { + const v = validatorFor(largeSchema, "Doc"); + // Provide only id and title; slug/abstract/description/author/date/category are missing + const result = v.validate({ id: 1, title: "Hello" }); + assert.ok(!result.success, "expected validation to fail"); + + const missing = ["slug", "abstract", "description", "author", "date", "category"]; + for (const prop of missing) { + assert.ok( + result.message.includes(prop), + `expected '${prop}' in error message but got: ${result.message}` + ); + } + assert.ok( + !result.message.includes("more"), + `error message should not contain "more" (truncation indicator), got: ${result.message}` + ); + }); + + it("excludes optional properties from the missing-properties list", () => { + const v = validatorFor(mixedSchema, "Item"); + // Provide only id — name/extra1..5 are missing required; tag is optional + const result = v.validate({ id: 1 }); + assert.ok(!result.success, "expected validation to fail"); + + const requiredMissing = ["name", "extra1", "extra2", "extra3", "extra4", "extra5"]; + for (const prop of requiredMissing) { + assert.ok( + result.message.includes(prop), + `expected '${prop}' in error message, got: ${result.message}` + ); + } + assert.ok( + !result.message.includes("tag"), + `optional property 'tag' should not appear in missing-properties message, got: ${result.message}` + ); + }); + + it("does not include 'and N more' truncation text", () => { + const v = validatorFor(largeSchema, "Doc"); + const result = v.validate({}); + assert.ok(!result.success, "expected validation to fail"); + assert.ok( + !/ and \d+ more/.test(result.message), + `message should not contain "and N more", got: ${result.message}` + ); + }); + }); + + describe("getSchemaText and getTypeName", () => { + it("returns the original schema text", () => { + const v = validatorFor(smallSchema, "Point"); + assert.strictEqual(v.getSchemaText(), smallSchema); + }); + + it("returns the correct type name", () => { + const v = validatorFor(smallSchema, "Point"); + assert.strictEqual(v.getTypeName(), "Point"); + }); + }); + + describe("createModuleTextFromJson", () => { + it("produces valid TypeScript module text for a simple object", () => { + const v = validatorFor(smallSchema, "Point"); + const result = v.createModuleTextFromJson({ x: 1, y: 2 }); + assert.ok(result.success, "expected module text creation to succeed"); + assert.ok(result.data.includes("import { Point } from './schema'")); + assert.ok(result.data.includes("const json: Point =")); + }); + }); +});