diff --git a/cli.js b/cli.js index fe2ac034ae1..2d3aa193a0e 100644 --- a/cli.js +++ b/cli.js @@ -838,6 +838,129 @@ async function taskMaintenance() { printStrictAndNotStrictAjvValidatedSchemas() } +async function taskCoverage() { + const javaScriptCoverageName = 'schema.json.translated.to.js' + const javaScriptCoverageNameWithPath = path.join( + `${temporaryCoverageDir}/${javaScriptCoverageName}`, + ) + + /** + * Translate one JSON schema file to javascript via AJV validator. + * And run the positive and negative test files with it. + * @param {string} processOnlyThisOneSchemaFile The schema file that need to process + */ + const generateCoverage = async (processOnlyThisOneSchemaFile) => { + const schemaVersion = showSchemaVersions() + let jsonName + let mainSchema + let mainSchemaJsonId + let isThisWithExternalSchema + let validations + + // Compile JSON schema to javascript and write it to disk. + const processSchemaFile = async (/** @type {Schema} */ schema) => { + jsonName = schema.jsonName + // Get possible options define in schema-validation.json + const { + unknownFormatsList, + unknownKeywordsList, + externalSchemaWithPathList, + } = getOption(schema.jsonName) + + // select the correct AJV object for this schema + mainSchema = schema.jsonObj + const versionObj = schemaVersion.getObj(mainSchema) + + // External schema present to be included? + const multipleSchema = [] + isThisWithExternalSchema = externalSchemaWithPathList.length > 0 + if (isThisWithExternalSchema) { + // There is an external schema that need to be included. + externalSchemaWithPathList.forEach((x) => { + multipleSchema.push(readJsonFile(x.toString())) + }) + // Also add the 'root' schema + multipleSchema.push(mainSchema) + } + + // Get the correct AJV version + const ajvSelected = factoryAJV({ + schemaName: versionObj?.schemaName, + unknownFormatsList, + fullStrictMode: !schemaValidation.ajvNotStrictMode.includes(jsonName), + standAloneCode: true, + standAloneCodeWithMultipleSchema: multipleSchema, + }) + + // AJV must ignore these keywords + unknownKeywordsList?.forEach((x) => { + ajvSelected.addKeyword(x) + }) + + let moduleCode + if (isThisWithExternalSchema) { + // Multiple schemas are combine to one JavaScript file. + // Must use the root $id/id to call the correct 'main' schema in JavaScript code + mainSchemaJsonId = + schemaVersion.getObj(mainSchema).schemaName === 'draft-04' + ? mainSchema.id + : mainSchema.$id + if (!mainSchemaJsonId) { + throwWithErrorText([`Missing $id or id in ${jsonName}`]) + } + moduleCode = AjvStandalone(ajvSelected) + } else { + // Single schema + mainSchemaJsonId = undefined + moduleCode = AjvStandalone(ajvSelected, ajvSelected.compile(mainSchema)) + } + + // Prettify the JavaScript module code + const prettierOptions = await prettier.resolveConfig(process.cwd()) + fs.writeFileSync( + javaScriptCoverageNameWithPath, + prettier.format(moduleCode, { + ...prettierOptions, + parser: 'babel', + printWidth: 200, + }), + ) + // Now use this JavaScript as validation in the positive and negative test + validations = readJsonFile(javaScriptCoverageNameWithPath) + } + + // Load the Javascript file from the disk and run it with the JSON test file. + // This will generate the NodeJS coverage data in the background. + const processTestFile = (/** @type {Schema} */ schema) => { + // Test only for the code coverage. Not for the validity of the test. + if (isThisWithExternalSchema) { + // Must use the root $id/id to call the correct schema JavaScript code + const validateRootSchema = validations[mainSchemaJsonId] + validateRootSchema?.(schema.jsonObj) + } else { + // Single schema does not need $id + validations(schema.jsonObj) + } + } + + await localSchemaFileAndTestFile( + { + schemaForTestScan: processSchemaFile, + positiveTestScan: processTestFile, + negativeTestScan: processTestFile, + }, + { skipReadFile: false, processOnlyThisOneSchemaFile }, + ) + } + + const schemaNameToBeCoverage = argv.SchemaName + if (!schemaNameToBeCoverage) { + throwWithErrorText(['Must start "make" file with --SchemaName parameter.']) + } + await generateCoverage(schemaNameToBeCoverage) + log.ok('OK') +} + function lintSchemaHasCorrectMetadata() { let countScan = 0 let totalMismatchIds = 0 @@ -946,53 +1069,28 @@ function lintSchemaNoSmartQuotes() { log.writeln(`Total files scan: ${countScan}`) } -function assertCatalogJsonHasNoPoorlyWordedFields() { +function lintTopLevelRefIsStandalone() { let countScan = 0 + localSchemaFileAndTestFile( + { + schemaOnlyScan(schema) { + if (schema.jsonObj.$ref?.startsWith('http')) { + for (const [member] of Object.entries(schema.jsonObj)) { + if (member !== '$ref') { + throwWithErrorText([ + `Schemas that reference a remote schema must only have $ref as a property. Found property "${member}" for ${schema.jsonName}`, + ]) + } + } + } - for (const entry of catalog.schemas) { - if ( - schemaValidation.catalogEntryNoLintNameOrDescription.includes(entry.url) - ) { - continue - } - - const schemaName = new URL(entry.url).pathname.slice(1) - - for (const property of ['name', 'description']) { - if ( - /$[,. \t-]/u.test(entry?.[property]) || - /[,. \t-]$/u.test(entry?.[property]) - ) { - ++countScan - - throwWithErrorText([ - `Catalog entry .${property}: Should not start or end with punctuation or whitespace (${schemaName})`, - ]) - } - } - - for (const property of ['name', 'description']) { - if (entry?.[property]?.toLowerCase()?.includes('schema')) { - ++countScan - - throwWithErrorText([ - `Catalog entry .${property}: Should not contain the string 'schema'. In most cases, this word is extraneous and the meaning is implied (${schemaName})`, - ]) - } - } - - for (const property of ['name', 'description']) { - if (entry?.[property]?.toLowerCase()?.includes('\n')) { ++countScan + }, + }, + { skipReadFile: false, ignoreSkiptest: true }, + ) - throwWithErrorText([ - `Catalog entry .${property}: Should not contain a newline character. In editors like VSCode, the newline is not rendered. (${schemaName})`, - ]) - } - } - } - - log.writeln(`Total found files: ${countScan}`) + log.ok(`All urls tested OK. Total: ${countScan}`) } function testAjv() { @@ -1020,28 +1118,18 @@ async function remoteTestAjv() { log.writeln(`Total schemas validated with AJV: ${countScan}`) } -function assertSchemaHasNoBom() { - let countScan = 0 - - localSchemaFileAndTestFile( - { - schemaOnlyScan(schema) { - countScan++ - testSchemaFileForBOM(schema) - }, - }, - { fullScanAllFiles: true, skipReadFile: false }, - ) - - log.ok( - `no BOM file found in all schema files. Total files scan: ${countScan}`, - ) -} - async function remoteAssertSchemaHasNoBom() { await remoteSchemaFile(testSchemaFileForBOM, false) } +async function remotePrintCountSchemaVersions() { + const x = showSchemaVersions() + await remoteSchemaFile((schema) => { + x.process_data(schema) + }, false) + x.process_data_done() +} + async function assertCatalogJsonPassesJsonLint() { jsonlint.parse( await fs.promises.readFile('./src/api/json/catalog.json', 'utf-8'), @@ -1072,67 +1160,112 @@ function assertCatalogJsonValidatesAgainstJsonSchema() { } } -function assertSchemaHasNoDuplicatedPropertyKeys() { - let countScan = 0 - const findDuplicatedProperty = (/** @type {Schema} */ schema) => { - ++countScan - - // Can only test JSON files for duplicates. - const fileExtension = schema.urlOrFilePath.split('.').pop() - if (fileExtension !== 'json') return - - // TODO: Workaround for https://github.com/prantlf/jsonlint/issues/23 - if (schema.jsonName === 'tslint.json') { - return - } +function assertCatalogJsonHasNoDuplicateNames() { + /** @type {string[]} */ + const schemaNames = catalog.schemas.map((entry) => entry.name) + /** @type {string[]} */ + const duplicateSchemaNames = [] - try { - jsonlint.parse(schema.rawFile, { - ignoreBOM: false, - ignoreComments: false, - ignoreTrailingCommas: false, - allowSingleQuotedStrings: false, - allowDuplicateObjectKeys: false, - }) - } catch (err) { - throwWithErrorText([`Test file: ${schema.urlOrFilePath}`, err]) + for (const schemaName of schemaNames) { + const matches = schemaNames.filter((item) => item === schemaName) + if (matches.length > 1 && !duplicateSchemaNames.includes(schemaName)) { + duplicateSchemaNames.push(schemaName) } } - localSchemaFileAndTestFile( - { - schemaForTestScan: findDuplicatedProperty, - positiveTestScan: findDuplicatedProperty, - negativeTestScan: findDuplicatedProperty, - }, - { skipReadFile: false }, - ) - log.ok( - `No duplicated property key found in JSON files. Total files scan: ${countScan}`, - ) -} -function lintTopLevelRefIsStandalone() { - let countScan = 0 - localSchemaFileAndTestFile( - { - schemaOnlyScan(schema) { - if (schema.jsonObj.$ref?.startsWith('http')) { - for (const [member] of Object.entries(schema.jsonObj)) { - if (member !== '$ref') { - throwWithErrorText([ - `Schemas that reference a remote schema must only have $ref as a property. Found property "${member}" for ${schema.jsonName}`, - ]) - } - } - } + if (duplicateSchemaNames.length > 0) { + throwWithErrorText([ + `Found duplicates: ${JSON.stringify(duplicateSchemaNames)}`, + ]) + } +} + +function assertCatalogJsonHasNoPoorlyWordedFields() { + let countScan = 0 + + for (const entry of catalog.schemas) { + if ( + schemaValidation.catalogEntryNoLintNameOrDescription.includes(entry.url) + ) { + continue + } + + const schemaName = new URL(entry.url).pathname.slice(1) + + for (const property of ['name', 'description']) { + if ( + /$[,. \t-]/u.test(entry?.[property]) || + /[,. \t-]$/u.test(entry?.[property]) + ) { + ++countScan + + throwWithErrorText([ + `Catalog entry .${property}: Should not start or end with punctuation or whitespace (${schemaName})`, + ]) + } + } + for (const property of ['name', 'description']) { + if (entry?.[property]?.toLowerCase()?.includes('schema')) { ++countScan - }, - }, - { skipReadFile: false, ignoreSkiptest: true }, - ) - log.ok(`All urls tested OK. Total: ${countScan}`) + throwWithErrorText([ + `Catalog entry .${property}: Should not contain the string 'schema'. In most cases, this word is extraneous and the meaning is implied (${schemaName})`, + ]) + } + } + + for (const property of ['name', 'description']) { + if (entry?.[property]?.toLowerCase()?.includes('\n')) { + ++countScan + + throwWithErrorText([ + `Catalog entry .${property}: Should not contain a newline character. In editors like VSCode, the newline is not rendered. (${schemaName})`, + ]) + } + } + } + + log.writeln(`Total found files: ${countScan}`) +} + +function assertCatalogJsonHasCorrectFileMatchPath() { + for (const schema of catalog.schemas) { + schema.fileMatch?.forEach((fileMatchItem) => { + if (fileMatchItem.includes('/')) { + // A folder must start with **/ + if (!fileMatchItem.startsWith('**/')) { + throwWithErrorText([ + `fileMatch with directory must start with "**/" => ${fileMatchItem}`, + ]) + } + } + }) + } + log.ok('fileMatch path OK') +} + +function assertCatalogJsonHasNoFileMatchConflict() { + const fileMatchConflict = schemaValidation.fileMatchConflict + let fileMatchCollection = [] + // Collect all the "fileMatch" and put it in fileMatchCollection[] + for (const schema of catalog.schemas) { + const fileMatchArray = schema.fileMatch + if (fileMatchArray) { + // Check if this is already present in the "fileMatchConflict" list. If so then remove it from filtered[] + const filtered = fileMatchArray.filter((fileMatch) => { + return !fileMatchConflict.includes(fileMatch) + }) + // Check if fileMatch is already present in the fileMatchCollection[] + filtered.forEach((fileMatch) => { + if (fileMatchCollection.includes(fileMatch)) { + throwWithErrorText([`Duplicate fileMatch found => ${fileMatch}`]) + } + }) + fileMatchCollection = fileMatchCollection.concat(filtered) + } + } + log.ok('No new fileMatch conflict detected.') } function assertCatalogJsonLocalUrlsMustRefFile() { @@ -1204,255 +1337,247 @@ function assertCatalogJsonIncludesAllSchemas() { log.ok(`All local schema files have URL link in catalog. Total: ${countScan}`) } -function assertCatalogJsonHasNoFileMatchConflict() { - const fileMatchConflict = schemaValidation.fileMatchConflict - let fileMatchCollection = [] - // Collect all the "fileMatch" and put it in fileMatchCollection[] - for (const schema of catalog.schemas) { - const fileMatchArray = schema.fileMatch - if (fileMatchArray) { - // Check if this is already present in the "fileMatchConflict" list. If so then remove it from filtered[] - const filtered = fileMatchArray.filter((fileMatch) => { - return !fileMatchConflict.includes(fileMatch) - }) - // Check if fileMatch is already present in the fileMatchCollection[] - filtered.forEach((fileMatch) => { - if (fileMatchCollection.includes(fileMatch)) { - throwWithErrorText([`Duplicate fileMatch found => ${fileMatch}`]) - } - }) - fileMatchCollection = fileMatchCollection.concat(filtered) +function assertSchemaValidationHasNoDuplicateLists() { + function checkForDuplicateInList(list, listName) { + if (list) { + if (new Set(list).size !== list.length) { + throwWithErrorText([`Duplicate item found in ${listName}`]) + } } } - log.ok('No new fileMatch conflict detected.') + checkForDuplicateInList( + schemaValidation.ajvNotStrictMode, + 'ajvNotStrictMode[]', + ) + checkForDuplicateInList(schemaValidation.skiptest, 'skiptest[]') + checkForDuplicateInList( + schemaValidation.missingCatalogUrl, + 'missingCatalogUrl[]', + ) + checkForDuplicateInList( + schemaValidation.catalogEntryNoLintNameOrDescription, + 'catalogEntryNoLintNameOrDescription[]', + ) + checkForDuplicateInList( + schemaValidation.fileMatchConflict, + 'fileMatchConflict[]', + ) + checkForDuplicateInList( + schemaValidation.highSchemaVersion, + 'highSchemaVersion[]', + ) + + // Check for duplicate in options[] + const checkList = [] + for (const schemaName in schemaValidation.options) { + if (checkList.includes(schemaName)) { + throwWithErrorText([ + `Duplicate schema name found in options[] schema-validation.json => ${schemaName}`, + ]) + } + // Check for all values inside one option object + const optionValues = schemaValidation.options[schemaName] + checkForDuplicateInList( + optionValues?.unknownKeywords, + `${schemaName} unknownKeywords[]`, + ) + checkForDuplicateInList( + optionValues?.unknownFormat, + `${schemaName} unknownFormat[]`, + ) + checkForDuplicateInList( + optionValues?.externalSchema, + `${schemaName} externalSchema[]`, + ) + checkList.push(schemaName) + } + + log.ok('OK') } -function assertCatalogJsonHasCorrectFileMatchPath() { - for (const schema of catalog.schemas) { - schema.fileMatch?.forEach((fileMatchItem) => { - if (fileMatchItem.includes('/')) { - // A folder must start with **/ - if (!fileMatchItem.startsWith('**/')) { +function assertSchemaValidationJsonHasNoMissingSchemaFiles() { + let countSchemaValidationItems = 0 + const x = (list) => { + list.forEach((schemaName) => { + if (schemaName.endsWith('.json')) { + countSchemaValidationItems++ + if (!schemasToBeTested.includes(schemaName)) { throwWithErrorText([ - `fileMatch with directory must start with "**/" => ${fileMatchItem}`, + `No schema ${schemaName} found in schema folder => ${schemaDir}`, ]) } } }) } - log.ok('fileMatch path OK') -} + x(schemaValidation.ajvNotStrictMode) + x(schemaValidation.skiptest) + x(schemaValidation.missingCatalogUrl) + x(schemaValidation.highSchemaVersion) -function assertFilenamesHaveCorrectExtensions() { - const schemaFileExtension = ['.json'] - const testFileExtension = ['.json', '.yml', '.yaml', '.toml'] - let countScan = 0 - const x = (data, fileExtensionList) => { - countScan++ - const found = fileExtensionList.find((x) => data.jsonName.endsWith(x)) - if (!found) { - throwWithErrorText([ - `Filename must have ${fileExtensionList} extension => ${data.urlOrFilePath}`, - ]) + for (const schemaName in schemaValidation.options) { + if (schemaName !== 'readme_example.json') { + countSchemaValidationItems++ + if (!schemasToBeTested.includes(schemaName)) { + throwWithErrorText([ + `No schema ${schemaName} found in schema folder => ${schemaDir}`, + ]) + } } } - localSchemaFileAndTestFile( - { - schemaForTestScan: (schema) => x(schema, schemaFileExtension), - positiveTestScan: (schema) => x(schema, testFileExtension), - negativeTestScan: (schema) => x(schema, testFileExtension), - }, - { - fullScanAllFiles: true, - }, - ) log.ok( - `All schema and test filename have the correct file extension. Total files scan: ${countScan}`, + `Total schema-validation.json items check: ${countSchemaValidationItems}`, ) } -function printSchemasWithoutPositiveTestFiles() { - let countMissingTest = 0 - // Check if each schemasToBeTested[] items is present in foldersPositiveTest[] - schemasToBeTested.forEach((schemaFileName) => { - if (!foldersPositiveTest.includes(schemaFileName.replace('.json', ''))) { - countMissingTest++ - log.ok(`(No positive test file present): ${schemaFileName}`) - } - }) - if (countMissingTest > 0) { - const percent = (countMissingTest / schemasToBeTested.length) * 100 - log.writeln() - log.writeln(`${Math.round(percent)}% of schemas do not have tests.`) - log.ok( - `Schemas that have no positive test files. Total files: ${countMissingTest}`, - ) - } else { - log.ok('All schemas have positive test') - } -} +function assertSchemaValidationJsonHasNoUnmatchedUrls() { + let totalItems = 0 -function assertDirectoryStructureIsValid() { - schemasToBeTested.forEach((name) => { - if ( - !skipThisFileName(name) && - !fs.lstatSync(path.join(schemaDir, name)).isFile() - ) { - throwWithErrorText([ - `There can only be files in directory : ${schemaDir} => ${name}`, - ]) + const x = (/** @type {string[]} */ schemaUrls) => { + schemaUrls.forEach((schemaUrl) => { + ++totalItems + + const catalogUrls = catalog.schemas.map((item) => item.url) + if (!catalogUrls.includes(schemaUrl)) { + throwWithErrorText([ + `No schema with URL '${schemaUrl}' found in catalog.json`, + ]) + } + }) + } + + x(schemaValidation.catalogEntryNoLintNameOrDescription) + + log.ok(`Total schema-validation.json items checked: ${totalItems}`) +} + +function assertSchemaValidationJsonHasValidSkipTest() { + let countSchemaValidationItems = 0 + const x = (list, listName) => { + list.forEach((schemaName) => { + if (schemaName.endsWith('.json')) { + countSchemaValidationItems++ + if (schemaValidation.skiptest.includes(schemaName)) { + throwWithErrorText([ + `Disabled/skiptest[] schema: ${schemaName} found in => ${listName}[]`, + ]) + } + } + }) + } + x(schemaValidation.ajvNotStrictMode, 'ajvNotStrictMode') + x(schemaValidation.missingCatalogUrl, 'missingCatalogUrl') + x(schemaValidation.highSchemaVersion, 'highSchemaVersion') + + for (const schemaName in schemaValidation.options) { + if (schemaName !== 'readme_example.json') { + countSchemaValidationItems++ + if (schemaValidation.skiptest.includes(schemaName)) { + throwWithErrorText([ + `Disabled/skiptest[] schema: ${schemaName} found in => options[]`, + ]) + } } - }) + } - foldersPositiveTest.forEach((name) => { - if ( - !skipThisFileName(name) && - !fs.lstatSync(path.join(testPositiveDir, name)).isDirectory() - ) { + // Test folder must not exist if defined in skiptest[] + schemaValidation.skiptest.forEach((schemaName) => { + countSchemaValidationItems++ + + const folderName = schemaName.replace('.json', '') + + if (foldersPositiveTest.includes(folderName)) { throwWithErrorText([ - `There can only be directory's in :${testPositiveDir} => ${name}`, + `Disabled/skiptest[] schema: ${schemaName} cannot have positive test folder`, ]) } - }) - - foldersNegativeTest.forEach((name) => { - if ( - !skipThisFileName(name) && - !fs.lstatSync(path.join(testNegativeDir, name)).isDirectory() - ) { + if (foldersNegativeTest.includes(folderName)) { throwWithErrorText([ - `There can only be directory's in :${testNegativeDir} => ${name}`, + `Disabled/skiptest[] schema: ${schemaName} cannot have negative test folder`, ]) } }) - log.ok('OK') + log.ok( + `Total schema-validation.json items check: ${countSchemaValidationItems}`, + ) } -function printDowngradableSchemaVersions() { - let countScan = 0 - - /** - * @param {string} schemaJson - * @param {string} schemaName - * @param {getOptionReturn} option - */ - const validateViaAjv = (schemaJson, schemaName, option) => { - try { - const ajvSelected = factoryAJV({ - schemaName, - unknownFormatsList: option.unknownFormatsList, - fullStrictMode: false, - }) - - // AJV must ignore these keywords - option.unknownKeywordsList?.forEach((x) => { - ajvSelected.addKeyword(x) - }) - - // Add external schema to AJV - option.externalSchemaWithPathList.forEach((x) => { - ajvSelected.addSchema(readJsonFile(x.toString())) - }) - - ajvSelected.compile(schemaJson) - return true - } catch { - return false - } +function assertTestFoldersHaveAtLeastOneTestSchema() { + let countTestFolders = 0 + const x = (listFolders) => { + listFolders.forEach((folderName) => { + if (!skipThisFileName(folderName)) { + countTestFolders++ + if (!schemasToBeTested.includes(folderName + '.json')) { + throwWithErrorText([ + `No schema ${folderName}.json found for test folder => ${folderName}`, + ]) + } + } + }) } + x(foldersPositiveTest) + x(foldersNegativeTest) + log.ok(`Total test folders: ${countTestFolders}`) +} - // There are no positive or negative test processes here. - // Only the schema files are tested. - const testLowerSchemaVersion = (/** @type {Schema} */ schema) => { - countScan++ - let versionIndexOriginal = 0 - const schemaJson = schema.jsonObj +function assertSchemaHasNoBom() { + let countScan = 0 - const option = getOption(schema.jsonName) + localSchemaFileAndTestFile( + { + schemaOnlyScan(schema) { + countScan++ + testSchemaFileForBOM(schema) + }, + }, + { fullScanAllFiles: true, skipReadFile: false }, + ) - // get the present schema_version - const schemaVersion = schemaJson.$schema - for (const [index, value] of SCHEMA_DIALECTS.entries()) { - if (schemaVersion === value.url) { - versionIndexOriginal = index - break - } - } + log.ok( + `no BOM file found in all schema files. Total files scan: ${countScan}`, + ) +} - // start testing each schema version in a while loop. - let result = false - let recommendedIndex = versionIndexOriginal - let versionIndexToBeTested = versionIndexOriginal - do { - // keep trying to use the next lower schema version from the countSchemas[] - versionIndexToBeTested++ - const schemaVersionToBeTested = SCHEMA_DIALECTS[versionIndexToBeTested] - if (!schemaVersionToBeTested?.isActive) { - // Can not use this schema version. And there are no more 'isActive' list item left. - break - } +function assertSchemaHasNoDuplicatedPropertyKeys() { + let countScan = 0 + const findDuplicatedProperty = (/** @type {Schema} */ schema) => { + ++countScan - // update the schema with a new alternative $schema version - schemaJson.$schema = schemaVersionToBeTested.url - // Test this new updated schema with AJV - result = validateViaAjv( - schemaJson, - schemaVersionToBeTested.schemaName, - option, - ) + // Can only test JSON files for duplicates. + const fileExtension = schema.urlOrFilePath.split('.').pop() + if (fileExtension !== 'json') return - if (result) { - // It passes the test. So this is the new recommended index - recommendedIndex = versionIndexToBeTested - } - // keep in the loop till it fail the validation process. - } while (result) + // TODO: Workaround for https://github.com/prantlf/jsonlint/issues/23 + if (schema.jsonName === 'tslint.json') { + return + } - if (recommendedIndex !== versionIndexOriginal) { - // found a different schema version that also work. - const original = SCHEMA_DIALECTS[versionIndexOriginal].schemaName - const recommended = SCHEMA_DIALECTS[recommendedIndex].schemaName - log.ok( - `${schema.jsonName} (${original}) is also valid with (${recommended})`, - ) + try { + jsonlint.parse(schema.rawFile, { + ignoreBOM: false, + ignoreComments: false, + ignoreTrailingCommas: false, + allowSingleQuotedStrings: false, + allowDuplicateObjectKeys: false, + }) + } catch (err) { + throwWithErrorText([`Test file: ${schema.urlOrFilePath}`, err]) } } - - log.writeln() - log.ok( - 'Check if a lower $schema version will also pass the schema validation test', - ) - localSchemaFileAndTestFile( - { schemaOnlyScan: testLowerSchemaVersion }, - { skipReadFile: false }, - ) - log.writeln() - log.ok(`Total files scan: ${countScan}`) -} - -function printCountSchemaVersions() { - const x = showSchemaVersions() localSchemaFileAndTestFile( { - schemaOnlyScan: x.process_data, - schemaOnlyScanDone: x.process_data_done, - }, - { - fullScanAllFiles: true, - skipReadFile: false, + schemaForTestScan: findDuplicatedProperty, + positiveTestScan: findDuplicatedProperty, + negativeTestScan: findDuplicatedProperty, }, + { skipReadFile: false }, + ) + log.ok( + `No duplicated property key found in JSON files. Total files scan: ${countScan}`, ) } -async function remotePrintCountSchemaVersions() { - const x = showSchemaVersions() - await remoteSchemaFile((schema) => { - x.process_data(schema) - }, false) - x.process_data_done() -} - -function assertSchemaHasValidIdField() { +function assertSchemaHasValidSchemaField() { let countScan = 0 localSchemaFileAndTestFile( @@ -1460,35 +1585,27 @@ function assertSchemaHasValidIdField() { schemaOnlyScan(schema) { countScan++ - let schemaId = '' - const schemasWithDollarlessId = [ - 'http://json-schema.org/draft-03/schema#', - 'http://json-schema.org/draft-04/schema#', - ] - if (schemasWithDollarlessId.includes(schema.jsonObj.$schema)) { - if (schema.jsonObj.id === undefined) { - throwWithErrorText([ - `Missing property 'id' for schema 'src/schemas/json/${schema.jsonName}'`, - ]) - } - schemaId = schema.jsonObj.id - } else { - if (schema.jsonObj.$id === undefined) { + const validSchemas = SCHEMA_DIALECTS.map( + (schemaDialect) => schemaDialect.url, + ) + if (!validSchemas.includes(schema.jsonObj.$schema)) { + throwWithErrorText([ + `Schema file has invalid or missing '$schema' keyword => ${schema.jsonName}`, + `Valid schemas: ${JSON.stringify(validSchemas)}`, + ]) + } + + if (!schemaValidation.highSchemaVersion.includes(schema.jsonName)) { + const tooHighSchemas = SCHEMA_DIALECTS.filter( + (schemaDialect) => schemaDialect.isTooHigh, + ).map((schemaDialect) => schemaDialect.url) + if (tooHighSchemas.includes(schema.jsonObj.$schema)) { throwWithErrorText([ - `Missing property '$id' for schema 'src/schemas/json/${schema.jsonName}'`, + `Schema version is too high => in file ${schema.jsonName}`, + `Schema version '${schema.jsonObj.$schema}' is not supported by many editors and IDEs`, + `${schema.jsonName} must use a lower schema version.`, ]) } - schemaId = schema.jsonObj.$id - } - - if ( - !schemaId.startsWith('https://') && - !schemaId.startsWith('http://') - ) { - throwWithErrorText([ - schemaId, - `Schema id/$id must begin with 'https://' or 'http://' for schema 'src/schemas/json/${schema.jsonName}'`, - ]) } }, }, @@ -1501,7 +1618,7 @@ function assertSchemaHasValidIdField() { log.ok(`Total files scan: ${countScan}`) } -function assertSchemaHasValidSchemaField() { +function assertSchemaHasValidIdField() { let countScan = 0 localSchemaFileAndTestFile( @@ -1509,27 +1626,35 @@ function assertSchemaHasValidSchemaField() { schemaOnlyScan(schema) { countScan++ - const validSchemas = SCHEMA_DIALECTS.map( - (schemaDialect) => schemaDialect.url, - ) - if (!validSchemas.includes(schema.jsonObj.$schema)) { - throwWithErrorText([ - `Schema file has invalid or missing '$schema' keyword => ${schema.jsonName}`, - `Valid schemas: ${JSON.stringify(validSchemas)}`, - ]) - } - - if (!schemaValidation.highSchemaVersion.includes(schema.jsonName)) { - const tooHighSchemas = SCHEMA_DIALECTS.filter( - (schemaDialect) => schemaDialect.isTooHigh, - ).map((schemaDialect) => schemaDialect.url) - if (tooHighSchemas.includes(schema.jsonObj.$schema)) { + let schemaId = '' + const schemasWithDollarlessId = [ + 'http://json-schema.org/draft-03/schema#', + 'http://json-schema.org/draft-04/schema#', + ] + if (schemasWithDollarlessId.includes(schema.jsonObj.$schema)) { + if (schema.jsonObj.id === undefined) { throwWithErrorText([ - `Schema version is too high => in file ${schema.jsonName}`, - `Schema version '${schema.jsonObj.$schema}' is not supported by many editors and IDEs`, - `${schema.jsonName} must use a lower schema version.`, + `Missing property 'id' for schema 'src/schemas/json/${schema.jsonName}'`, ]) } + schemaId = schema.jsonObj.id + } else { + if (schema.jsonObj.$id === undefined) { + throwWithErrorText([ + `Missing property '$id' for schema 'src/schemas/json/${schema.jsonName}'`, + ]) + } + schemaId = schema.jsonObj.$id + } + + if ( + !schemaId.startsWith('https://') && + !schemaId.startsWith('http://') + ) { + throwWithErrorText([ + schemaId, + `Schema id/$id must begin with 'https://' or 'http://' for schema 'src/schemas/json/${schema.jsonName}'`, + ]) } }, }, @@ -1568,372 +1693,101 @@ function assertSchemaPassesSchemaSafeLint() { log.ok(`Total files scan: ${countScan}`) } -function assertSchemaValidationHasNoDuplicateLists() { - function checkForDuplicateInList(list, listName) { - if (list) { - if (new Set(list).size !== list.length) { - throwWithErrorText([`Duplicate item found in ${listName}`]) - } - } - } - checkForDuplicateInList( - schemaValidation.ajvNotStrictMode, - 'ajvNotStrictMode[]', - ) - checkForDuplicateInList(schemaValidation.skiptest, 'skiptest[]') - checkForDuplicateInList( - schemaValidation.missingCatalogUrl, - 'missingCatalogUrl[]', - ) - checkForDuplicateInList( - schemaValidation.catalogEntryNoLintNameOrDescription, - 'catalogEntryNoLintNameOrDescription[]', - ) - checkForDuplicateInList( - schemaValidation.fileMatchConflict, - 'fileMatchConflict[]', - ) - checkForDuplicateInList( - schemaValidation.highSchemaVersion, - 'highSchemaVersion[]', - ) - - // Check for duplicate in options[] - const checkList = [] - for (const schemaName in schemaValidation.options) { - if (checkList.includes(schemaName)) { - throwWithErrorText([ - `Duplicate schema name found in options[] schema-validation.json => ${schemaName}`, - ]) - } - // Check for all values inside one option object - const optionValues = schemaValidation.options[schemaName] - checkForDuplicateInList( - optionValues?.unknownKeywords, - `${schemaName} unknownKeywords[]`, - ) - checkForDuplicateInList( - optionValues?.unknownFormat, - `${schemaName} unknownFormat[]`, - ) - checkForDuplicateInList( - optionValues?.externalSchema, - `${schemaName} externalSchema[]`, - ) - checkList.push(schemaName) - } - - log.ok('OK') -} - -function assertCatalogJsonHasNoDuplicateNames() { - /** @type {string[]} */ - const schemaNames = catalog.schemas.map((entry) => entry.name) - /** @type {string[]} */ - const duplicateSchemaNames = [] - - for (const schemaName of schemaNames) { - const matches = schemaNames.filter((item) => item === schemaName) - if (matches.length > 1 && !duplicateSchemaNames.includes(schemaName)) { - duplicateSchemaNames.push(schemaName) - } - } - - if (duplicateSchemaNames.length > 0) { - throwWithErrorText([ - `Found duplicates: ${JSON.stringify(duplicateSchemaNames)}`, - ]) - } -} - -function assertTestFoldersHaveAtLeastOneTestSchema() { - let countTestFolders = 0 - const x = (listFolders) => { - listFolders.forEach((folderName) => { - if (!skipThisFileName(folderName)) { - countTestFolders++ - if (!schemasToBeTested.includes(folderName + '.json')) { - throwWithErrorText([ - `No schema ${folderName}.json found for test folder => ${folderName}`, - ]) - } - } - }) - } - x(foldersPositiveTest) - x(foldersNegativeTest) - log.ok(`Total test folders: ${countTestFolders}`) -} - -function printUrlCountsInCatalog() { - let countScanURLExternal = 0 - let countScanURLInternal = 0 - getUrlFromCatalog((catalogUrl) => { - catalogUrl.startsWith(urlSchemaStore) - ? countScanURLInternal++ - : countScanURLExternal++ - }) - const totalCount = countScanURLExternal + countScanURLInternal - const percentExternal = (countScanURLExternal / totalCount) * 100 - log.ok(`${countScanURLInternal} SchemaStore URL`) - log.ok( - `${countScanURLExternal} External URL (${Math.round(percentExternal)}%)`, - ) - log.ok(`${totalCount} Total URL`) -} - -function printSchemasTestedInFullStrictMode() { - let countSchemaScanViaAJV = 0 - localSchemaFileAndTestFile({ - schemaOnlyScan() { - countSchemaScanViaAJV++ - }, - }) - // If only ONE AJV schema test is run then this calculation does not work. - if (countSchemaScanViaAJV !== 1) { - const countFullStrictSchema = - countSchemaScanViaAJV - schemaValidation.ajvNotStrictMode.length - const percent = (countFullStrictSchema / countSchemaScanViaAJV) * 100 - log.ok( - 'Schema in full strict mode to prevent any unexpected behaviours or silently ignored mistakes in user schemas.', - ) - log.ok( - `${countFullStrictSchema} of ${countSchemaScanViaAJV} (${Math.round( - percent, - )}%)`, - ) - } -} - -function assertSchemaValidationJsonHasNoMissingSchemaFiles() { - let countSchemaValidationItems = 0 - const x = (list) => { - list.forEach((schemaName) => { - if (schemaName.endsWith('.json')) { - countSchemaValidationItems++ - if (!schemasToBeTested.includes(schemaName)) { - throwWithErrorText([ - `No schema ${schemaName} found in schema folder => ${schemaDir}`, - ]) - } - } - }) - } - x(schemaValidation.ajvNotStrictMode) - x(schemaValidation.skiptest) - x(schemaValidation.missingCatalogUrl) - x(schemaValidation.highSchemaVersion) - - for (const schemaName in schemaValidation.options) { - if (schemaName !== 'readme_example.json') { - countSchemaValidationItems++ - if (!schemasToBeTested.includes(schemaName)) { - throwWithErrorText([ - `No schema ${schemaName} found in schema folder => ${schemaDir}`, - ]) - } - } - } - log.ok( - `Total schema-validation.json items check: ${countSchemaValidationItems}`, - ) -} - -function assertSchemaValidationJsonHasNoUnmatchedUrls() { - let totalItems = 0 - - const x = (/** @type {string[]} */ schemaUrls) => { - schemaUrls.forEach((schemaUrl) => { - ++totalItems - - const catalogUrls = catalog.schemas.map((item) => item.url) - if (!catalogUrls.includes(schemaUrl)) { - throwWithErrorText([ - `No schema with URL '${schemaUrl}' found in catalog.json`, - ]) - } - }) - } - - x(schemaValidation.catalogEntryNoLintNameOrDescription) - - log.ok(`Total schema-validation.json items checked: ${totalItems}`) -} - -function assertSchemaValidationJsonHasValidSkipTest() { - let countSchemaValidationItems = 0 - const x = (list, listName) => { - list.forEach((schemaName) => { - if (schemaName.endsWith('.json')) { - countSchemaValidationItems++ - if (schemaValidation.skiptest.includes(schemaName)) { - throwWithErrorText([ - `Disabled/skiptest[] schema: ${schemaName} found in => ${listName}[]`, - ]) - } - } - }) - } - x(schemaValidation.ajvNotStrictMode, 'ajvNotStrictMode') - x(schemaValidation.missingCatalogUrl, 'missingCatalogUrl') - x(schemaValidation.highSchemaVersion, 'highSchemaVersion') - - for (const schemaName in schemaValidation.options) { - if (schemaName !== 'readme_example.json') { - countSchemaValidationItems++ - if (schemaValidation.skiptest.includes(schemaName)) { - throwWithErrorText([ - `Disabled/skiptest[] schema: ${schemaName} found in => options[]`, - ]) - } - } - } - - // Test folder must not exist if defined in skiptest[] - schemaValidation.skiptest.forEach((schemaName) => { - countSchemaValidationItems++ - - const folderName = schemaName.replace('.json', '') - - if (foldersPositiveTest.includes(folderName)) { - throwWithErrorText([ - `Disabled/skiptest[] schema: ${schemaName} cannot have positive test folder`, - ]) - } - if (foldersNegativeTest.includes(folderName)) { - throwWithErrorText([ - `Disabled/skiptest[] schema: ${schemaName} cannot have negative test folder`, - ]) - } - }) - log.ok( - `Total schema-validation.json items check: ${countSchemaValidationItems}`, - ) -} - -async function taskCoverage() { - const javaScriptCoverageName = 'schema.json.translated.to.js' - const javaScriptCoverageNameWithPath = path.join( - `${temporaryCoverageDir}/${javaScriptCoverageName}`, - ) - - /** - * Translate one JSON schema file to javascript via AJV validator. - * And run the positive and negative test files with it. - * @param {string} processOnlyThisOneSchemaFile The schema file that need to process - */ - const generateCoverage = async (processOnlyThisOneSchemaFile) => { - const schemaVersion = showSchemaVersions() - let jsonName - let mainSchema - let mainSchemaJsonId - let isThisWithExternalSchema - let validations - - // Compile JSON schema to javascript and write it to disk. - const processSchemaFile = async (/** @type {Schema} */ schema) => { - jsonName = schema.jsonName - // Get possible options define in schema-validation.json - const { - unknownFormatsList, - unknownKeywordsList, - externalSchemaWithPathList, - } = getOption(schema.jsonName) - - // select the correct AJV object for this schema - mainSchema = schema.jsonObj - const versionObj = schemaVersion.getObj(mainSchema) - - // External schema present to be included? - const multipleSchema = [] - isThisWithExternalSchema = externalSchemaWithPathList.length > 0 - if (isThisWithExternalSchema) { - // There is an external schema that need to be included. - externalSchemaWithPathList.forEach((x) => { - multipleSchema.push(readJsonFile(x.toString())) - }) - // Also add the 'root' schema - multipleSchema.push(mainSchema) - } - - // Get the correct AJV version - const ajvSelected = factoryAJV({ - schemaName: versionObj?.schemaName, - unknownFormatsList, - fullStrictMode: !schemaValidation.ajvNotStrictMode.includes(jsonName), - standAloneCode: true, - standAloneCodeWithMultipleSchema: multipleSchema, - }) - - // AJV must ignore these keywords - unknownKeywordsList?.forEach((x) => { - ajvSelected.addKeyword(x) - }) - - let moduleCode - if (isThisWithExternalSchema) { - // Multiple schemas are combine to one JavaScript file. - // Must use the root $id/id to call the correct 'main' schema in JavaScript code - mainSchemaJsonId = - schemaVersion.getObj(mainSchema).schemaName === 'draft-04' - ? mainSchema.id - : mainSchema.$id - if (!mainSchemaJsonId) { - throwWithErrorText([`Missing $id or id in ${jsonName}`]) - } - moduleCode = AjvStandalone(ajvSelected) - } else { - // Single schema - mainSchemaJsonId = undefined - moduleCode = AjvStandalone(ajvSelected, ajvSelected.compile(mainSchema)) - } - - // Prettify the JavaScript module code - const prettierOptions = await prettier.resolveConfig(process.cwd()) - fs.writeFileSync( - javaScriptCoverageNameWithPath, - prettier.format(moduleCode, { - ...prettierOptions, - parser: 'babel', - printWidth: 200, - }), - ) - // Now use this JavaScript as validation in the positive and negative test - validations = readJsonFile(javaScriptCoverageNameWithPath) +function assertFilenamesHaveCorrectExtensions() { + const schemaFileExtension = ['.json'] + const testFileExtension = ['.json', '.yml', '.yaml', '.toml'] + let countScan = 0 + const x = (data, fileExtensionList) => { + countScan++ + const found = fileExtensionList.find((x) => data.jsonName.endsWith(x)) + if (!found) { + throwWithErrorText([ + `Filename must have ${fileExtensionList} extension => ${data.urlOrFilePath}`, + ]) } + } + localSchemaFileAndTestFile( + { + schemaForTestScan: (schema) => x(schema, schemaFileExtension), + positiveTestScan: (schema) => x(schema, testFileExtension), + negativeTestScan: (schema) => x(schema, testFileExtension), + }, + { + fullScanAllFiles: true, + }, + ) + log.ok( + `All schema and test filename have the correct file extension. Total files scan: ${countScan}`, + ) +} - // Load the Javascript file from the disk and run it with the JSON test file. - // This will generate the NodeJS coverage data in the background. - const processTestFile = (/** @type {Schema} */ schema) => { - // Test only for the code coverage. Not for the validity of the test. - if (isThisWithExternalSchema) { - // Must use the root $id/id to call the correct schema JavaScript code - const validateRootSchema = validations[mainSchemaJsonId] - validateRootSchema?.(schema.jsonObj) - } else { - // Single schema does not need $id - validations(schema.jsonObj) - } +function assertDirectoryStructureIsValid() { + schemasToBeTested.forEach((name) => { + if ( + !skipThisFileName(name) && + !fs.lstatSync(path.join(schemaDir, name)).isFile() + ) { + throwWithErrorText([ + `There can only be files in directory : ${schemaDir} => ${name}`, + ]) } + }) - await localSchemaFileAndTestFile( - { - schemaForTestScan: processSchemaFile, - positiveTestScan: processTestFile, - negativeTestScan: processTestFile, - }, - { skipReadFile: false, processOnlyThisOneSchemaFile }, - ) - } + foldersPositiveTest.forEach((name) => { + if ( + !skipThisFileName(name) && + !fs.lstatSync(path.join(testPositiveDir, name)).isDirectory() + ) { + throwWithErrorText([ + `There can only be directory's in :${testPositiveDir} => ${name}`, + ]) + } + }) - const schemaNameToBeCoverage = argv.SchemaName - if (!schemaNameToBeCoverage) { - throwWithErrorText(['Must start "make" file with --SchemaName parameter.']) - } - await generateCoverage(schemaNameToBeCoverage) + foldersNegativeTest.forEach((name) => { + if ( + !skipThisFileName(name) && + !fs.lstatSync(path.join(testNegativeDir, name)).isDirectory() + ) { + throwWithErrorText([ + `There can only be directory's in :${testNegativeDir} => ${name}`, + ]) + } + }) log.ok('OK') } +function printCountSchemaVersions() { + const x = showSchemaVersions() + localSchemaFileAndTestFile( + { + schemaOnlyScan: x.process_data, + schemaOnlyScanDone: x.process_data_done, + }, + { + fullScanAllFiles: true, + skipReadFile: false, + }, + ) +} + +function printUrlCountsInCatalog() { + let countScanURLExternal = 0 + let countScanURLInternal = 0 + getUrlFromCatalog((catalogUrl) => { + catalogUrl.startsWith(urlSchemaStore) + ? countScanURLInternal++ + : countScanURLExternal++ + }) + const totalCount = countScanURLExternal + countScanURLInternal + const percentExternal = (countScanURLExternal / totalCount) * 100 + log.ok(`${countScanURLInternal} SchemaStore URL`) + log.ok( + `${countScanURLExternal} External URL (${Math.round(percentExternal)}%)`, + ) + log.ok(`${totalCount} Total URL`) +} + function printStrictAndNotStrictAjvValidatedSchemas() { const schemaVersion = showSchemaVersions() const schemaInFullStrictMode = [] @@ -2007,6 +1861,152 @@ function printStrictAndNotStrictAjvValidatedSchemas() { ) } +function printDowngradableSchemaVersions() { + let countScan = 0 + + /** + * @param {string} schemaJson + * @param {string} schemaName + * @param {getOptionReturn} option + */ + const validateViaAjv = (schemaJson, schemaName, option) => { + try { + const ajvSelected = factoryAJV({ + schemaName, + unknownFormatsList: option.unknownFormatsList, + fullStrictMode: false, + }) + + // AJV must ignore these keywords + option.unknownKeywordsList?.forEach((x) => { + ajvSelected.addKeyword(x) + }) + + // Add external schema to AJV + option.externalSchemaWithPathList.forEach((x) => { + ajvSelected.addSchema(readJsonFile(x.toString())) + }) + + ajvSelected.compile(schemaJson) + return true + } catch { + return false + } + } + + // There are no positive or negative test processes here. + // Only the schema files are tested. + const testLowerSchemaVersion = (/** @type {Schema} */ schema) => { + countScan++ + let versionIndexOriginal = 0 + const schemaJson = schema.jsonObj + + const option = getOption(schema.jsonName) + + // get the present schema_version + const schemaVersion = schemaJson.$schema + for (const [index, value] of SCHEMA_DIALECTS.entries()) { + if (schemaVersion === value.url) { + versionIndexOriginal = index + break + } + } + + // start testing each schema version in a while loop. + let result = false + let recommendedIndex = versionIndexOriginal + let versionIndexToBeTested = versionIndexOriginal + do { + // keep trying to use the next lower schema version from the countSchemas[] + versionIndexToBeTested++ + const schemaVersionToBeTested = SCHEMA_DIALECTS[versionIndexToBeTested] + if (!schemaVersionToBeTested?.isActive) { + // Can not use this schema version. And there are no more 'isActive' list item left. + break + } + + // update the schema with a new alternative $schema version + schemaJson.$schema = schemaVersionToBeTested.url + // Test this new updated schema with AJV + result = validateViaAjv( + schemaJson, + schemaVersionToBeTested.schemaName, + option, + ) + + if (result) { + // It passes the test. So this is the new recommended index + recommendedIndex = versionIndexToBeTested + } + // keep in the loop till it fail the validation process. + } while (result) + + if (recommendedIndex !== versionIndexOriginal) { + // found a different schema version that also work. + const original = SCHEMA_DIALECTS[versionIndexOriginal].schemaName + const recommended = SCHEMA_DIALECTS[recommendedIndex].schemaName + log.ok( + `${schema.jsonName} (${original}) is also valid with (${recommended})`, + ) + } + } + + log.writeln() + log.ok( + 'Check if a lower $schema version will also pass the schema validation test', + ) + localSchemaFileAndTestFile( + { schemaOnlyScan: testLowerSchemaVersion }, + { skipReadFile: false }, + ) + log.writeln() + log.ok(`Total files scan: ${countScan}`) +} + +function printSchemasTestedInFullStrictMode() { + let countSchemaScanViaAJV = 0 + localSchemaFileAndTestFile({ + schemaOnlyScan() { + countSchemaScanViaAJV++ + }, + }) + // If only ONE AJV schema test is run then this calculation does not work. + if (countSchemaScanViaAJV !== 1) { + const countFullStrictSchema = + countSchemaScanViaAJV - schemaValidation.ajvNotStrictMode.length + const percent = (countFullStrictSchema / countSchemaScanViaAJV) * 100 + log.ok( + 'Schema in full strict mode to prevent any unexpected behaviours or silently ignored mistakes in user schemas.', + ) + log.ok( + `${countFullStrictSchema} of ${countSchemaScanViaAJV} (${Math.round( + percent, + )}%)`, + ) + } +} + +function printSchemasWithoutPositiveTestFiles() { + let countMissingTest = 0 + // Check if each schemasToBeTested[] items is present in foldersPositiveTest[] + schemasToBeTested.forEach((schemaFileName) => { + if (!foldersPositiveTest.includes(schemaFileName.replace('.json', ''))) { + countMissingTest++ + log.ok(`(No positive test file present): ${schemaFileName}`) + } + }) + if (countMissingTest > 0) { + const percent = (countMissingTest / schemasToBeTested.length) * 100 + log.writeln() + log.writeln(`${Math.round(percent)}% of schemas do not have tests.`) + log.ok( + `Schemas that have no positive test files. Total files: ${countMissingTest}`, + ) + } else { + log.ok('All schemas have positive test') + } +} + { const helpMenu = `USAGE: node ./cli.js