From 245be7d1f620fa530e7fbf25e339c326b65b0fec Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sun, 8 Nov 2026 19:04:56 -0500 Subject: [PATCH 1/2] Apply suggested fix to scripts/contracts/generate.mjs from Copilot Autofix Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- scripts/contracts/generate.mjs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/scripts/contracts/generate.mjs b/scripts/contracts/generate.mjs index dfc4410f4..c4da62764 100644 --- a/scripts/contracts/generate.mjs +++ b/scripts/contracts/generate.mjs @@ -72,11 +72,31 @@ export async function generateArtifacts(source, target = 'all') { addFormats(ajv); ajv.addSchema(schemaDocument(doc)); const schemaRef = name => `urn:lightnvr:contracts#/$defs/${name.replaceAll('~', '~0').replaceAll('/', '~1')}`; + const toIdentifierSuffix = value => { + const base = String(value).replace(/[^A-Za-z0-9_$]/g, '_'); + const headSafe = /^[A-Za-z_$]/.test(base) ? base : `_${base}`; + const reserved = new Set(['await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', + 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'new', 'null', 'return', 'super', + 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield']); + return reserved.has(headSafe) ? `_${headSafe}` : headSafe; + }; + const usedIdentifiers = new Set(); + const schemaIdentifiers = new Map(names.map(name => { + const base = toIdentifierSuffix(name); + let candidate = base; + let n = 2; + while (usedIdentifiers.has(candidate)) candidate = `${base}_${n++}`; + usedIdentifiers.add(candidate); + return [name, candidate]; + })); // Validate every component, even if no runtime export was selected for it. for (const name of Object.keys(doc.components?.schemas || {})) ajv.getSchema(schemaRef(name)); - const exports = Object.fromEntries(names.map(name => [`__contract_${name}`, schemaRef(name)])); - let raw = normalizeErrorAccumulators(standaloneCode(ajv, exports)).replace(/export const (__contract_[A-Za-z0-9_]+) = /g, 'const $1 = '); - raw += '\n' + names.map(name => `export function validate${name}(value) {\n const valid = __contract_${name}(value);\n validate${name}.errors = valid ? null : (__contract_${name}.errors || []).map(({keyword, schemaPath}) => ({keyword, schemaPath}));\n return valid;\n}\nvalidate${name}.errors = null;`).join('\n'); + const exports = Object.fromEntries(names.map(name => [`__contract_${schemaIdentifiers.get(name)}`, schemaRef(name)])); + let raw = normalizeErrorAccumulators(standaloneCode(ajv, exports)).replace(/export const (__contract_[A-Za-z0-9_$]+) = /g, 'const $1 = '); + raw += '\n' + names.map(name => { + const id = schemaIdentifiers.get(name); + return `export function validate${id}(value) {\n const valid = __contract_${id}(value);\n validate${id}.errors = valid ? null : (__contract_${id}.errors || []).map(({keyword, schemaPath}) => ({keyword, schemaPath}));\n return valid;\n}\nvalidate${id}.errors = null;`; + }).join('\n'); const bundle = buildSync({metafile: true, preserveSymlinks: true, stdin: {contents: raw, resolveDir: root, sourcefile: 'standalone-validators.js'}, bundle: true, write: false, minifySyntax: true, format: 'esm', platform: 'browser', target: 'es2020', legalComments: 'none', logLevel: 'silent'}); if (Object.values(bundle.metafile.outputs).some(output => output.imports.length)) throw new Error('Generated validators contain unresolved imports'); @@ -100,7 +120,7 @@ export async function generateArtifacts(source, target = 'all') { files.set(webPaths[2], '// Generated by scripts/contracts/generate.mjs; do not edit.\nimport type { components } from "./schema";\n' + 'export interface ContractValidationError { readonly keyword: string; readonly schemaPath: string }\n' + 'export interface ContractValidator { (value: unknown): value is T; errors: readonly ContractValidationError[] | null }\n' + - names.map(name => `export declare const validate${name}: ContractValidator;`).join('\n') + '\n'); + names.map(name => `export declare const validate${schemaIdentifiers.get(name)}: ContractValidator;`).join('\n') + '\n'); } return files; } From ca58efeba7883cbc34f7cd709b65b02291f4dff5 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sun, 8 Nov 2026 19:10:08 -0500 Subject: [PATCH 2/2] fix(contracts): preserve validated schema export names --- scripts/contracts/generate.mjs | 31 ++++------------- tests/contracts/tooling/generator.test.mjs | 39 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/scripts/contracts/generate.mjs b/scripts/contracts/generate.mjs index c4da62764..2ce4a8f31 100644 --- a/scripts/contracts/generate.mjs +++ b/scripts/contracts/generate.mjs @@ -62,6 +62,9 @@ export async function generateArtifacts(source, target = 'all') { } if (target !== 'native') { const doc = JSON.parse(await readFile(source, 'utf8')); + // validateSource rejects selected names outside [A-Za-z][A-Za-z0-9_]*, + // including when this list defaults to all components. Keep names intact: + // the __contract_ and validate prefixes also make reserved words safe. const names = [...(doc['x-lightnvr-validators'] || Object.keys(doc.components?.schemas || {}))].sort(); files.set(webPaths[0], astToString(await openapiTS(doc, {alphabetize: true, defaultNonNullable: false}))); // Runtime consumers need validity, not an exhaustive payload error report. @@ -72,31 +75,11 @@ export async function generateArtifacts(source, target = 'all') { addFormats(ajv); ajv.addSchema(schemaDocument(doc)); const schemaRef = name => `urn:lightnvr:contracts#/$defs/${name.replaceAll('~', '~0').replaceAll('/', '~1')}`; - const toIdentifierSuffix = value => { - const base = String(value).replace(/[^A-Za-z0-9_$]/g, '_'); - const headSafe = /^[A-Za-z_$]/.test(base) ? base : `_${base}`; - const reserved = new Set(['await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', - 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'new', 'null', 'return', 'super', - 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield']); - return reserved.has(headSafe) ? `_${headSafe}` : headSafe; - }; - const usedIdentifiers = new Set(); - const schemaIdentifiers = new Map(names.map(name => { - const base = toIdentifierSuffix(name); - let candidate = base; - let n = 2; - while (usedIdentifiers.has(candidate)) candidate = `${base}_${n++}`; - usedIdentifiers.add(candidate); - return [name, candidate]; - })); // Validate every component, even if no runtime export was selected for it. for (const name of Object.keys(doc.components?.schemas || {})) ajv.getSchema(schemaRef(name)); - const exports = Object.fromEntries(names.map(name => [`__contract_${schemaIdentifiers.get(name)}`, schemaRef(name)])); - let raw = normalizeErrorAccumulators(standaloneCode(ajv, exports)).replace(/export const (__contract_[A-Za-z0-9_$]+) = /g, 'const $1 = '); - raw += '\n' + names.map(name => { - const id = schemaIdentifiers.get(name); - return `export function validate${id}(value) {\n const valid = __contract_${id}(value);\n validate${id}.errors = valid ? null : (__contract_${id}.errors || []).map(({keyword, schemaPath}) => ({keyword, schemaPath}));\n return valid;\n}\nvalidate${id}.errors = null;`; - }).join('\n'); + const exports = Object.fromEntries(names.map(name => [`__contract_${name}`, schemaRef(name)])); + let raw = normalizeErrorAccumulators(standaloneCode(ajv, exports)).replace(/export const (__contract_[A-Za-z0-9_]+) = /g, 'const $1 = '); + raw += '\n' + names.map(name => `export function validate${name}(value) {\n const valid = __contract_${name}(value);\n validate${name}.errors = valid ? null : (__contract_${name}.errors || []).map(({keyword, schemaPath}) => ({keyword, schemaPath}));\n return valid;\n}\nvalidate${name}.errors = null;`).join('\n'); const bundle = buildSync({metafile: true, preserveSymlinks: true, stdin: {contents: raw, resolveDir: root, sourcefile: 'standalone-validators.js'}, bundle: true, write: false, minifySyntax: true, format: 'esm', platform: 'browser', target: 'es2020', legalComments: 'none', logLevel: 'silent'}); if (Object.values(bundle.metafile.outputs).some(output => output.imports.length)) throw new Error('Generated validators contain unresolved imports'); @@ -120,7 +103,7 @@ export async function generateArtifacts(source, target = 'all') { files.set(webPaths[2], '// Generated by scripts/contracts/generate.mjs; do not edit.\nimport type { components } from "./schema";\n' + 'export interface ContractValidationError { readonly keyword: string; readonly schemaPath: string }\n' + 'export interface ContractValidator { (value: unknown): value is T; errors: readonly ContractValidationError[] | null }\n' + - names.map(name => `export declare const validate${schemaIdentifiers.get(name)}: ContractValidator;`).join('\n') + '\n'); + names.map(name => `export declare const validate${name}: ContractValidator;`).join('\n') + '\n'); } return files; } diff --git a/tests/contracts/tooling/generator.test.mjs b/tests/contracts/tooling/generator.test.mjs index 2dbd42f8b..247e40987 100644 --- a/tests/contracts/tooling/generator.test.mjs +++ b/tests/contracts/tooling/generator.test.mjs @@ -151,6 +151,45 @@ test('native generator rejects malformed validator names with validation errors' } }); +test('reserved-word schema names retain their prefixed public validator names', async () => { + const doc=JSON.parse(await readFile(source,'utf8')); + const names=['class','default','await','yield','Class','with_underscore']; + for(const name of names)doc.components.schemas[name]={type:'string'}; + doc['x-lightnvr-validators']=names; + const candidate=path.join(directory,'reserved-validator-names.json'); + await writeFile(candidate,JSON.stringify(doc)); + const generated=await generateArtifacts(candidate,'web'); + const modulePath=path.join(directory,'reserved-validators.mjs'); + await writeFile(modulePath,generated.get(webPaths[1])); + const module=await import(pathToFileURL(modulePath)); + assert.deepEqual(Object.keys(module).sort(),names.map(name=>`validate${name}`).sort()); + for(const name of names){ + const validate=module[`validate${name}`]; + assert.equal(validate('valid'),true); + assert.equal(validate(null),false); + assert.match(generated.get(webPaths[2]),new RegExp(`export declare const validate${name}:`)); + assert.ok(generated.get(webPaths[2]).includes(`components["schemas"]["${name}"]`)); + } +}); + +test('web generation rejects unsafe selected schema names before interpolation', async () => { + const original=JSON.parse(await readFile(source,'utf8')); + for(const name of ['with-dash','with/slash','with~tilde','1Thing','_Thing','$Thing',"Thing);throw new Error('injected');//"]){ + for(const explicit of [true,false]){ + const doc=structuredClone(original); + doc.components.schemas[name]={type:'string'}; + if(explicit)doc['x-lightnvr-validators']=[name]; + else delete doc['x-lightnvr-validators']; + const candidate=path.join(directory,'unsafe-validator-name.json'); + await writeFile(candidate,JSON.stringify(doc)); + await assert.rejects(generateArtifacts(candidate,'web'),error=>{ + assert.match(String(error.stderr),/Validator names must identify named component schemas/); + return true; + }); + } + } +}); + test('native generator reads UTF-8 schemas even under an ASCII locale', async () => { const doc = JSON.parse(await readFile(source, 'utf8')); doc.info.description = 'Camera café 🐱';