├── .c8rc ├── .github ├── dependabot.yml └── workflows │ ├── build-docs.yml │ ├── build.yml │ └── enforce-dependency-review.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config-schema.json ├── docs ├── babel.config.js ├── build-plugin-docs.mjs ├── docs │ ├── configuration.md │ ├── exit-codes.md │ ├── faq.md │ ├── ignoring-files.md │ ├── intro.md │ ├── plugins │ │ ├── _category_.json │ │ ├── using-plugins.md │ │ └── writing-plugins.md │ ├── proxy.md │ ├── semver.md │ └── usage-examples.md ├── docusaurus.config.js ├── jsdoc.json ├── package-lock.json ├── package.json ├── sidebars.js ├── src │ └── css │ │ └── custom.css └── static │ ├── .nojekyll │ └── img │ ├── favicon.ico │ ├── github.svg │ ├── logo.png │ └── npm.svg ├── eslint.config.mjs ├── package-lock.json ├── package.json ├── src ├── ajv.js ├── ajv.spec.js ├── bootstrap.js ├── bootstrap.spec.js ├── cache.js ├── cache.spec.js ├── catalogs.js ├── catalogs.spec.js ├── cli.js ├── cli.spec.js ├── config-validators.js ├── config-validators.spec.js ├── glob.js ├── glob.spec.js ├── index.js ├── io.js ├── logger.js ├── output-formatters.js ├── output-formatters.spec.js ├── parser.js ├── plugins.js ├── plugins.spec.js ├── plugins │ ├── output-json.js │ ├── output-text.js │ ├── parser-json.js │ ├── parser-json5.js │ ├── parser-toml.js │ └── parser-yaml.js ├── public.js └── test-helpers.js └── testfiles ├── catalogs ├── catalog-malformed.json ├── catalog-nomatch.json ├── catalog-url-with-yaml-schema.json └── catalog-url.json ├── configs └── config.json ├── files ├── invalid.json ├── multi-doc.yaml ├── not-supported.txt ├── valid.json ├── valid.json5 ├── valid.toml ├── valid.yaml └── with-comments.json ├── ignorefiles ├── ignore-json └── ignore-yaml ├── plugins ├── bad-parse-method1.js ├── bad-parse-method2.js ├── invalid-base.js ├── invalid-name.js ├── invalid-params.js └── valid.js └── schemas ├── fragment.json ├── invalid_external_ref.json ├── local_external_ref_with_id.json ├── local_external_ref_without_id.json ├── remote_external_ref.json ├── schema.json ├── schema.multi-doc.yaml └── schema.yaml /.c8rc: -------------------------------------------------------------------------------- 1 | { 2 | "all": true, 3 | "include": [ 4 | "**/*.js" 5 | ], 6 | "exclude": [ 7 | "**/*.spec.js", 8 | "src/test-helpers.js", 9 | "testfiles/plugins/**", 10 | "docs/**" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/docs" 11 | schedule: 12 | interval: "daily" 13 | open-pull-requests-limit: 10 14 | groups: 15 | # All official @docusaurus/* packages should have the exact same version as @docusaurus/core. 16 | # From https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#groups: 17 | # "You cannot apply a single grouping set of rules to both version updates and security 18 | # updates [...] you must define two, separately named, grouping sets of rules" 19 | # See https://github.com/badges/shields/issues/10242 for more information. 20 | docusaurus-version-updates: 21 | applies-to: version-updates 22 | patterns: 23 | - '@docusaurus/*' 24 | docusaurus-security-updates: 25 | applies-to: security-updates 26 | patterns: 27 | - '@docusaurus/*' 28 | 29 | - package-ecosystem: "github-actions" 30 | directory: "/" 31 | schedule: 32 | interval: "daily" 33 | -------------------------------------------------------------------------------- /.github/workflows/build-docs.yml: -------------------------------------------------------------------------------- 1 | name: Build Docs 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build-docs: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@v4 10 | 11 | # build docs on node 22 because 12 | # https://github.com/naver/jsdoc-to-mdx/issues/20 13 | - name: Install Node JS 22 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: 22 17 | 18 | - name: Install 19 | run: | 20 | cd docs 21 | npm ci --engine-strict --strict-peer-deps 22 | 23 | - name: Build 24 | run: | 25 | cd docs 26 | npm run build 27 | 28 | - name: Deploy 29 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 30 | uses: JamesIves/github-pages-deploy-action@v4 31 | with: 32 | branch: gh-pages 33 | folder: docs/build 34 | clean: true 35 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | node: ['20', '22', '24'] 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | 14 | - run: echo "build_status_message=failing" >> $GITHUB_ENV 15 | - run: echo "build_status_color=red" >> $GITHUB_ENV 16 | 17 | - name: Install Node JS ${{ matrix.node }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Show Node/NPM versions 23 | run: | 24 | node --version 25 | npm --version 26 | 27 | - name: Install 28 | run: npm ci --engine-strict --strict-peer-deps 29 | 30 | - name: Lint 31 | run: | 32 | npm run prettier:check 33 | npm run lint 34 | 35 | - name: Run tests 36 | run: | 37 | npm test 38 | npm run coverage 39 | 40 | - run: echo "build_status_message=passing" >> $GITHUB_ENV 41 | - run: echo "build_status_color=brightgreen" >> $GITHUB_ENV 42 | 43 | - name: Upload coverage report to codecov 44 | uses: codecov/codecov-action@v3 45 | with: 46 | file: ./coverage/cobertura-coverage.xml 47 | 48 | - name: Write Coverage badge 49 | uses: action-badges/cobertura-coverage-xml-badges@main 50 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 51 | with: 52 | badge-branch: badges 53 | file-name: coverage.svg 54 | github-token: '${{ secrets.GITHUB_TOKEN }}' 55 | coverage-file-name: ./coverage/cobertura-coverage.xml 56 | 57 | - name: Write Build Status badge 58 | if: ${{ always() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} 59 | uses: action-badges/core@main 60 | with: 61 | badge-branch: badges 62 | file-name: build-status.svg 63 | github-token: "${{ secrets.GITHUB_TOKEN }}" 64 | label: build 65 | message: "${{ env.build_status_message }}" 66 | message-color: "${{ env.build_status_color }}" 67 | 68 | package-json-badges: 69 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 70 | runs-on: ubuntu-latest 71 | steps: 72 | - name: Checkout 73 | uses: actions/checkout@v4 74 | 75 | - name: Write License badge 76 | uses: action-badges/package-json-badges@main 77 | with: 78 | badge-branch: badges 79 | file-name: package-license.svg 80 | github-token: '${{ secrets.GITHUB_TOKEN }}' 81 | integration: license 82 | 83 | - name: Write Node Version badge 84 | uses: action-badges/package-json-badges@main 85 | with: 86 | badge-branch: badges 87 | file-name: package-node-version.svg 88 | github-token: '${{ secrets.GITHUB_TOKEN }}' 89 | integration: node-version 90 | 91 | - name: Write Version badge 92 | uses: action-badges/package-json-badges@main 93 | with: 94 | badge-branch: badges 95 | file-name: package-version.svg 96 | github-token: '${{ secrets.GITHUB_TOKEN }}' 97 | integration: version 98 | -------------------------------------------------------------------------------- /.github/workflows/enforce-dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | on: [pull_request] 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | dependency-review: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: 'Checkout Repository' 12 | uses: actions/checkout@v4 13 | - name: 'Dependency Review' 14 | uses: actions/dependency-review-action@v4 15 | with: 16 | fail-on-scopes: runtime 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules/ 3 | docs/node_modules/ 4 | 5 | # coverage 6 | coverage/ 7 | 8 | # docs: build 9 | docs/build/ 10 | # docs: generated files 11 | docs/.docusaurus/ 12 | docs/.cache-loader/ 13 | docs/docs/plugins/reference.mdx 14 | docs/.tempdocs/ 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | plugins: 2 | - "./node_modules/prettier-plugin-jsdoc/dist/index.js" 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 📦 [5.0.0](https://www.npmjs.com/package/v8r/v/5.0.0) - 2025-05-10 4 | 5 | Following on from the deprecations in version 4.4.0, 6 | version 5.0.0 contains a number of breaking changes: 7 | 8 | * The `--format` CLI argument and `format` config file key have been removed. 9 | Switch to using `--output-format` and `outputFormat`. 10 | * v8r now ignores patterns in `.gitignore` by default. 11 | * The `fileLocation` argument of `getSingleResultLogMessage` has been removed. 12 | The signature is now `getSingleResultLogMessage(result, format)`. 13 | Plugins implementing the `getSingleResultLogMessage` hook will need to to update 14 | the signature. 15 | If you are using `fileLocation` in the `getSingleResultLogMessage` function body, 16 | switch to using `result.fileLocation`. 17 | * File paths are no longer passed to plugins in dot-relative notation. 18 | Plugins implementing the `getSingleResultLogMessage`, `getAllResultsLogMessage` and `parseInputFile` 19 | plugin hooks may need to be updated. 20 | * The minimum compatible node version is now Node 20. 21 | 22 | Other changes in this release: 23 | 24 | * v8r is now tested on node 24 25 | * Upgrade to latest major versions of core packages (got, glob, minimatch, etc) 26 | 27 | ## 📦 [4.4.0](https://www.npmjs.com/package/v8r/v/4.4.0) - 2025-04-26 28 | 29 | Version 4.4.0 is a deprecation release. This release adds deprecation warnings for 30 | upcoming breaking changes that will be made in version 5.0 31 | 32 | * This release adds the `--output-format` CLI argument and `outputFormat` config file key. 33 | In v8r 4.4.0 `--format` and `format` can still be used as aliases. 34 | In version 5 `--format` and `format` will be removed. 35 | It is recommended to switch to using `--output-format` and `outputFormat` now. 36 | * Starting from v8r version 5, v8r will ignore patterns in `.gitignore` by default. 37 | * In v8r version 5 the `fileLocation` argument of `getSingleResultLogMessage` will be removed. 38 | The signature will become `getSingleResultLogMessage(result, format)`. 39 | Plugins implementing the `getSingleResultLogMessage` plugin hook will need to to update 40 | the signature to be compatible with version 5. 41 | If you are using `fileLocation` in the `getSingleResultLogMessage` function body, 42 | switch to using `result.fileLocation`. 43 | * Starting from v8r version 5 file paths will no longer be passed to plugins in dot-relative notation. 44 | Plugins implementing the `getSingleResultLogMessage`, `getAllResultsLogMessage` and `parseInputFile` 45 | plugin hooks may need to be updated. 46 | 47 | ## 📦 [4.3.0](https://www.npmjs.com/package/v8r/v/4.3.0) - 2025-04-21 48 | 49 | * Add ignore patern files. v8r now looks for ignore patterns in `.v8rignore` by default. 50 | More info: https://chris48s.github.io/v8r/ignoring-files/ 51 | * Include the prop name in `additionalProperty` log message. 52 | * Allow config file to contain `$schema` key. 53 | * Fix: Clear the cache on init if TTL is 0. 54 | 55 | ## 📦 [4.2.1](https://www.npmjs.com/package/v8r/v/4.2.1) - 2024-12-14 56 | 57 | * Upgrade to flat-cache 6. 58 | This release revamps how cache is stored and invalidated internally 59 | but should have no user-visible impact 60 | 61 | ## 📦 [4.2.0](https://www.npmjs.com/package/v8r/v/4.2.0) - 2024-10-24 62 | 63 | * Add `V8R_CONFIG_FILE` environment variable. 64 | This allows loading a config file from a location other than the directory v8r is invoked from. 65 | More info: https://chris48s.github.io/v8r/configuration/ 66 | 67 | ## 📦 [4.1.0](https://www.npmjs.com/package/v8r/v/4.1.0) - 2024-08-25 68 | 69 | * v8r can now parse and validate files that contain multiple yaml documents 70 | More info: https://chris48s.github.io/v8r/usage-examples/#files-containing-multiple-documents 71 | * The `parseInputFile()` plugin hook may now conditionally return an array of `Document` objects 72 | * The `ValidationResult` object now contains a `documentIndex` property. 73 | This identifies the document when a multi-doc file has been validated. 74 | 75 | ## 📦 [4.0.1](https://www.npmjs.com/package/v8r/v/4.0.1) - 2024-08-19 76 | 77 | * De-duplicate and sort files before validating 78 | 79 | ## 📦 [4.0.0](https://www.npmjs.com/package/v8r/v/4.0.0) - 2024-08-19 80 | 81 | * **Breaking:** Change to the JSON output format. The `results` key is now an array instead of an object. 82 | In v8r <4, `results` was an object mapping filename to result object. For example: 83 | ```json 84 | { 85 | "results": { 86 | "./package.json": { 87 | "fileLocation": "./package.json", 88 | "schemaLocation": "https://json.schemastore.org/package.json", 89 | "valid": true, 90 | "errors": [], 91 | "code": 0 92 | } 93 | } 94 | } 95 | ``` 96 | 97 | In v8r >=4 `results` is now an array of result objects. For example: 98 | ```json 99 | { 100 | "results": [ 101 | { 102 | "fileLocation": "./package.json", 103 | "schemaLocation": "https://json.schemastore.org/package.json", 104 | "valid": true, 105 | "errors": [], 106 | "code": 0 107 | } 108 | ] 109 | } 110 | ``` 111 | * Plugin system: It is now possible to extend the functionality of v8r by using or writing plugins. See https://chris48s.github.io/v8r/category/plugins/ for further information 112 | * Documentation improvements 113 | 114 | ## 📦 [3.1.1](https://www.npmjs.com/package/v8r/v/3.1.1) - 2024-08-03 115 | 116 | * Allow 'toml' as an allowed value for parser in custom catalog 117 | 118 | ## 📦 [3.1.0](https://www.npmjs.com/package/v8r/v/3.1.0) - 2024-06-03 119 | 120 | * Add ability to configure a proxy using global-agent 121 | 122 | ## 📦 [3.0.0](https://www.npmjs.com/package/v8r/v/3.0.0) - 2024-01-25 123 | 124 | * **Breaking:** Drop compatibility with node 16 125 | * Add ability to validate Toml documents 126 | 127 | ## 📦 [2.1.0](https://www.npmjs.com/package/v8r/v/2.1.0) - 2023-10-23 128 | 129 | * Add compatibility with JSON schemas serialized as Yaml 130 | 131 | ## 📦 [2.0.0](https://www.npmjs.com/package/v8r/v/2.0.0) - 2023-05-02 132 | 133 | * **Breaking:** Drop compatibility with node 14 134 | * Upgrade glob and minimatch to latest versions 135 | * Tested on node 20 136 | 137 | ## 📦 [1.0.0](https://www.npmjs.com/package/v8r/v/1.0.0) - 2023-03-12 138 | 139 | Version 1.0.0 contains no new features and no bug fixes. 140 | The one change in this release is that the project now publishes a 141 | [SemVer compatibility statement](https://github.com/chris48s/v8r/blob/main/README.md#versioning). 142 | 143 | ## 📦 [0.14.0](https://www.npmjs.com/package/v8r/v/0.14.0) - 2023-01-28 144 | 145 | * Throw an error if multiple versions of a schema are found in the catalog, 146 | instead of assuming the latest version 147 | 148 | ## 📦 [0.13.1](https://www.npmjs.com/package/v8r/v/0.13.1) - 2022-12-10 149 | 150 | * Resolve external `$ref`s in local schemas 151 | 152 | ## 📦 [0.13.0](https://www.npmjs.com/package/v8r/v/0.13.0) - 2022-06-11 153 | 154 | * Overhaul of CLI output/machine-readable output. Validation results are sent to stdout. Log messages are now sent to stderr only. Pass `--format [text|json] (default: text)` to specify what is sent to stdout. 155 | 156 | ## 📦 [0.12.0](https://www.npmjs.com/package/v8r/v/0.12.0) - 2022-05-07 157 | 158 | * Add config file. See https://github.com/chris48s/v8r/blob/main/README.md#configuration for more details. 159 | 160 | ## 📦 [0.11.1](https://www.npmjs.com/package/v8r/v/0.11.1) - 2022-03-03 161 | 162 | * Fix: call minimatch with `{dot: true}`, fixes [#174](https://github.com/chris48s/v8r/issues/174) 163 | 164 | ## 📦 [0.11.0](https://www.npmjs.com/package/v8r/v/0.11.0) - 2022-02-27 165 | 166 | * Drop compatibility with node 12, now requires node `^14.13.1 || >=15.0.0` 167 | * Upgrade to got 12 internally 168 | * Call ajv with `allErrors` flag 169 | 170 | ## 📦 [0.10.1](https://www.npmjs.com/package/v8r/v/0.10.1) - 2022-01-06 171 | 172 | * Fix `--version` flag when installed globally in some environments 173 | 174 | ## 📦 [0.10.0](https://www.npmjs.com/package/v8r/v/0.10.0) - 2022-01-03 175 | 176 | * Accept multiple filenames or globs as positional args. e.g: 177 | ```bash 178 | v8r file1.json file2.json 'dir/*.yaml' 179 | ``` 180 | 181 | ## 📦 [0.9.0](https://www.npmjs.com/package/v8r/v/0.9.0) - 2021-12-27 182 | 183 | * Accept glob pattern instead of filename. It is now possible to validate multiple files at once. e.g: 184 | ```bash 185 | v8r '{file1.json,file2.json}' 186 | v8r 'files/*' 187 | ``` 188 | * Improvements to terminal output styling 189 | * `.jsonc` and `.json5` files can now be validated 190 | 191 | ## 📦 [0.8.1](https://www.npmjs.com/package/v8r/v/0.8.1) - 2021-12-25 192 | 193 | * Fix `--version` flag when installed globally 194 | 195 | ## 📦 [0.8.0](https://www.npmjs.com/package/v8r/v/0.8.0) - 2021-12-25 196 | 197 | * Switch from CommonJS to ESModules internally 198 | * Requires node `^12.20.0 || ^14.13.1 || >=15.0.0` 199 | 200 | ## 📦 [0.7.0](https://www.npmjs.com/package/v8r/v/0.7.0) - 2021-11-30 201 | 202 | * Upgrade to ajv 8 internally 203 | Adds compatibility for JSON Schema draft 2019-09 and draft 2020-12 204 | * Docs/logging improvements to clarify behaviour of `--catalogs` param 205 | 206 | ## 📦 [0.6.1](https://www.npmjs.com/package/v8r/v/0.6.1) - 2021-08-06 207 | 208 | * Refactor cache module to remove global state 209 | 210 | ## 📦 [0.6.0](https://www.npmjs.com/package/v8r/v/0.6.0) - 2021-07-28 211 | 212 | * Add the ability to search custom schema catalogs using the `--catalogs` param 213 | 214 | ## 📦 [0.5.0](https://www.npmjs.com/package/v8r/v/0.5.0) - 2021-01-13 215 | 216 | * Allow validation against a local schema 217 | * Move cache file to OS temp dir 218 | 219 | ## 📦 [0.4.0](https://www.npmjs.com/package/v8r/v/0.4.0) - 2020-12-30 220 | 221 | * Resolve external references in schemas 222 | 223 | ## 📦 [0.3.0](https://www.npmjs.com/package/v8r/v/0.3.0) - 2020-12-29 224 | 225 | * Cache HTTP responses locally to improve performance 226 | * Add `--verbose` flag 227 | 228 | ## 📦 [0.2.0](https://www.npmjs.com/package/v8r/v/0.2.0) - 2020-12-24 229 | 230 | * Find schemas using paths and glob patterns 231 | * Add `--ignore-errors` flag 232 | 233 | ## 📦 [0.1.1](https://www.npmjs.com/package/v8r/v/0.1.1) - 2020-11-08 234 | 235 | * Add Documentation 236 | * Recognise `.geojson` and `.jsonld` as JSON files 237 | 238 | ## 📦 [0.1.0](https://www.npmjs.com/package/v8r/v/0.1.0) - 2020-11-08 239 | 240 | * First Release 241 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 chris48s 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # v8r 2 | 3 | [![build](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/build-status.svg)](https://github.com/chris48s/v8r/actions/workflows/build.yml?query=branch%3Amain) 4 | [![coverage](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/coverage.svg)](https://app.codecov.io/gh/chris48s/v8r) 5 | [![version](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/package-version.svg)](https://www.npmjs.com/package/v8r) 6 | [![license](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/package-license.svg)](https://www.npmjs.com/package/v8r) 7 | [![node](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/package-node-version.svg)](https://www.npmjs.com/package/v8r) 8 | 9 | v8r is a command-line validator that uses [Schema Store](https://www.schemastore.org/) to detect a suitable schema for your input files based on the filename. 10 | 11 | 📦 Install the package from [NPM](https://www.npmjs.com/package/v8r) 12 | 13 | 📚 Jump into the [Documentation](https://chris48s.github.io/v8r) to get started 14 | -------------------------------------------------------------------------------- /config-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "JSON schema for v8r config files", 3 | "$schema": "https://json-schema.org/draft/2019-09/schema", 4 | "type": "object", 5 | "additionalProperties": false, 6 | "properties": { 7 | "cacheTtl": { 8 | "description": "Remove cached HTTP responses older than cacheTtl seconds old. Specifying 0 clears and disables cache completely", 9 | "type": "integer", 10 | "minimum": 0 11 | }, 12 | "customCatalog": { 13 | "type": "object", 14 | "description": "A custom schema catalog. This catalog will be searched ahead of any custom catalogs passed using --catalogs or SchemaStore.org", 15 | "required": [ 16 | "schemas" 17 | ], 18 | "properties": { 19 | "schemas": { 20 | "type": "array", 21 | "description": "A list of JSON schema references.", 22 | "items": { 23 | "type": "object", 24 | "required": [ 25 | "name", 26 | "fileMatch", 27 | "location" 28 | ], 29 | "additionalProperties": false, 30 | "properties": { 31 | "description": { 32 | "description": "A description of the schema", 33 | "type": "string" 34 | }, 35 | "fileMatch": { 36 | "description": "A Minimatch glob expression for matching up file names with a schema.", 37 | "uniqueItems": true, 38 | "type": "array", 39 | "items": { 40 | "type": "string" 41 | } 42 | }, 43 | "location": { 44 | "description": "A URL or local file path for the schema location", 45 | "type": "string" 46 | }, 47 | "name": { 48 | "description": "The name of the schema", 49 | "type": "string" 50 | }, 51 | "parser": { 52 | "description": "A custom parser to use for files matching fileMatch instead of trying to infer the correct parser from the filename. 'json', 'json5', 'toml' and 'yaml' are always valid. Plugins may define additional values which are valid here.", 53 | "type": "string" 54 | } 55 | } 56 | } 57 | } 58 | } 59 | }, 60 | "outputFormat": { 61 | "description": "Output format for validation results. 'text' and 'json' are always valid. Plugins may define additional values which are valid here.", 62 | "type": "string" 63 | }, 64 | "ignoreErrors": { 65 | "description": "Exit with code 0 even if an error was encountered. True means a non-zero exit code is only issued if validation could be completed successfully and one or more files were invalid", 66 | "type": "boolean" 67 | }, 68 | "ignorePatternFiles": { 69 | "description": "A list of files containing glob patterns to ignore", 70 | "uniqueItems": true, 71 | "type": "array", 72 | "items": { 73 | "type": "string" 74 | } 75 | }, 76 | "patterns": { 77 | "type": "array", 78 | "description": "One or more filenames or glob patterns describing local file or files to validate", 79 | "minItems": 1, 80 | "uniqueItems": true, 81 | "items": { 82 | "type": "string" 83 | } 84 | }, 85 | "verbose": { 86 | "description": "Level of verbose logging. 0 is standard, higher numbers are more verbose", 87 | "type": "integer", 88 | "minimum": 0 89 | }, 90 | "plugins": { 91 | "type": "array", 92 | "description": "An array of strings describing v8r plugins to load", 93 | "uniqueItems": true, 94 | "items": { 95 | "type": "string", 96 | "pattern": "^(package:|file:)" 97 | } 98 | }, 99 | "$schema": { 100 | "type": "string" 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve("@docusaurus/core/lib/babel/preset")], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/build-plugin-docs.mjs: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import { execSync } from "node:child_process"; 4 | 5 | function ensureDirExists(dirPath) { 6 | if (!fs.existsSync(dirPath)) { 7 | fs.mkdirSync(dirPath, { recursive: true }); 8 | } 9 | } 10 | 11 | const tempDocsDir = "./.tempdocs"; 12 | const referenceFile = "./docs/plugins/reference.mdx"; 13 | const tempFiles = [ 14 | { filename: path.join(tempDocsDir, "BasePlugin.mdx"), title: "BasePlugin" }, 15 | { filename: path.join(tempDocsDir, "Document.mdx"), title: "Document" }, 16 | { 17 | filename: path.join(tempDocsDir, "ValidationResult.mdx"), 18 | title: "ValidationResult", 19 | }, 20 | ]; 21 | 22 | // clear files if they already exist 23 | if (fs.existsSync(tempDocsDir)) { 24 | fs.rmSync(tempDocsDir, { recursive: true, force: true }); 25 | } 26 | ensureDirExists(tempDocsDir); 27 | if (fs.existsSync(referenceFile)) { 28 | fs.unlinkSync(referenceFile); 29 | } 30 | 31 | // generate from JSDoc 32 | execSync("npx jsdoc-to-mdx -o ./.tempdocs -j jsdoc.json"); 33 | 34 | // post-processing 35 | let combinedContent = `--- 36 | sidebar_position: 3 37 | custom_edit_url: null 38 | --- 39 | 40 | # Plugin API Reference 41 | 42 | v8r exports two classes: [BasePlugin](#BasePlugin) and [Document](#Document). 43 | v8r plugins extend the [BasePlugin](#BasePlugin) class. 44 | Parsing a file should return a [Document](#Document) object. 45 | Additionally, validating a document yields a [ValidationResult](#ValidationResult) object. 46 | 47 | `; 48 | tempFiles.forEach((file) => { 49 | if (fs.existsSync(file.filename)) { 50 | let content = fs.readFileSync(file.filename, "utf8"); 51 | content = content 52 | .replace(/##/g, "###") 53 | .replace( 54 | /---\ncustom_edit_url: null\n---/g, 55 | `## ${file.title} {#${file.title}}`, 56 | ) 57 | .replace(/
/g, " ") 58 | .replace(/:---:/g, "---") 59 | .replace( 60 | /\[ValidationResult\]\(ValidationResult\)/g, 61 | "[ValidationResult](#ValidationResult)", 62 | ) 63 | .replace(/\[Document\]\(Document\)/g, "[Document](#Document)"); 64 | combinedContent += content; 65 | } 66 | }); 67 | 68 | // write out result 69 | ensureDirExists(path.dirname(tempDocsDir)); 70 | fs.writeFileSync(referenceFile, combinedContent); 71 | -------------------------------------------------------------------------------- /docs/docs/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Configuration 6 | 7 | v8r uses [CosmiConfig](https://www.npmjs.com/package/cosmiconfig) to search for a configuration. This means you can specify your configuration in any of the following places: 8 | 9 | - `package.json` 10 | - `.v8rrc` 11 | - `.v8rrc.json` 12 | - `.v8rrc.yaml` 13 | - `.v8rrc.yml` 14 | - `.v8rrc.js` 15 | - `.v8rrc.cjs` 16 | - `v8r.config.js` 17 | - `v8r.config.cjs` 18 | 19 | By default, v8r searches for a config file in the current working directory. 20 | 21 | If you want to load a config file from another location, you can invoke v8r with the `V8R_CONFIG_FILE` environment variable. All patterns and relative paths in the config file will be resolved relative to the current working directory rather than the config file location, even if the config file is loaded from somewhere other than the current working directory. 22 | 23 | Example yaml config file: 24 | 25 | ```yaml title=".v8rrc.yml" 26 | # - One or more filenames or glob patterns describing local file or files to validate 27 | # Patterns are resolved relative to the current working directory. 28 | # - overridden by passing one or more positional arguments 29 | patterns: ['*.json'] 30 | 31 | # - Level of verbose logging. 0 is standard, higher numbers are more verbose 32 | # - overridden by passing --verbose / -v 33 | # - default = 0 34 | verbose: 2 35 | 36 | # - Exit with code 0 even if an error was encountered. True means a non-zero exit 37 | # code is only issued if validation could be completed successfully and one or 38 | # more files were invalid 39 | # - overridden by passing --ignore-errors 40 | # - default = false 41 | ignoreErrors: true 42 | 43 | # - Remove cached HTTP responses older than cacheTtl seconds old. 44 | # Specifying 0 clears and disables cache completely 45 | # - overridden by passing --cache-ttl 46 | # - default = 600 47 | cacheTtl: 86400 48 | 49 | # - Output format for validation results 50 | # - overridden by passing --output-format 51 | # - default = text 52 | outputFormat: "json" 53 | 54 | # - A list of files containing glob patterns to ignore 55 | # Set this to [] to disable all ignore files 56 | # - overridden by passing --ignore-pattern-files or --no-ignore 57 | # - default = [".v8rignore", ".gitignore"] 58 | ignorePatternFiles: [] 59 | 60 | # - A custom schema catalog. 61 | # This catalog will be searched ahead of any custom catalogs passed using 62 | # --catalogs or SchemaStore.org 63 | # The format of this is subtly different to the format of a catalog 64 | # passed via --catalogs (which matches the SchemaStore.org format) 65 | customCatalog: 66 | schemas: 67 | - name: Custom Schema # The name of the schema (required) 68 | description: Custom Schema # A description of the schema (optional) 69 | 70 | # A Minimatch glob expression for matching up file names with a schema (required) 71 | # Expressions are resolved relative to the current working directory. 72 | fileMatch: ["*.geojson"] 73 | 74 | # A URL or local file path for the schema location (required) 75 | # Unlike the SchemaStore.org format, which has a `url` key, 76 | # custom catalogs defined in v8r config files have a `location` key 77 | # which can refer to either a URL or local file. 78 | # Relative paths are resolved relative to the current working directory. 79 | location: foo/bar/geojson-schema.json 80 | 81 | # A custom parser to use for files matching fileMatch 82 | # instead of trying to infer the correct parser from the filename (optional) 83 | # This property is specific to custom catalogs defined in v8r config files 84 | parser: json5 85 | 86 | # - An array of v8r plugins to load 87 | # - Plugins can only be specified in the config file. 88 | # They can't be loaded using command line arguments 89 | plugins: 90 | # Plugins installed from NPM (or JSR) must be prefixed by "package:" 91 | - "package:v8r-plugin-emoji-output" 92 | # Plugins in the project dir must be prefixed by "file:" 93 | # Relative paths are resolved relative to the current working directory. 94 | - "file:./subdir/my-local-plugin.mjs" 95 | ``` 96 | 97 | The config file format is specified more formally in a JSON Schema: 98 | 99 | - [machine-readable JSON](https://github.com/chris48s/v8r/blob/main/config-schema.json) 100 | - [human-readable HTML](https://json-schema-viewer.vercel.app/view?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchris48s%2Fv8r%2Fmain%2Fconfig-schema.json&show_breadcrumbs=on&template_name=flat) 101 | -------------------------------------------------------------------------------- /docs/docs/exit-codes.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 6 3 | --- 4 | 5 | # Exit codes 6 | 7 | v8r always exits with code `0` when: 8 | 9 | * The input glob pattern(s) matched one or more files, all input files were validated against a schema, and all input files were **valid** 10 | * `v8r` was called with `--help` or `--version` flags 11 | 12 | By default v8r exits with code `1` when an error was encountered trying to validate one or more input files. For example: 13 | 14 | * No suitable schema could be found 15 | * An error was encountered during an HTTP request 16 | * An input file was not JSON or yaml 17 | * etc 18 | 19 | This behaviour can be modified using the `--ignore-errors` flag. When invoked with `--ignore-errors` v8r will exit with code `0` even if one of these errors was encountered while attempting validation. A non-zero exit code will only be issued if validation could be completed successfully and the file was invalid. 20 | 21 | v8r always exits with code `97` when: 22 | 23 | * There was an error loading a config file 24 | * A config file was loaded but failed validation 25 | * There was an error loading a plugin 26 | * A plugin file was loaded but failed validation 27 | 28 | v8r always exits with code `98` when: 29 | 30 | * An input glob pattern was invalid 31 | * An input glob pattern was valid but did not match any files 32 | * All files matching input glob patterns were ignored 33 | 34 | v8r always exits with code `99` when: 35 | 36 | * The input glob pattern matched one or more files, one or more input files were validated against a schema and the input file was **invalid** 37 | -------------------------------------------------------------------------------- /docs/docs/faq.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 8 3 | --- 4 | 5 | # FAQ 6 | 7 | ### ❓ How does `v8r` decide what schema to validate against if I don't supply one? 8 | 9 | 💡 `v8r` queries the [Schema Store catalog](https://www.schemastore.org/) to try and find a suitable schema based on the name of the input file. 10 | 11 | ### ❓ My file is valid, but it doesn't validate against one of the suggested schemas. 12 | 13 | 💡 `v8r` is a fairly thin layer of glue between [Schema Store](https://www.schemastore.org/) (where the schemas come from) and [ajv](https://www.npmjs.com/package/ajv) (the validation engine). It is likely that this kind of problem is either an issue with the schema or validation engine. 14 | 15 | * Schema store issue tracker: https://github.com/SchemaStore/schemastore/issues 16 | * Ajv issue tracker: https://github.com/ajv-validator/ajv/issues 17 | 18 | ### ❓ What JSON schema versions are compatible? 19 | 20 | 💡 `v8r` works with JSON schema drafts: 21 | 22 | * draft-04 23 | * draft-06 24 | * draft-07 25 | * draft 2019-09 26 | * draft 2020-12 27 | 28 | ### ❓ Will 100% of the schemas on schemastore.org work with this tool? 29 | 30 | 💡 No. There are some with [known issues](https://github.com/chris48s/v8r/issues/18) 31 | 32 | ### ❓ Can `v8r` validate against a local schema? 33 | 34 | 💡 Yes. The `--schema` flag can be either a path to a local file or a URL. You can also use a [config file](./configuration.md) to include local schemas in a custom catalog. 35 | -------------------------------------------------------------------------------- /docs/docs/ignoring-files.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | # Ignore Patterns 6 | 7 | ## .v8rignore and .gitignore 8 | 9 | By default v8r looks for ignore patterns in a file called `.v8rignore` in the root of your project. `.v8rignore` uses [gitignore syntax](https://git-scm.com/docs/gitignore#_pattern_format). It also respects ignore patterns declared in `.gitignore`. 10 | 11 | ## Additional ignore files 12 | 13 | You can tell v8r to look for ignore patterns in more files using `ignorePatternFiles` in your [config file](./configuration.md) or `--ignore-pattern-files` on the command line. You can disable all ignore pattern files by passing `--no-ignore` on the command line. 14 | 15 | ## Extglob syntax 16 | 17 | For ad-hoc usage, it is possible to use extglob syntax with v8r. This means you can write patterns that include negations. For example if you wanted to validate all JSON files in the current directory excluding `package-lock.json` you could call 18 | 19 | ```bash 20 | v8r './!(package-lock)*.json' 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | slug: / 4 | --- 5 | 6 | # Introduction 7 | 8 | v8r is a command-line validator that uses [Schema Store](https://www.schemastore.org/) to detect a suitable schema for your input files based on the filename. 9 | 10 | NPM package
11 | GitHub repo 12 | 13 | ## Getting Started 14 | 15 | One-off: 16 | 17 | ```bash 18 | npx v8r@latest 19 | ``` 20 | 21 | Local install: 22 | 23 | ```bash 24 | npm install --save-dev v8r 25 | npx v8r 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/docs/plugins/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Plugins", 3 | "position": 6, 4 | "link": { 5 | "type": "generated-index" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /docs/docs/plugins/using-plugins.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Using Plugins 6 | 7 | It is possible to extend the functionality of v8r by installing plugins. 8 | 9 | Plugins can be packages installed from a registry like [npm](https://www.npmjs.com/) or [jsr](https://jsr.io/) or local files in your repo. 10 | 11 | Plugins must be specified in a [config file](../configuration.md). They can't be loaded using command line arguments. 12 | 13 | ```yaml title=".v8rrc.yml" 14 | plugins: 15 | # Plugins installed from NPM (or JSR) must be prefixed by "package:" 16 | - "package:v8r-plugin-emoji-output" 17 | # Plugins in the project dir must be prefixed by "file:" 18 | - "file:./subdir/my-local-plugin.mjs" 19 | ``` 20 | 21 | Plugins are invoked one at a time in the order they are specified in your config file. 22 | 23 | In general, there are four ways that users invoke v8r: 24 | 25 | - Local install (recommended) 26 | - Global install (`npm install -g v8r`) 27 | - Ad-hoc invocation (`npx v8r@latest`) 28 | - via [MegaLinter](https://megalinter.io/) 29 | 30 | Each of these methods has slightly different considerations when working with plugins. 31 | 32 | ## Local Install 33 | 34 | If you are using plugins, this is the recommended way to use v8r. In this case it is straightforward to use both NPM package plugins and local file plugins in your project. Install v8r and any plugins in your project's `package.json`. This allows you to pin your versions, co-ordinate upgrades in the event of any [breaking changes](../semver.md) and use different versions of v8r in different projects if needed. 35 | 36 | ## Global Install 37 | 38 | It is possible to install v8r into the global environment using `npm install -g v8r`. This can be useful if you want to use v8r across lots of projects but this model may not be a good fit if you use plugins. That said, you can also install NPM package plugins into your global environment. For example `npm install -g v8r v8r-plugin-ndjson`. 39 | 40 | As a general rule, if you have a project with a config file and local plugins, then you also want a local install of v8r. However, if you want to use local file plugins with a global install, you can run `npm link v8r` in your project dir. This will create a symlink in your project's `node_modules` dir to your global v8r install. That will then allow you to `import ... from "v8r";` in local plugins. 41 | 42 | ## Ad-hoc Invocation 43 | 44 | It is also possible to invoke v8r directly from npm using `npx`. If you invoke v8r ad-hoc with npx, there is no way to use a local file plugin. However, you can install NPM package plugins into the temporary environment. For example: 45 | 46 | ```bash 47 | npx --package v8r-plugin-ndjson@latest --package v8r@latest -- v8r *.json 48 | ``` 49 | 50 | For local file plugins, you will need an installation of some description. 51 | 52 | ## MegaLinter 53 | 54 | [MegaLinter](https://megalinter.io/) is a separate project, but it is one of the most common ways that users consume v8r. MegaLinter effectively gives you a global install of v8r (with the drawbacks associated with that). However the global packages live in `/node-deps`. 55 | 56 | Similar to a standard global install, you can install NPM packages into the global (or "root") environment. For example: 57 | 58 | ```yaml title=".mega-linter.yml" 59 | ENABLE_LINTERS: 60 | - JSON_V8R 61 | JSON_V8R_CLI_LINT_MODE: project 62 | JSON_V8R_PRE_COMMANDS: 63 | - command: 'npm install v8r-plugin-ndjson' 64 | continue_if_failed: False 65 | cwd: root 66 | ``` 67 | 68 | It is also possible to use local plugins with MegaLinter. The workaround for this is similar to using a global install. In this case, it is necessary to create the symlink we need manually because `npm link` doesn't know to look at the packages installed in `/node-deps`. 69 | 70 | ```yaml title=".mega-linter.yml" 71 | ENABLE_LINTERS: 72 | - JSON_V8R 73 | JSON_V8R_CLI_LINT_MODE: project 74 | JSON_V8R_PRE_COMMANDS: 75 | - command: 'mkdir -p node_modules' 76 | continue_if_failed: False 77 | cwd: workspace 78 | - command: 'ln -s /node-deps/node_modules/v8r node_modules/v8r' 79 | continue_if_failed: False 80 | cwd: workspace 81 | ``` 82 | -------------------------------------------------------------------------------- /docs/docs/plugins/writing-plugins.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Writing Plugins 6 | 7 | We can extend the functionality of v8r by writing a plugin. A plugin can be a local file contained within your project or package published to a registry like [npm](https://www.npmjs.com/) or [jsr](https://jsr.io/). 8 | 9 | Plugins extend the [BasePlugin](../reference) class which exposes hooks that allow us customise the parsing of files and output of results. Internally, v8r's [core parsers and output formats](https://github.com/chris48s/v8r/tree/main/src/plugins) are implemented as plugins. You can use these as a reference. 10 | 11 | ## Plugin Execution 12 | 13 | Plugins are invoked in the following sequence: 14 | 15 | Plugins that the user has specified in the config file are run first. These are executed in the order they are specified in the config file. 16 | 17 | v8r's core plugins run second. The order of execution for core plugins is non-configurable. 18 | 19 | ## Hook Types 20 | 21 | There are two patterns used by v8r plugin hooks. 22 | 23 | ### Register Hooks 24 | 25 | - `registerInputFileParsers` 26 | - `registerOutputFormats` 27 | 28 | These hooks return an array of strings. Any values returned by these hooks are added to the list of formats v8r can work with. 29 | 30 | ### Early Return Hooks 31 | 32 | - `parseInputFile` 33 | - `getSingleResultLogMessage` 34 | - `getAllResultsLogMessage` 35 | 36 | These hooks may optionally return or not return a value. Each plugin is run in sequence. The first plugin that returns a value "wins". That value will be used and no further plugins will be invoked. If a hook doesn't return anything, v8r will move on to the next plugin in the stack. 37 | 38 | ## Worked Example 39 | 40 | Lets build a simple example plugin. Our plugin is going to register an output format called "emoji". If the user passes `--output-format emoji` (or sets `outputFormat: emoji` in their config file) the plugin will output a 👍 when the file is valid and a 👎 when the file is invalid instead of the default text log message. 41 | 42 | ```js title="./plugins/v8r-plugin-emoji-output.js" 43 | // Our plugin extends the BasePlugin class 44 | import { BasePlugin } from "v8r"; 45 | 46 | class EmojiOutput extends BasePlugin { 47 | 48 | // v8r plugins must declare a name starting with "v8r-plugin-" 49 | static name = "v8r-plugin-emoji-output"; 50 | 51 | registerOutputFormats() { 52 | /* 53 | Registering "emoji" as an output format here adds "emoji" to the list 54 | of values the user may pass to the --output-format argument. 55 | We could register multiple output formats here if we want, 56 | but we're just going to register one. 57 | */ 58 | return ["emoji"]; 59 | } 60 | 61 | /* 62 | We're going to implement the getSingleResultLogMessage hook here. This 63 | allows us to optionally return a log message to be written to stdout 64 | after each file is validated. 65 | */ 66 | getSingleResultLogMessage(result, format) { 67 | /* 68 | Check if the user has requested "emoji" output. 69 | If the user hasn't requested emoji output, we don't want to return a value. 70 | That will allow v8r to hand over to the next plugin in the sequence 71 | to check for other output formats. 72 | */ 73 | if (format === "emoji") { 74 | // Implement our plugin logic 75 | if (result.valid === true) { 76 | return "👍"; 77 | } 78 | return "👎"; 79 | } 80 | } 81 | } 82 | 83 | // Our plugin must be an ESM module 84 | // and the plugin class must be the default export 85 | export default EmojiOutput; 86 | ``` 87 | 88 | We can now register the plugin in our config file: 89 | 90 | ```yaml title=".v8rrc.yml" 91 | plugins: 92 | - "file:./plugins/v8r-plugin-emoji-output.js" 93 | ``` 94 | -------------------------------------------------------------------------------- /docs/docs/proxy.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | # Configuring a Proxy 6 | 7 | It is possible to configure a proxy via [global-agent](https://www.npmjs.com/package/global-agent) using the `GLOBAL_AGENT_HTTP_PROXY` environment variable: 8 | 9 | ```bash 10 | export GLOBAL_AGENT_HTTP_PROXY=http://myproxy:8888 11 | ``` 12 | -------------------------------------------------------------------------------- /docs/docs/semver.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 7 3 | --- 4 | 5 | # Versioning 6 | 7 | v8r follows [semantic versioning](https://semver.org/). For this project, the "API" is defined as: 8 | 9 | - CLI flags and options 10 | - CLI exit codes 11 | - The configuration file format 12 | - The native JSON output format 13 | - The `BasePlugin` class, `Document` class, and `ValidationResult` type 14 | 15 | A "breaking change" also includes: 16 | 17 | - Dropping compatibility with a major Node JS version 18 | - Dropping compatibility with a JSON Schema draft 19 | -------------------------------------------------------------------------------- /docs/docs/usage-examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Usage Examples 6 | 7 | ## Validating files 8 | 9 | v8r can validate JSON, YAML or TOML files. You can pass filenames or glob patterns: 10 | 11 | ```bash 12 | # single filename 13 | $ v8r package.json 14 | 15 | # multiple files 16 | $ v8r file1.json file2.json 17 | 18 | # glob patterns 19 | $ v8r 'dir/*.yml' 'dir/*.yaml' 20 | ``` 21 | 22 | [DigitalOcean's Glob Tool](https://www.digitalocean.com/community/tools/glob) can be used to help construct glob patterns 23 | 24 | ## Manually specifying a schema 25 | 26 | By default, v8r queries [Schema Store](https://www.schemastore.org/) to detect a suitable schema based on the filename. 27 | 28 | ```bash 29 | # if v8r can't auto-detect a schema for your file.. 30 | $ v8r feature.geojson 31 | ℹ Processing ./feature.geojson 32 | ✖ Could not find a schema to validate feature.geojson 33 | 34 | # ..you can specify one using the --schema flag 35 | $ v8r feature.geojson --schema https://json.schemastore.org/geojson 36 | ℹ Processing ./feature.geojson 37 | ℹ Validating feature.geojson against schema from https://json.schemastore.org/geojson ... 38 | ✔ feature.geojson is valid 39 | ``` 40 | 41 | ## Using a custom catlog 42 | 43 | Using the `--schema` flag will validate all files matched by the glob pattern against that schema. You can also define a custom [schema catalog](https://json.schemastore.org/schema-catalog.json). v8r will search any custom catalogs before falling back to [Schema Store](https://www.schemastore.org/). 44 | 45 | 46 | ```js title="my-catalog.json" 47 | { "$schema": "https://json.schemastore.org/schema-catalog.json", 48 | "version": 1, 49 | "schemas": [ { "name": "geojson", 50 | "description": "geojson", 51 | "url": "https://json.schemastore.org/geojson.json", 52 | "fileMatch": ["*.geojson"] } ] } 53 | ``` 54 | 55 | ``` 56 | $ v8r feature.geojson -c my-catalog.json 57 | ℹ Processing ./feature.geojson 58 | ℹ Found schema in my-catalog.json ... 59 | ℹ Validating feature.geojson against schema from https://json.schemastore.org/geojson ... 60 | ✔ feature.geojson is valid 61 | ``` 62 | 63 | This can be used to specify different custom schemas for multiple file patterns. 64 | 65 | ## Files Containing Multiple Documents 66 | 67 | A single YAML file can contain [multiple documents](https://www.yaml.info/learn/document.html). v8r is able to parse and validate these files. In this situation: 68 | 69 | - All documents within the file are assumed to conform to the same schema. It is not possible to validate documents within the same file against different schemas 70 | - Documents within the file are referred to as `multi-doc.yml[0]`, `multi-doc.yml[1]`, etc 71 | 72 | ``` 73 | $ v8r catalog-info.yaml 74 | ℹ Processing ./catalog-info.yaml 75 | ℹ Found schema in https://www.schemastore.org/api/json/catalog.json ... 76 | ℹ Validating ./catalog-info.yaml against schema from https://json.schemastore.org/catalog-info.json ... 77 | ✔ ./catalog-info.yaml[0] is valid 78 | 79 | ✔ ./catalog-info.yaml[1] is valid 80 | ``` 81 | -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // `@type` JSDoc annotations allow editor autocompletion and type checking 3 | // (when paired with `@ts-check`). 4 | // There are various equivalent ways to declare your Docusaurus config. 5 | // See: https://docusaurus.io/docs/api/docusaurus-config 6 | 7 | import { themes as prismThemes } from "prism-react-renderer"; 8 | 9 | /** @type {import("@docusaurus/types").Config} */ 10 | const config = { 11 | title: "v8r", 12 | tagline: "A command-line validator that's on your wavelength", 13 | favicon: "img/favicon.ico", 14 | 15 | // Set the production url of your site here 16 | url: "https://chris48s.github.io", 17 | // Set the // pathname under which your site is served 18 | // For GitHub pages deployment, it is often '//' 19 | baseUrl: "/v8r/", 20 | 21 | // GitHub pages deployment config. 22 | // If you aren't using GitHub pages, you don't need these. 23 | organizationName: "chris48s", // Usually your GitHub org/user name. 24 | projectName: "v8r", // Usually your repo name. 25 | deploymentBranch: "gh-pages", 26 | trailingSlash: true, 27 | 28 | onBrokenLinks: "throw", 29 | onBrokenMarkdownLinks: "warn", 30 | 31 | // Even if you don't use internationalization, you can use this field to set 32 | // useful metadata like html lang. For example, if your site is Chinese, you 33 | // may want to replace "en" with "zh-Hans". 34 | i18n: { 35 | defaultLocale: "en", 36 | locales: ["en"], 37 | }, 38 | 39 | presets: [ 40 | [ 41 | "classic", 42 | /** @type {import("@docusaurus/preset-classic").Options} */ 43 | ({ 44 | docs: { 45 | sidebarPath: "./sidebars.js", 46 | editUrl: "https://github.com/chris48s/v8r/tree/main/docs/", 47 | routeBasePath: "/", 48 | }, 49 | blog: false, 50 | theme: { 51 | customCss: "./src/css/custom.css", 52 | }, 53 | }), 54 | ], 55 | ], 56 | 57 | themeConfig: 58 | /** @type {import("@docusaurus/preset-classic").ThemeConfig} */ 59 | ({ 60 | navbar: { 61 | title: "v8r", 62 | logo: { 63 | alt: "Logo", 64 | src: "img/logo.png", 65 | }, 66 | items: [ 67 | { 68 | type: "docSidebar", 69 | sidebarId: "docSidebar", 70 | position: "left", 71 | label: "Docs", 72 | }, 73 | { 74 | href: "https://github.com/chris48s/v8r", 75 | label: "GitHub", 76 | position: "right", 77 | }, 78 | ], 79 | }, 80 | footer: { 81 | style: "dark", 82 | links: [ 83 | { 84 | title: "Links", 85 | items: [ 86 | { 87 | label: "GitHub", 88 | href: "https://github.com/chris48s/v8r", 89 | }, 90 | { 91 | label: "NPM", 92 | href: "https://www.npmjs.com/package/v8r", 93 | }, 94 | { 95 | label: "Changelog", 96 | href: "https://github.com/chris48s/v8r/blob/main/CHANGELOG.md", 97 | }, 98 | ], 99 | }, 100 | ], 101 | }, 102 | prism: { 103 | theme: prismThemes.github, 104 | darkTheme: prismThemes.dracula, 105 | additionalLanguages: ["bash"], 106 | }, 107 | }), 108 | }; 109 | 110 | export default config; 111 | -------------------------------------------------------------------------------- /docs/jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "include": ["../src/plugins.js"] 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "v8r-docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build-plugin-docs": "node build-plugin-docs.mjs", 7 | "start": "node build-plugin-docs.mjs && docusaurus start", 8 | "build": "node build-plugin-docs.mjs && docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "clear": "docusaurus clear", 11 | "write-translations": "docusaurus write-translations", 12 | "write-heading-ids": "docusaurus write-heading-ids" 13 | }, 14 | "devDependencies": { 15 | "@docusaurus/core": "3.7.0", 16 | "@docusaurus/module-type-aliases": "3.7.0", 17 | "@docusaurus/types": "3.7.0", 18 | "@docusaurus/preset-classic": "3.7.0", 19 | "@mdx-js/react": "^3.1.0", 20 | "clsx": "^2.0.0", 21 | "jsdoc-to-mdx": "^1.2.1", 22 | "prism-react-renderer": "^2.4.1", 23 | "react": "^18.0.0", 24 | "react-dom": "^18.0.0" 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.5%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 3 chrome version", 34 | "last 3 firefox version", 35 | "last 5 safari version" 36 | ] 37 | }, 38 | "engines": { 39 | "node": ">=18.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | * 4 | * - Create an ordered group of docs 5 | * - Render a sidebar for each doc of that group 6 | * - Provide next/previous navigation 7 | * 8 | * The sidebars can be generated from the filesystem, or explicitly defined 9 | * here. 10 | * 11 | * Create as many sidebars as you want. 12 | */ 13 | 14 | // @ts-check 15 | 16 | /** @type {import("@docusaurus/plugin-content-docs").SidebarsConfig} */ 17 | const sidebars = { 18 | // By default, Docusaurus generates a sidebar from the docs folder structure 19 | docSidebar: [{ type: "autogenerated", dirName: "." }], 20 | 21 | // But you can create a sidebar manually 22 | /* 23 | docSidebar: [ 24 | 'intro', 25 | 'hello', 26 | { 27 | type: 'category', 28 | label: 'Tutorial', 29 | items: ['tutorial-basics/create-a-document'], 30 | }, 31 | ], 32 | */ 33 | }; 34 | 35 | export default sidebars; 36 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #2e8555; 10 | --ifm-color-primary-dark: #29784c; 11 | --ifm-color-primary-darker: #277148; 12 | --ifm-color-primary-darkest: #205d3b; 13 | --ifm-color-primary-light: #33925d; 14 | --ifm-color-primary-lighter: #359962; 15 | --ifm-color-primary-lightest: #3cad6e; 16 | --ifm-code-font-size: 95%; 17 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 18 | } 19 | 20 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 21 | [data-theme='dark'] { 22 | --ifm-color-primary: #25c2a0; 23 | --ifm-color-primary-dark: #21af90; 24 | --ifm-color-primary-darker: #1fa588; 25 | --ifm-color-primary-darkest: #1a8870; 26 | --ifm-color-primary-light: #29d5b0; 27 | --ifm-color-primary-lighter: #32d8b4; 28 | --ifm-color-primary-lightest: #4fddbf; 29 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 30 | } 31 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris48s/v8r/ead986997948d0520b0e4ab06d05880f9256cd35/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris48s/v8r/ead986997948d0520b0e4ab06d05880f9256cd35/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/github.svg: -------------------------------------------------------------------------------- 1 | GitHub -------------------------------------------------------------------------------- /docs/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris48s/v8r/ead986997948d0520b0e4ab06d05880f9256cd35/docs/static/img/logo.png -------------------------------------------------------------------------------- /docs/static/img/npm.svg: -------------------------------------------------------------------------------- 1 | npm -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import js from "@eslint/js"; 3 | import jsdocPlugin from "eslint-plugin-jsdoc"; 4 | import prettierConfig from "eslint-config-prettier"; 5 | import prettierPlugin from "eslint-plugin-prettier"; 6 | import mochaPlugin from "eslint-plugin-mocha"; 7 | 8 | const config = [ 9 | { 10 | ignores: ["docs/.docusaurus/**/*", "docs/build/**/*"], 11 | }, 12 | js.configs.recommended, 13 | mochaPlugin.configs.recommended, 14 | prettierConfig, 15 | jsdocPlugin.configs["flat/recommended-error"], 16 | { 17 | plugins: { 18 | mocha: mochaPlugin, 19 | prettier: prettierPlugin, 20 | jsdoc: jsdocPlugin, 21 | }, 22 | languageOptions: { 23 | ecmaVersion: 2022, 24 | sourceType: "module", 25 | globals: { 26 | mocha: true, 27 | ...globals.node, 28 | }, 29 | }, 30 | rules: { 31 | "prettier/prettier": ["error"], 32 | "mocha/no-pending-tests": ["error"], 33 | "mocha/no-exclusive-tests": ["error"], 34 | "mocha/max-top-level-suites": ["off"], 35 | "jsdoc/require-jsdoc": ["off"], 36 | "jsdoc/tag-lines": ["off"], // let prettier-plugin-jsdoc take care of this 37 | }, 38 | }, 39 | ]; 40 | 41 | export default config; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "v8r", 3 | "version": "5.0.0", 4 | "description": "A command-line JSON, YAML and TOML validator that's on your wavelength", 5 | "scripts": { 6 | "test": "V8R_CACHE_NAME=v8r-test c8 --reporter=text mocha \"src/**/*.spec.js\"", 7 | "lint": "eslint \"**/*.{js,cjs,mjs}\"", 8 | "coverage": "c8 report --reporter=cobertura", 9 | "prettier": "prettier --write \"**/*.{js,cjs,mjs}\"", 10 | "prettier:check": "prettier --check \"**/*.{js,cjs,mjs}\"", 11 | "v8r": "src/index.js" 12 | }, 13 | "bin": { 14 | "v8r": "src/index.js" 15 | }, 16 | "exports": "./src/public.js", 17 | "files": [ 18 | "src/**/!(*.spec).js", 19 | "config-schema.json", 20 | "CHANGELOG.md" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/chris48s/v8r.git" 25 | }, 26 | "homepage": "https://github.com/chris48s/v8r", 27 | "author": "chris48s", 28 | "license": "MIT", 29 | "dependencies": { 30 | "ajv": "^8.8.2", 31 | "ajv-draft-04": "^1.0.0", 32 | "ajv-formats": "^3.0.1", 33 | "chalk": "^5.0.0", 34 | "cosmiconfig": "^9.0.0", 35 | "decamelize": "^6.0.0", 36 | "flat-cache": "^6.1.4", 37 | "glob": "^11.0.0", 38 | "global-agent": "^3.0.0", 39 | "got": "^14.0.0", 40 | "ignore": "^7.0.0", 41 | "is-url": "^1.2.4", 42 | "js-yaml": "^4.0.0", 43 | "json5": "^2.2.0", 44 | "minimatch": "^10.0.0", 45 | "smol-toml": "^1.0.1", 46 | "yargs": "^17.0.1" 47 | }, 48 | "devDependencies": { 49 | "c8": "^10.1.2", 50 | "eslint": "^9.9.0", 51 | "eslint-config-prettier": "^10.1.2", 52 | "eslint-plugin-jsdoc": "^50.2.2", 53 | "eslint-plugin-mocha": "^11.0.0", 54 | "eslint-plugin-prettier": "^5.0.0", 55 | "mocha": "^11.0.1", 56 | "mock-cwd": "^1.0.0", 57 | "nock": "^14.0.4", 58 | "prettier": "^3.0.0", 59 | "prettier-plugin-jsdoc": "^1.3.0" 60 | }, 61 | "engines": { 62 | "node": ">=20" 63 | }, 64 | "type": "module", 65 | "keywords": [ 66 | "JSON", 67 | "YAML", 68 | "schema", 69 | "validator", 70 | "validation", 71 | "jsonschema", 72 | "json-schema", 73 | "command-line" 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /src/ajv.js: -------------------------------------------------------------------------------- 1 | import { createRequire } from "node:module"; 2 | // TODO: once JSON modules is stable these requires could become imports 3 | // https://nodejs.org/api/esm.html#esm_experimental_json_modules 4 | const require = createRequire(import.meta.url); 5 | 6 | import AjvDraft4 from "ajv-draft-04"; 7 | import Ajv from "ajv"; 8 | import Ajv2019 from "ajv/dist/2019.js"; 9 | import Ajv2020 from "ajv/dist/2020.js"; 10 | import addFormats from "ajv-formats"; 11 | 12 | function _ajvFactory( 13 | schema, 14 | strictMode, 15 | cache, 16 | resolver = (url) => cache.fetch(url), 17 | ) { 18 | const opts = { allErrors: true, loadSchema: resolver, strict: strictMode }; 19 | 20 | if ( 21 | typeof schema["$schema"] === "string" || 22 | schema["$schema"] instanceof String 23 | ) { 24 | if (schema["$schema"].includes("json-schema.org/draft-04/schema")) { 25 | opts.schemaId = "auto"; 26 | return new AjvDraft4(opts); 27 | } else if (schema["$schema"].includes("json-schema.org/draft-06/schema")) { 28 | const ajvDraft06 = new Ajv(opts); 29 | ajvDraft06.addMetaSchema( 30 | require("ajv/lib/refs/json-schema-draft-06.json"), 31 | ); 32 | return ajvDraft06; 33 | } else if (schema["$schema"].includes("json-schema.org/draft-07/schema")) { 34 | return new Ajv(opts); 35 | } else if ( 36 | schema["$schema"].includes("json-schema.org/draft/2019-09/schema") 37 | ) { 38 | return new Ajv2019(opts); 39 | } else if ( 40 | schema["$schema"].includes("json-schema.org/draft/2020-12/schema") 41 | ) { 42 | return new Ajv2020(opts); 43 | } 44 | } 45 | 46 | // hedge our bets as best we can 47 | const ajv = new Ajv(opts); 48 | ajv.addMetaSchema(require("ajv/lib/refs/json-schema-draft-06.json")); 49 | return ajv; 50 | 51 | /* TODO: 52 | const ajv = new Ajv2019(opts); 53 | ajv.addMetaSchema(require("ajv/dist/refs/json-schema-draft-07.json")); 54 | return ajv 55 | 56 | might also be an equally valid fallback here 57 | */ 58 | } 59 | 60 | async function validate(data, schema, strictMode, cache, resolver) { 61 | const ajv = _ajvFactory(schema, strictMode, cache, resolver); 62 | addFormats(ajv); 63 | const validateFn = await ajv.compileAsync(schema); 64 | const valid = validateFn(data); 65 | return { valid, errors: validateFn.errors ? validateFn.errors : [] }; 66 | } 67 | 68 | export { _ajvFactory, validate }; 69 | -------------------------------------------------------------------------------- /src/ajv.spec.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import { FlatCache } from "flat-cache"; 3 | import { Cache } from "./cache.js"; 4 | import { _ajvFactory } from "./ajv.js"; 5 | import { testCacheName, setUp, tearDown } from "./test-helpers.js"; 6 | 7 | describe("_ajvFactory", function () { 8 | describe("schema drafts compatibility", function () { 9 | let testCache; 10 | 11 | before(function () { 12 | const ttl = 3000; 13 | const cache = new FlatCache({ cacheId: testCacheName, ttl: ttl }); 14 | cache.load(); 15 | testCache = new Cache(cache); 16 | }); 17 | 18 | beforeEach(function () { 19 | setUp(); 20 | }); 21 | 22 | afterEach(function () { 23 | tearDown(); 24 | }); 25 | 26 | it("should support draft-04", function () { 27 | const ajv = _ajvFactory( 28 | { $schema: "http://json-schema.org/draft-04/schema#" }, 29 | false, 30 | testCache, 31 | ); 32 | assert( 33 | Object.prototype.hasOwnProperty.call( 34 | ajv.schemas, 35 | "http://json-schema.org/draft-04/schema", 36 | ), 37 | ); 38 | }); 39 | 40 | it("should support draft-06", function () { 41 | const ajv = _ajvFactory( 42 | { $schema: "http://json-schema.org/draft-06/schema#" }, 43 | false, 44 | testCache, 45 | ); 46 | assert( 47 | Object.prototype.hasOwnProperty.call( 48 | ajv.schemas, 49 | "http://json-schema.org/draft-06/schema", 50 | ), 51 | ); 52 | }); 53 | 54 | it("should support draft-07", function () { 55 | const ajv = _ajvFactory( 56 | { $schema: "http://json-schema.org/draft-07/schema#" }, 57 | false, 58 | testCache, 59 | ); 60 | assert( 61 | Object.prototype.hasOwnProperty.call( 62 | ajv.schemas, 63 | "http://json-schema.org/draft-07/schema", 64 | ), 65 | ); 66 | }); 67 | 68 | it("should support draft-2019-09", function () { 69 | const ajv = _ajvFactory( 70 | { $schema: "https://json-schema.org/draft/2019-09/schema" }, 71 | false, 72 | testCache, 73 | ); 74 | assert( 75 | Object.prototype.hasOwnProperty.call( 76 | ajv.schemas, 77 | "https://json-schema.org/draft/2019-09/schema", 78 | ), 79 | ); 80 | }); 81 | 82 | it("should support draft-2020-12", function () { 83 | const ajv = _ajvFactory( 84 | { $schema: "https://json-schema.org/draft/2020-12/schema" }, 85 | false, 86 | testCache, 87 | ); 88 | assert( 89 | Object.prototype.hasOwnProperty.call( 90 | ajv.schemas, 91 | "https://json-schema.org/draft/2020-12/schema", 92 | ), 93 | ); 94 | }); 95 | 96 | it("should fall back to draft-06/draft-07 mode if $schema key is missing", function () { 97 | const ajv = _ajvFactory({}, false, testCache); 98 | assert( 99 | Object.prototype.hasOwnProperty.call( 100 | ajv.schemas, 101 | "http://json-schema.org/draft-06/schema", 102 | ), 103 | ); 104 | assert( 105 | Object.prototype.hasOwnProperty.call( 106 | ajv.schemas, 107 | "http://json-schema.org/draft-07/schema", 108 | ), 109 | ); 110 | }); 111 | 112 | it("should fall back to draft-06/draft-07 mode if $schema key is invalid (str)", function () { 113 | const ajv = _ajvFactory({ $schema: "foobar" }, false, testCache); 114 | assert( 115 | Object.prototype.hasOwnProperty.call( 116 | ajv.schemas, 117 | "http://json-schema.org/draft-06/schema", 118 | ), 119 | ); 120 | assert( 121 | Object.prototype.hasOwnProperty.call( 122 | ajv.schemas, 123 | "http://json-schema.org/draft-07/schema", 124 | ), 125 | ); 126 | }); 127 | 128 | it("should fall back to draft-06/draft-07 mode if $schema key is invalid (not str)", function () { 129 | const ajv = _ajvFactory({ $schema: true }, false, testCache); 130 | assert( 131 | Object.prototype.hasOwnProperty.call( 132 | ajv.schemas, 133 | "http://json-schema.org/draft-06/schema", 134 | ), 135 | ); 136 | assert( 137 | Object.prototype.hasOwnProperty.call( 138 | ajv.schemas, 139 | "http://json-schema.org/draft-07/schema", 140 | ), 141 | ); 142 | }); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /src/bootstrap.js: -------------------------------------------------------------------------------- 1 | import { createRequire } from "node:module"; 2 | // TODO: once JSON modules is stable these requires could become imports 3 | // https://nodejs.org/api/esm.html#esm_experimental_json_modules 4 | const require = createRequire(import.meta.url); 5 | 6 | import fs from "node:fs"; 7 | import path from "node:path"; 8 | import { cosmiconfig } from "cosmiconfig"; 9 | import decamelize from "decamelize"; 10 | import yargs from "yargs"; 11 | import { hideBin } from "yargs/helpers"; 12 | import { 13 | validateConfigAgainstSchema, 14 | validateConfigDocumentParsers, 15 | validateConfigOutputFormats, 16 | } from "./config-validators.js"; 17 | import logger from "./logger.js"; 18 | import { loadAllPlugins, resolveUserPlugins } from "./plugins.js"; 19 | 20 | async function getCosmiConfig(cosmiconfigOptions) { 21 | let configFile; 22 | 23 | if (process.env.V8R_CONFIG_FILE) { 24 | if (!fs.existsSync(process.env.V8R_CONFIG_FILE)) { 25 | throw new Error(`File ${process.env.V8R_CONFIG_FILE} does not exist.`); 26 | } 27 | configFile = await cosmiconfig("v8r", cosmiconfigOptions).load( 28 | process.env.V8R_CONFIG_FILE, 29 | ); 30 | } else { 31 | cosmiconfigOptions.stopDir = process.cwd(); 32 | configFile = (await cosmiconfig("v8r", cosmiconfigOptions).search( 33 | process.cwd(), 34 | )) || { config: {} }; 35 | } 36 | 37 | if (configFile.filepath) { 38 | logger.info(`Loaded config file from ${getRelativeFilePath(configFile)}`); 39 | logger.info( 40 | `Patterns and relative paths will be resolved relative to current working directory: ${process.cwd()}`, 41 | ); 42 | } else { 43 | logger.info(`No config file found`); 44 | } 45 | return configFile; 46 | } 47 | 48 | function mergeConfigs(args, config) { 49 | const mergedConfig = { ...args }; 50 | mergedConfig.cacheName = config?.config?.cacheName; 51 | mergedConfig.customCatalog = config?.config?.customCatalog; 52 | mergedConfig.configFileRelativePath = undefined; 53 | if (config.filepath) { 54 | mergedConfig.configFileRelativePath = getRelativeFilePath(config); 55 | } 56 | return mergedConfig; 57 | } 58 | 59 | function getRelativeFilePath(config) { 60 | return path.relative(process.cwd(), config.filepath); 61 | } 62 | 63 | function parseArgs(argv, config, documentFormats, outputFormats) { 64 | const parser = yargs(hideBin(argv)); 65 | 66 | let command = "$0 "; 67 | const patternsOpts = { 68 | describe: 69 | "One or more filenames or glob patterns describing local file or files to validate", 70 | }; 71 | if (Object.keys(config.config).includes("patterns")) { 72 | command = "$0 [patterns..]"; 73 | patternsOpts.default = config.config.patterns; 74 | patternsOpts.defaultDescription = `${JSON.stringify( 75 | config.config.patterns, 76 | )} (from config file ${getRelativeFilePath(config)})`; 77 | } 78 | 79 | const ignoreFilesOpts = { 80 | describe: "A list of files containing glob patterns to ignore", 81 | }; 82 | let ignoreFilesDefault = [".v8rignore", ".gitignore"]; 83 | ignoreFilesOpts.defaultDescription = `${JSON.stringify(ignoreFilesDefault)}`; 84 | if (Object.keys(config.config).includes("ignorePatternFiles")) { 85 | ignoreFilesDefault = config.config.ignorePatternFiles; 86 | ignoreFilesOpts.defaultDescription = `${JSON.stringify( 87 | ignoreFilesDefault, 88 | )} (from config file ${getRelativeFilePath(config)})`; 89 | } 90 | 91 | parser 92 | .command( 93 | // command 94 | command, 95 | 96 | // description 97 | `Validate local ${documentFormats.join("/")} files against schema(s)`, 98 | 99 | // builder 100 | (yargs) => { 101 | yargs.positional("patterns", patternsOpts); 102 | }, 103 | 104 | // handler 105 | (args) => { 106 | /* 107 | Yargs doesn't allow .conflicts() with an argument that has a default 108 | value (it considers the arg "set" even if we just use the default) 109 | so we need to apply the default values here. 110 | */ 111 | if (args.ignorePatternFiles === undefined) { 112 | args.ignorePatternFiles = args["ignore-pattern-files"] = 113 | ignoreFilesDefault; 114 | } 115 | 116 | if (args.ignore === false) { 117 | args.ignorePatternFiles = args["ignore-pattern-files"] = []; 118 | } 119 | 120 | if (args.ignore === undefined) { 121 | args.ignore = true; 122 | } 123 | }, 124 | ) 125 | .version( 126 | // Workaround for https://github.com/yargs/yargs/issues/1934 127 | // TODO: remove once fixed 128 | require("../package.json").version, 129 | ) 130 | .option("verbose", { 131 | alias: "v", 132 | type: "boolean", 133 | description: "Run with verbose logging. Can be stacked e.g: -vv -vvv", 134 | }) 135 | .count("verbose") 136 | .option("schema", { 137 | alias: "s", 138 | type: "string", 139 | describe: 140 | "Local path or URL of a schema to validate against. " + 141 | "If not supplied, we will attempt to find an appropriate schema on " + 142 | "schemastore.org using the filename. If passed with glob pattern(s) " + 143 | "matching multiple files, all matching files will be validated " + 144 | "against this schema", 145 | }) 146 | .option("catalogs", { 147 | type: "string", 148 | alias: "c", 149 | array: true, 150 | describe: 151 | "A list of local paths or URLs of custom catalogs to use prior to schemastore.org", 152 | }) 153 | .conflicts("schema", "catalogs") 154 | .option("ignore-errors", { 155 | type: "boolean", 156 | default: false, 157 | describe: 158 | "Exit with code 0 even if an error was encountered. Passing this flag " + 159 | "means a non-zero exit code is only issued if validation could be " + 160 | "completed successfully and one or more files were invalid", 161 | }) 162 | .option("ignore-pattern-files", { 163 | type: "string", 164 | array: true, 165 | describe: "A list of files containing glob patterns to ignore", 166 | ...ignoreFilesOpts, 167 | }) 168 | .option("no-ignore", { 169 | type: "boolean", 170 | describe: "Disable all ignore files", 171 | }) 172 | .conflicts("ignore-pattern-files", "no-ignore") 173 | .option("cache-ttl", { 174 | type: "number", 175 | default: 600, 176 | describe: 177 | "Remove cached HTTP responses older than seconds old. " + 178 | "Passing 0 clears and disables cache completely", 179 | }) 180 | .option("output-format", { 181 | type: "string", 182 | choices: outputFormats, 183 | default: "text", 184 | describe: "Output format for validation results", 185 | }) 186 | .example([ 187 | ["$0 file.json", "Validate a single file"], 188 | ["$0 file1.json file2.json", "Validate multiple files"], 189 | [ 190 | "$0 'dir/*.yml' 'dir/*.yaml'", 191 | "Specify files to validate with glob patterns", 192 | ], 193 | ]); 194 | 195 | for (const [key, value] of Object.entries(config.config)) { 196 | if (["cacheTtl", "outputFormat", "ignoreErrors", "verbose"].includes(key)) { 197 | parser.default( 198 | decamelize(key, { separator: "-" }), 199 | value, 200 | `${value} (from config file ${getRelativeFilePath(config)})`, 201 | ); 202 | } 203 | } 204 | 205 | return parser.argv; 206 | } 207 | 208 | function getDocumentFormats(loadedPlugins) { 209 | let documentFormats = []; 210 | for (const plugin of loadedPlugins) { 211 | documentFormats = documentFormats.concat(plugin.registerInputFileParsers()); 212 | } 213 | return documentFormats; 214 | } 215 | 216 | function getOutputFormats(loadedPlugins) { 217 | let outputFormats = []; 218 | for (const plugin of loadedPlugins) { 219 | outputFormats = outputFormats.concat(plugin.registerOutputFormats()); 220 | } 221 | return outputFormats; 222 | } 223 | 224 | async function bootstrap(argv, config, cosmiconfigOptions = {}) { 225 | if (config) { 226 | // special case for unit testing purposes 227 | // this allows us to inject an incomplete config and bypass the validation 228 | const { allLoadedPlugins, loadedCorePlugins, loadedUserPlugins } = 229 | await loadAllPlugins(config.plugins || []); 230 | return { 231 | config, 232 | allLoadedPlugins, 233 | loadedCorePlugins, 234 | loadedUserPlugins, 235 | }; 236 | } 237 | 238 | // load the config file and validate it against the schema 239 | const configFile = await getCosmiConfig(cosmiconfigOptions); 240 | validateConfigAgainstSchema(configFile); 241 | 242 | // load both core and user plugins 243 | let plugins = resolveUserPlugins(configFile.config.plugins || []); 244 | const { allLoadedPlugins, loadedCorePlugins, loadedUserPlugins } = 245 | await loadAllPlugins(plugins); 246 | const documentFormats = getDocumentFormats(allLoadedPlugins); 247 | const outputFormats = getOutputFormats(allLoadedPlugins); 248 | 249 | // now we have documentFormats and outputFormats 250 | // we can finish validating and processing the config 251 | validateConfigDocumentParsers(configFile, documentFormats); 252 | validateConfigOutputFormats(configFile, outputFormats); 253 | 254 | // parse command line arguments 255 | const args = parseArgs(argv, configFile, documentFormats, outputFormats); 256 | 257 | return { 258 | config: mergeConfigs(args, configFile), 259 | allLoadedPlugins, 260 | loadedCorePlugins, 261 | loadedUserPlugins, 262 | }; 263 | } 264 | 265 | export { bootstrap, getDocumentFormats, getOutputFormats, parseArgs }; 266 | -------------------------------------------------------------------------------- /src/bootstrap.spec.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import { 3 | bootstrap, 4 | getDocumentFormats, 5 | getOutputFormats, 6 | parseArgs, 7 | } from "./bootstrap.js"; 8 | import { loadAllPlugins } from "./plugins.js"; 9 | import { setUp, tearDown, logContainsInfo } from "./test-helpers.js"; 10 | 11 | const { allLoadedPlugins } = await loadAllPlugins([]); 12 | const documentFormats = getDocumentFormats(allLoadedPlugins); 13 | const outputFormats = getOutputFormats(allLoadedPlugins); 14 | 15 | describe("parseArgs", function () { 16 | it("should populate default values when no args and no base config", function () { 17 | const args = parseArgs( 18 | ["node", "index.js", "infile.json"], 19 | { config: {} }, 20 | documentFormats, 21 | outputFormats, 22 | ); 23 | assert.equal(args.ignoreErrors, false); 24 | assert.equal(args.cacheTtl, 600); 25 | assert.equal(args.outputFormat, "text"); 26 | assert.equal(args.verbose, 0); 27 | assert.equal(args.catalogs, undefined); 28 | assert.equal(args.schema, undefined); 29 | assert.deepStrictEqual(args.ignorePatternFiles, [ 30 | ".v8rignore", 31 | ".gitignore", 32 | ]); 33 | assert.equal(args.ignore, true); 34 | }); 35 | 36 | it("should populate default values from base config when no args", function () { 37 | const args = parseArgs( 38 | ["node", "index.js"], 39 | { 40 | config: { 41 | patterns: ["file1.json", "file2.json"], 42 | ignoreErrors: true, 43 | cacheTtl: 300, 44 | verbose: 1, 45 | ignorePatternFiles: [".v8rignore"], 46 | }, 47 | filepath: "/foo/bar.yml", 48 | }, 49 | documentFormats, 50 | outputFormats, 51 | ); 52 | assert.deepStrictEqual(args.patterns, ["file1.json", "file2.json"]); 53 | assert.equal(args.ignoreErrors, true); 54 | assert.equal(args.cacheTtl, 300); 55 | assert.equal(args.verbose, 1); 56 | assert.equal(args.catalogs, undefined); 57 | assert.equal(args.schema, undefined); 58 | assert.deepStrictEqual(args.ignorePatternFiles, [".v8rignore"]); 59 | assert.equal(args.ignore, true); 60 | }); 61 | 62 | it("should override default values when args specified and no base config", function () { 63 | const args = parseArgs( 64 | [ 65 | "node", 66 | "index.js", 67 | "infile.json", 68 | "--ignore-errors", 69 | "--cache-ttl", 70 | "86400", 71 | "-vv", 72 | "--ignore-pattern-files", 73 | ".gitignore", 74 | ], 75 | { config: {} }, 76 | documentFormats, 77 | outputFormats, 78 | ); 79 | assert.deepStrictEqual(args.patterns, ["infile.json"]); 80 | assert.equal(args.ignoreErrors, true); 81 | assert.equal(args.cacheTtl, 86400); 82 | assert.equal(args.verbose, 2); 83 | assert.equal(args.catalogs, undefined); 84 | assert.equal(args.schema, undefined); 85 | assert.deepStrictEqual(args.ignorePatternFiles, [".gitignore"]); 86 | }); 87 | 88 | it("should override default values from base config when args specified", function () { 89 | const args = parseArgs( 90 | [ 91 | "node", 92 | "index.js", 93 | "infile.json", 94 | "--ignore-errors", 95 | "--cache-ttl", 96 | "86400", 97 | "-vv", 98 | "--ignore-pattern-files", 99 | ".gitignore", 100 | ], 101 | { 102 | config: { 103 | patterns: ["file1.json", "file2.json"], 104 | ignoreErrors: false, 105 | cacheTtl: 300, 106 | verbose: 1, 107 | ignorePatternFiles: [".v8rignore"], 108 | }, 109 | filepath: "/foo/bar.yml", 110 | }, 111 | documentFormats, 112 | outputFormats, 113 | ); 114 | assert.deepStrictEqual(args.patterns, ["infile.json"]); 115 | assert.equal(args.ignoreErrors, true); 116 | assert.equal(args.cacheTtl, 86400); 117 | assert.equal(args.verbose, 2); 118 | assert.equal(args.catalogs, undefined); 119 | assert.equal(args.schema, undefined); 120 | assert.deepStrictEqual(args.ignorePatternFiles, [".gitignore"]); 121 | }); 122 | 123 | it("should accept schema param", function () { 124 | const args = parseArgs( 125 | ["node", "index.js", "infile.json", "--schema", "http://foo.bar/baz"], 126 | { config: {} }, 127 | documentFormats, 128 | outputFormats, 129 | ); 130 | assert.equal(args.schema, "http://foo.bar/baz"); 131 | }); 132 | 133 | it("should accept catalogs param", function () { 134 | const args = parseArgs( 135 | [ 136 | "node", 137 | "index.js", 138 | "infile.json", 139 | "--catalogs", 140 | "catalog1.json", 141 | "catalog2.json", 142 | ], 143 | { config: {} }, 144 | documentFormats, 145 | outputFormats, 146 | ); 147 | assert.deepStrictEqual(args.catalogs, ["catalog1.json", "catalog2.json"]); 148 | }); 149 | 150 | it("should accept multiple patterns", function () { 151 | const args = parseArgs( 152 | ["node", "index.js", "file1.json", "dir/*", "file2.json", "*.yaml"], 153 | { config: {} }, 154 | documentFormats, 155 | outputFormats, 156 | ); 157 | assert.deepStrictEqual(args.patterns, [ 158 | "file1.json", 159 | "dir/*", 160 | "file2.json", 161 | "*.yaml", 162 | ]); 163 | }); 164 | 165 | it("should accept multiple ignore files", function () { 166 | const args = parseArgs( 167 | [ 168 | "node", 169 | "index.js", 170 | "infile.json", 171 | "--ignore-pattern-files", 172 | "ignore1", 173 | "ignore2", 174 | ], 175 | { config: {} }, 176 | documentFormats, 177 | outputFormats, 178 | ); 179 | assert.deepStrictEqual(args.ignorePatternFiles, ["ignore1", "ignore2"]); 180 | }); 181 | 182 | it("should accept no-ignore param", function () { 183 | const args = parseArgs( 184 | ["node", "index.js", "infile.json", "--no-ignore"], 185 | { config: {} }, 186 | documentFormats, 187 | outputFormats, 188 | ); 189 | assert.deepStrictEqual(args.ignore, false); 190 | }); 191 | }); 192 | 193 | describe("getConfig", function () { 194 | beforeEach(function () { 195 | setUp(); 196 | }); 197 | 198 | afterEach(function () { 199 | tearDown(); 200 | }); 201 | 202 | it("should use defaults if no config file found", async function () { 203 | const { config } = await bootstrap( 204 | ["node", "index.js", "infile.json"], 205 | undefined, 206 | { cache: false }, 207 | ); 208 | assert.equal(config.ignoreErrors, false); 209 | assert.deepStrictEqual(config.ignorePatternFiles, [ 210 | ".v8rignore", 211 | ".gitignore", 212 | ]); 213 | assert.equal(config.cacheTtl, 600); 214 | assert.equal(config.verbose, 0); 215 | assert.equal(config.catalogs, undefined); 216 | assert.equal(config.schema, undefined); 217 | assert.equal(config.cacheName, undefined); 218 | assert.equal(config.customCatalog, undefined); 219 | assert.equal(config.configFileRelativePath, undefined); 220 | assert(logContainsInfo("No config file found")); 221 | }); 222 | 223 | it("should throw if V8R_CONFIG_FILE does not exist", async function () { 224 | process.env.V8R_CONFIG_FILE = "./testfiles/does-not-exist.json"; 225 | await assert.rejects( 226 | bootstrap(["node", "index.js", "infile.json"], undefined, { 227 | cache: false, 228 | }), 229 | { 230 | name: "Error", 231 | message: "File ./testfiles/does-not-exist.json does not exist.", 232 | }, 233 | ); 234 | }); 235 | 236 | it("should read options from config file if available", async function () { 237 | process.env.V8R_CONFIG_FILE = "./testfiles/configs/config.json"; 238 | const { config } = await bootstrap(["node", "index.js"], undefined, { 239 | cache: false, 240 | }); 241 | assert.equal(config.ignoreErrors, true); 242 | assert.deepStrictEqual(config.ignorePatternFiles, [".v8rignore"]); 243 | assert.equal(config.cacheTtl, 300); 244 | assert.equal(config.verbose, 1); 245 | assert.deepStrictEqual(config.patterns, ["./foobar/*.json"]); 246 | assert.equal(config.catalogs, undefined); 247 | assert.equal(config.schema, undefined); 248 | assert.equal(config.cacheName, undefined); 249 | assert.deepStrictEqual(config.customCatalog, { 250 | schemas: [ 251 | { 252 | name: "custom schema", 253 | fileMatch: ["valid.json", "invalid.json"], 254 | location: "./testfiles/schemas/schema.json", 255 | parser: "json5", 256 | }, 257 | ], 258 | }); 259 | assert.equal( 260 | config.configFileRelativePath, 261 | "testfiles/configs/config.json", 262 | ); 263 | assert( 264 | logContainsInfo("Loaded config file from testfiles/configs/config.json"), 265 | ); 266 | }); 267 | 268 | it("should override options from config file with args if specified", async function () { 269 | process.env.V8R_CONFIG_FILE = "./testfiles/configs/config.json"; 270 | const { config } = await bootstrap( 271 | [ 272 | "node", 273 | "index.js", 274 | "infile.json", 275 | "--ignore-errors", 276 | "--cache-ttl", 277 | "86400", 278 | "-vv", 279 | "--ignore-pattern-files", 280 | ".gitignore", 281 | ], 282 | undefined, 283 | { cache: false }, 284 | ); 285 | assert.deepStrictEqual(config.patterns, ["infile.json"]); 286 | assert.equal(config.ignoreErrors, true); 287 | assert.deepStrictEqual(config.ignorePatternFiles, [".gitignore"]); 288 | assert.equal(config.cacheTtl, 86400); 289 | assert.equal(config.verbose, 2); 290 | assert.equal(config.catalogs, undefined); 291 | assert.equal(config.schema, undefined); 292 | assert.equal(config.cacheName, undefined); 293 | assert.deepStrictEqual(config.customCatalog, { 294 | schemas: [ 295 | { 296 | name: "custom schema", 297 | fileMatch: ["valid.json", "invalid.json"], 298 | location: "./testfiles/schemas/schema.json", 299 | parser: "json5", 300 | }, 301 | ], 302 | }); 303 | assert.equal( 304 | config.configFileRelativePath, 305 | "testfiles/configs/config.json", 306 | ); 307 | assert( 308 | logContainsInfo("Loaded config file from testfiles/configs/config.json"), 309 | ); 310 | }); 311 | }); 312 | -------------------------------------------------------------------------------- /src/cache.js: -------------------------------------------------------------------------------- 1 | import got from "got"; 2 | import logger from "./logger.js"; 3 | import { parseSchema } from "./parser.js"; 4 | 5 | class Cache { 6 | constructor(flatCache) { 7 | this.cache = flatCache; 8 | this.ttl = this.cache._cache.ttl || 0; 9 | this.callCounter = {}; 10 | this.callLimit = 10; 11 | if (this.ttl === 0) { 12 | this.cache.clear(); 13 | } 14 | } 15 | 16 | limitDepth(url) { 17 | /* 18 | It is possible to create cyclic dependencies with external references 19 | in JSON schema. Ajv doesn't detect this when resolving external references, 20 | so we keep a count of how many times we've called the same URL. 21 | If we are calling the same URL over and over we've probably hit a circular 22 | external reference and we need to break the loop. 23 | */ 24 | if (url in this.callCounter) { 25 | this.callCounter[url]++; 26 | } else { 27 | this.callCounter[url] = 1; 28 | } 29 | if (this.callCounter[url] > this.callLimit) { 30 | throw new Error( 31 | `Called ${url} >${this.callLimit} times. Possible circular reference.`, 32 | ); 33 | } 34 | } 35 | 36 | resetCounters() { 37 | this.callCounter = {}; 38 | } 39 | 40 | async fetch(url) { 41 | this.limitDepth(url); 42 | const cachedResponse = this.cache.getKey(url); 43 | if (cachedResponse !== undefined) { 44 | logger.debug(`Cache hit: using cached response from ${url}`); 45 | return cachedResponse.body; 46 | } 47 | 48 | try { 49 | logger.debug(`Cache miss: calling ${url}`); 50 | const resp = await got(url); 51 | const parsedBody = parseSchema(resp.body, url); 52 | if (this.ttl > 0) { 53 | this.cache.setKey(url, { body: parsedBody }); 54 | this.cache.save(true); 55 | } 56 | return parsedBody; 57 | } catch (error) { 58 | if (error.response) { 59 | throw new Error(`Failed fetching ${url}\n${error.response.body}`); 60 | } 61 | throw new Error(`Failed fetching ${url}`); 62 | } 63 | } 64 | } 65 | 66 | export { Cache }; 67 | -------------------------------------------------------------------------------- /src/cache.spec.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import { FlatCache } from "flat-cache"; 3 | import nock from "nock"; 4 | import { Cache } from "./cache.js"; 5 | import { testCacheName, setUp, tearDown } from "./test-helpers.js"; 6 | 7 | const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 8 | 9 | describe("Cache", function () { 10 | describe("fetch function", function () { 11 | let testCache; 12 | 13 | before(function () { 14 | const ttl = 500; 15 | const cache = new FlatCache({ cacheId: testCacheName, ttl: ttl }); 16 | cache.load(); 17 | testCache = new Cache(cache); 18 | }); 19 | 20 | beforeEach(function () { 21 | setUp(); 22 | }); 23 | 24 | afterEach(function () { 25 | tearDown(); 26 | }); 27 | 28 | it("should use cached response if valid", async function () { 29 | nock("https://www.foobar.com").get("/baz").reply(200, { cached: false }); 30 | 31 | testCache.cache.setKey("https://www.foobar.com/baz", { 32 | body: { cached: true }, 33 | }); 34 | const resp = await testCache.fetch("https://www.foobar.com/baz"); 35 | assert.deepEqual(resp, { cached: true }); 36 | nock.cleanAll(); 37 | }); 38 | 39 | it("should not use cached response if expired", async function () { 40 | const mock = nock("https://www.foobar.com") 41 | .get("/baz") 42 | .reply(200, { cached: false }); 43 | 44 | testCache.cache.setKey("https://www.foobar.com/baz", { 45 | body: { cached: true }, 46 | }); 47 | 48 | await sleep(600); 49 | 50 | const resp = await testCache.fetch("https://www.foobar.com/baz"); 51 | assert.deepEqual(resp, { cached: false }); 52 | mock.done(); 53 | }); 54 | }); 55 | 56 | describe("cyclic detection", function () { 57 | let testCache; 58 | 59 | before(function () { 60 | const ttl = 3000; 61 | const cache = new FlatCache({ cacheId: testCacheName, ttl: ttl }); 62 | cache.load(); 63 | testCache = new Cache(cache); 64 | }); 65 | 66 | beforeEach(function () { 67 | setUp(); 68 | }); 69 | 70 | afterEach(function () { 71 | tearDown(); 72 | }); 73 | 74 | it("throws if callLimit is exceeded", async function () { 75 | const mock = nock("https://www.foobar.com") 76 | .get("/baz") 77 | .reply(200, { cached: false }); 78 | 79 | testCache.callLimit = 2; 80 | // the first two calls should work 81 | await testCache.fetch("https://www.foobar.com/baz"); 82 | await testCache.fetch("https://www.foobar.com/baz"); 83 | 84 | //..and then the third one should fail 85 | await assert.rejects(testCache.fetch("https://www.foobar.com/baz"), { 86 | name: "Error", 87 | message: 88 | "Called https://www.foobar.com/baz >2 times. Possible circular reference.", 89 | }); 90 | mock.done(); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/catalogs.js: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { minimatch } from "minimatch"; 3 | import { validate } from "./ajv.js"; 4 | import { getFromUrlOrFile } from "./io.js"; 5 | import logger from "./logger.js"; 6 | 7 | const SCHEMASTORE_CATALOG_URL = 8 | "https://www.schemastore.org/api/json/catalog.json"; 9 | const SCHEMASTORE_CATALOG_SCHEMA_URL = 10 | "https://json.schemastore.org/schema-catalog.json"; 11 | 12 | function coerceMatch(inMatch) { 13 | const outMatch = {}; 14 | outMatch.location = inMatch.url || inMatch.location; 15 | for (const [key, value] of Object.entries(inMatch)) { 16 | if (!["location", "url"].includes(key)) { 17 | outMatch[key] = value; 18 | } 19 | } 20 | return outMatch; 21 | } 22 | 23 | function getCatalogs(config) { 24 | let catalogs = []; 25 | if (config.customCatalog) { 26 | catalogs.push({ 27 | location: config.configFileRelativePath, 28 | catalog: config.customCatalog, 29 | }); 30 | } 31 | if (config.catalogs) { 32 | catalogs = catalogs.concat( 33 | config.catalogs.map(function (loc) { 34 | return { location: loc }; 35 | }), 36 | ); 37 | } 38 | catalogs.push({ location: SCHEMASTORE_CATALOG_URL }); 39 | return catalogs; 40 | } 41 | 42 | function getMatchLogMessage(match) { 43 | let outStr = ""; 44 | outStr += ` ${match.name}\n`; 45 | if (match.description) { 46 | outStr += ` ${match.description}\n`; 47 | } 48 | outStr += ` ${match.url || match.location}\n`; 49 | return outStr; 50 | } 51 | 52 | function getVersionLogMessage(match, versionId, versionSchemaUrl) { 53 | let outStr = ""; 54 | outStr += ` ${match.name} (${versionId})\n`; 55 | if (match.description) { 56 | outStr += ` ${match.description}\n`; 57 | } 58 | outStr += ` ${versionSchemaUrl}\n`; 59 | return outStr; 60 | } 61 | 62 | function getMultipleMatchesLogMessage(matches) { 63 | return matches 64 | .map(function (match) { 65 | if (Object.keys(match.versions || {}).length > 1) { 66 | return Object.entries(match.versions) 67 | .map(function ([versionId, versionSchemaUrl]) { 68 | return getVersionLogMessage(match, versionId, versionSchemaUrl); 69 | }) 70 | .join("\n"); 71 | } 72 | return getMatchLogMessage(match); 73 | }) 74 | .join("\n"); 75 | } 76 | 77 | async function getMatchForFilename(catalogs, filename, cache) { 78 | for (const [i, rec] of catalogs.entries()) { 79 | const catalogLocation = rec.location; 80 | const catalog = 81 | rec.catalog || (await getFromUrlOrFile(catalogLocation, cache)); 82 | 83 | if (!rec.catalog) { 84 | const catalogSchema = await getFromUrlOrFile( 85 | SCHEMASTORE_CATALOG_SCHEMA_URL, 86 | cache, 87 | ); 88 | 89 | // Validate the catalog 90 | const strictMode = false; 91 | const { valid } = await validate( 92 | catalog, 93 | catalogSchema, 94 | strictMode, 95 | cache, 96 | ); 97 | if (!valid || catalog.schemas === undefined) { 98 | throw new Error(`Malformed catalog at ${catalogLocation}`); 99 | } 100 | } 101 | 102 | const { schemas } = catalog; 103 | const matches = getSchemaMatchesForFilename(schemas, filename); 104 | logger.debug(`Searching for schema in ${catalogLocation} ...`); 105 | 106 | if ( 107 | (matches.length === 1 && matches[0].versions == null) || 108 | (matches.length === 1 && Object.keys(matches[0].versions).length === 1) 109 | ) { 110 | logger.info(`Found schema in ${catalogLocation} ...`); 111 | return coerceMatch(matches[0]); // Exactly one match found. We're done. 112 | } 113 | 114 | if (matches.length === 0 && i < catalogs.length - 1) { 115 | continue; // No matches found. Try the next catalog in the array. 116 | } 117 | 118 | if ( 119 | matches.length > 1 || 120 | (matches.length === 1 && 121 | Object.keys(matches[0].versions || {}).length > 1) 122 | ) { 123 | // We found >1 matches in the same catalog. This is always a hard error. 124 | const matchesLog = getMultipleMatchesLogMessage(matches); 125 | logger.info( 126 | `Found multiple possible matches for ${filename}. Possible matches:\n\n${matchesLog}`, 127 | ); 128 | throw new Error( 129 | `Found multiple possible schemas to validate ${filename}`, 130 | ); 131 | } 132 | 133 | // We found 0 matches in the last catalog 134 | // and there are no more catalogs left to try 135 | throw new Error(`Could not find a schema to validate ${filename}`); 136 | } 137 | } 138 | 139 | function getSchemaMatchesForFilename(schemas, filename) { 140 | const matches = []; 141 | schemas.forEach(function (schema) { 142 | if ("fileMatch" in schema) { 143 | if (schema.fileMatch.includes(path.basename(filename))) { 144 | matches.push(schema); 145 | return; 146 | } 147 | for (const glob of schema.fileMatch) { 148 | if (minimatch(path.normalize(filename), glob, { dot: true })) { 149 | matches.push(schema); 150 | break; 151 | } 152 | } 153 | } 154 | }); 155 | return matches; 156 | } 157 | 158 | export { getCatalogs, getMatchForFilename, getSchemaMatchesForFilename }; 159 | -------------------------------------------------------------------------------- /src/catalogs.spec.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import { FlatCache } from "flat-cache"; 3 | import { Cache } from "./cache.js"; 4 | import { 5 | getMatchForFilename, 6 | getSchemaMatchesForFilename, 7 | } from "./catalogs.js"; 8 | import { 9 | testCacheName, 10 | setUp, 11 | tearDown, 12 | logContainsInfo, 13 | } from "./test-helpers.js"; 14 | 15 | describe("getSchemaMatchesForFilename", function () { 16 | const schemas = [ 17 | { 18 | url: "https://example.com/subdir-schema.json", 19 | fileMatch: ["subdir/**/*.json"], 20 | }, 21 | { 22 | url: "https://example.com/files-schema-schema.json", 23 | fileMatch: ["file1.json", "file2.json"], 24 | }, 25 | { 26 | url: "https://example.com/duplicate.json", 27 | fileMatch: ["file2.json"], 28 | }, 29 | { 30 | url: "https://example.com/starts-with-a-dot-schema.json", 31 | fileMatch: ["*.starts-with-a-dot.json"], 32 | }, 33 | ]; 34 | 35 | it("returns [] when no matches found", function () { 36 | assert.deepStrictEqual( 37 | getSchemaMatchesForFilename(schemas, "doesnt-match-anything.json"), 38 | [], 39 | ); 40 | }); 41 | 42 | it("returns a match using globstar pattern", function () { 43 | assert.deepStrictEqual( 44 | getSchemaMatchesForFilename(schemas, "subdir/one/two/three/file.json"), 45 | [ 46 | { 47 | url: "https://example.com/subdir-schema.json", 48 | fileMatch: ["subdir/**/*.json"], 49 | }, 50 | ], 51 | ); 52 | }); 53 | 54 | it("returns a match when fileMatch contains multiple patterns", function () { 55 | assert.deepStrictEqual(getSchemaMatchesForFilename(schemas, "file1.json"), [ 56 | { 57 | url: "https://example.com/files-schema-schema.json", 58 | fileMatch: ["file1.json", "file2.json"], 59 | }, 60 | ]); 61 | }); 62 | 63 | it("returns multiple matches if input matches >1 globs", function () { 64 | assert.deepStrictEqual(getSchemaMatchesForFilename(schemas, "file2.json"), [ 65 | { 66 | url: "https://example.com/files-schema-schema.json", 67 | fileMatch: ["file1.json", "file2.json"], 68 | }, 69 | { 70 | url: "https://example.com/duplicate.json", 71 | fileMatch: ["file2.json"], 72 | }, 73 | ]); 74 | }); 75 | 76 | it("returns a match if filename starts with a dot", function () { 77 | assert.deepStrictEqual( 78 | getSchemaMatchesForFilename(schemas, ".starts-with-a-dot.json"), 79 | [ 80 | { 81 | url: "https://example.com/starts-with-a-dot-schema.json", 82 | fileMatch: ["*.starts-with-a-dot.json"], 83 | }, 84 | ], 85 | ); 86 | }); 87 | }); 88 | 89 | describe("getMatchForFilename", function () { 90 | const catalogs = [ 91 | { 92 | catalog: { 93 | schemas: [ 94 | { 95 | url: "https://example.com/files-schema-schema.json", 96 | fileMatch: ["file1.json", "file2.json"], 97 | }, 98 | { 99 | url: "https://example.com/duplicate.json", 100 | fileMatch: ["file2.json"], 101 | }, 102 | { 103 | url: "https://example.com/v1.json", 104 | fileMatch: ["one-version.json"], 105 | versions: { 106 | "v1.0": "https://example.com/v1.json", 107 | }, 108 | }, 109 | { 110 | url: "https://example.com/versions-v2.json", 111 | fileMatch: ["versions.json"], 112 | versions: { 113 | "v1.0": "https://example.com/versions-v1.json", 114 | "v2.0": "https://example.com/versions-v2.json", 115 | }, 116 | }, 117 | ], 118 | }, 119 | }, 120 | ]; 121 | 122 | let testCache; 123 | 124 | before(function () { 125 | const ttl = 3000; 126 | const cache = new FlatCache({ cacheId: testCacheName, ttl: ttl }); 127 | cache.load(); 128 | testCache = new Cache(cache); 129 | }); 130 | 131 | beforeEach(function () { 132 | setUp(); 133 | }); 134 | 135 | afterEach(function () { 136 | tearDown(); 137 | }); 138 | 139 | it("returns a schema when one match found", async function () { 140 | assert.deepStrictEqual( 141 | await getMatchForFilename(catalogs, "file1.json", testCache), 142 | { 143 | location: "https://example.com/files-schema-schema.json", 144 | fileMatch: ["file1.json", "file2.json"], 145 | }, 146 | ); 147 | }); 148 | 149 | it("returns a schema when match has only one version", async function () { 150 | assert.deepStrictEqual( 151 | await getMatchForFilename(catalogs, "one-version.json", testCache), 152 | { 153 | location: "https://example.com/v1.json", 154 | fileMatch: ["one-version.json"], 155 | versions: { 156 | "v1.0": "https://example.com/v1.json", 157 | }, 158 | }, 159 | ); 160 | }); 161 | 162 | it("throws an exception when no matches found", async function () { 163 | await assert.rejects( 164 | getMatchForFilename(catalogs, "doesnt-match-anything.json", testCache), 165 | { 166 | name: "Error", 167 | message: 168 | "Could not find a schema to validate doesnt-match-anything.json", 169 | }, 170 | ); 171 | }); 172 | 173 | it("throws an exception when multiple matches found", async function () { 174 | await assert.rejects( 175 | getMatchForFilename(catalogs, "file2.json", testCache), 176 | { 177 | name: "Error", 178 | message: "Found multiple possible schemas to validate file2.json", 179 | }, 180 | ); 181 | 182 | assert(logContainsInfo("Found multiple possible matches for file2.json")); 183 | assert(logContainsInfo("https://example.com/files-schema-schema.json")); 184 | assert(logContainsInfo("https://example.com/duplicate.json")); 185 | }); 186 | 187 | it("throws an exception when match has multiple versions", async function () { 188 | await assert.rejects( 189 | getMatchForFilename(catalogs, "versions.json", testCache), 190 | { 191 | name: "Error", 192 | message: "Found multiple possible schemas to validate versions.json", 193 | }, 194 | ); 195 | 196 | assert( 197 | logContainsInfo("Found multiple possible matches for versions.json"), 198 | ); 199 | assert(logContainsInfo("https://example.com/versions-v1.json")); 200 | assert(logContainsInfo("https://example.com/versions-v2.json")); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import os from "node:os"; 3 | import path from "node:path"; 4 | import { FlatCache } from "flat-cache"; 5 | import isUrl from "is-url"; 6 | import { validate } from "./ajv.js"; 7 | import { bootstrap } from "./bootstrap.js"; 8 | import { Cache } from "./cache.js"; 9 | import { getCatalogs, getMatchForFilename } from "./catalogs.js"; 10 | import { getFiles, NotFound } from "./glob.js"; 11 | import { getFromUrlOrFile } from "./io.js"; 12 | import logger from "./logger.js"; 13 | import { getDocumentLocation } from "./output-formatters.js"; 14 | import { parseFile } from "./parser.js"; 15 | 16 | const EXIT = { 17 | VALID: 0, 18 | ERROR: 1, 19 | INVALID_CONFIG_OR_PLUGIN: 97, 20 | NOT_FOUND: 98, 21 | INVALID: 99, 22 | }; 23 | 24 | const CACHE_DIR = path.join(os.tmpdir(), "flat-cache"); 25 | 26 | function secondsToMilliseconds(seconds) { 27 | return seconds * 1000; 28 | } 29 | 30 | function getFlatCache(ttl) { 31 | let cache; 32 | if (process.env.V8R_CACHE_NAME) { 33 | cache = new FlatCache({ cacheId: process.env.V8R_CACHE_NAME, ttl: ttl }); 34 | } else { 35 | cache = new FlatCache({ cacheId: "v8rv2", cacheDir: CACHE_DIR, ttl: ttl }); 36 | } 37 | cache.load(); 38 | return cache; 39 | } 40 | 41 | async function validateDocument( 42 | fileLocation, 43 | documentIndex, 44 | document, 45 | schemaLocation, 46 | schema, 47 | strictMode, 48 | cache, 49 | resolver, 50 | ) { 51 | let result = { 52 | fileLocation, 53 | documentIndex, 54 | schemaLocation, 55 | valid: null, 56 | errors: [], 57 | code: null, 58 | }; 59 | try { 60 | const { valid, errors } = await validate( 61 | document, 62 | schema, 63 | strictMode, 64 | cache, 65 | resolver, 66 | ); 67 | result.valid = valid; 68 | result.errors = errors; 69 | 70 | const documentLocation = getDocumentLocation(result); 71 | if (valid) { 72 | logger.success(`${documentLocation} is valid\n`); 73 | } else { 74 | logger.error(`${documentLocation} is invalid\n`); 75 | } 76 | 77 | result.code = valid ? EXIT.VALID : EXIT.INVALID; 78 | return result; 79 | } catch (e) { 80 | logger.error(`${e.message}\n`); 81 | result.code = EXIT.ERROR; 82 | return result; 83 | } 84 | } 85 | 86 | async function validateFile(filename, config, plugins, cache) { 87 | logger.info(`Processing ${filename}`); 88 | 89 | let schema, schemaLocation, documents, strictMode, resolver; 90 | 91 | try { 92 | const catalogs = getCatalogs(config); 93 | const catalogMatch = config.schema 94 | ? {} 95 | : await getMatchForFilename(catalogs, filename, cache); 96 | schemaLocation = config.schema || catalogMatch.location; 97 | schema = await getFromUrlOrFile(schemaLocation, cache); 98 | logger.info( 99 | `Validating ${filename} against schema from ${schemaLocation} ...`, 100 | ); 101 | 102 | documents = parseFile( 103 | plugins, 104 | await fs.promises.readFile(filename, "utf8"), 105 | filename, 106 | catalogMatch.parser, 107 | ); 108 | 109 | strictMode = config.verbose >= 2 ? "log" : false; 110 | resolver = isUrl(schemaLocation) 111 | ? (location) => getFromUrlOrFile(location, cache) 112 | : (location) => 113 | getFromUrlOrFile(location, cache, path.dirname(schemaLocation)); 114 | } catch (e) { 115 | logger.error(`${e.message}\n`); 116 | return [ 117 | { 118 | fileLocation: filename, 119 | documentIndex: null, 120 | schemaLocation: schemaLocation || null, 121 | valid: null, 122 | errors: [], 123 | code: EXIT.ERROR, 124 | }, 125 | ]; 126 | } 127 | 128 | let results = []; 129 | for (let i = 0; i < documents.length; i++) { 130 | const documentIndex = documents.length === 1 ? null : i; 131 | const result = await validateDocument( 132 | filename, 133 | documentIndex, 134 | documents[i], 135 | schemaLocation, 136 | schema, 137 | strictMode, 138 | cache, 139 | resolver, 140 | ); 141 | 142 | results.push(result); 143 | 144 | for (const plugin of plugins) { 145 | const message = plugin.getSingleResultLogMessage( 146 | result, 147 | config.outputFormat, 148 | ); 149 | if (message != null) { 150 | logger.log(message); 151 | break; 152 | } 153 | } 154 | } 155 | return results; 156 | } 157 | 158 | function resultsToStatusCode(results, ignoreErrors) { 159 | const codes = Object.values(results).map((result) => result.code); 160 | if (codes.includes(EXIT.INVALID)) { 161 | return EXIT.INVALID; 162 | } 163 | if (codes.includes(EXIT.ERROR) && !ignoreErrors) { 164 | return EXIT.ERROR; 165 | } 166 | return EXIT.VALID; 167 | } 168 | 169 | function Validator() { 170 | return async function (config, plugins) { 171 | let filenames = []; 172 | try { 173 | filenames = await getFiles(config.patterns, config.ignorePatternFiles); 174 | } catch (e) { 175 | if (e instanceof NotFound) { 176 | logger.error(e.message); 177 | return EXIT.NOT_FOUND; 178 | } 179 | throw e; 180 | } 181 | 182 | const ttl = secondsToMilliseconds(config.cacheTtl || 0); 183 | const cache = new Cache(getFlatCache(ttl)); 184 | 185 | let results = []; 186 | for (const filename of filenames) { 187 | const fileResults = await validateFile(filename, config, plugins, cache); 188 | results = results.concat(fileResults); 189 | cache.resetCounters(); 190 | } 191 | 192 | for (const plugin of plugins) { 193 | const message = plugin.getAllResultsLogMessage( 194 | results, 195 | config.outputFormat, 196 | ); 197 | if (message != null) { 198 | logger.log(message); 199 | break; 200 | } 201 | } 202 | 203 | return resultsToStatusCode(results, config.ignoreErrors); 204 | }; 205 | } 206 | 207 | async function cli(config) { 208 | let allLoadedPlugins, loadedCorePlugins, loadedUserPlugins; 209 | try { 210 | ({ config, allLoadedPlugins, loadedCorePlugins, loadedUserPlugins } = 211 | await bootstrap(process.argv, config)); 212 | } catch (e) { 213 | logger.error(e.message); 214 | return EXIT.INVALID_CONFIG_OR_PLUGIN; 215 | } 216 | 217 | logger.setVerbosity(config.verbose); 218 | logger.debug(`Merged args/config: ${JSON.stringify(config, null, 2)}`); 219 | 220 | /* 221 | Note there is a bit of a chicken and egg problem here. 222 | We have to load the plugins before we can load the config 223 | but this logger.debug() needs to happen AFTER we call logger.setVerbosity(). 224 | */ 225 | logger.debug( 226 | `Loaded user plugins: ${JSON.stringify( 227 | loadedUserPlugins.map((plugin) => plugin.constructor.name), 228 | null, 229 | 2, 230 | )}`, 231 | ); 232 | logger.debug( 233 | `Loaded core plugins: ${JSON.stringify( 234 | loadedCorePlugins.map((plugin) => plugin.constructor.name), 235 | null, 236 | 2, 237 | )}`, 238 | ); 239 | 240 | try { 241 | const validate = new Validator(); 242 | return await validate(config, allLoadedPlugins); 243 | } catch (e) { 244 | logger.error(e.message); 245 | return EXIT.ERROR; 246 | } 247 | } 248 | 249 | export { cli }; 250 | -------------------------------------------------------------------------------- /src/cli.spec.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import { randomUUID } from "node:crypto"; 3 | import fs from "node:fs"; 4 | import os from "node:os"; 5 | import path from "node:path"; 6 | import { dump as dumpToYaml } from "js-yaml"; 7 | import { mockCwd } from "mock-cwd"; 8 | import nock from "nock"; 9 | import { cli } from "./cli.js"; 10 | import logger from "./logger.js"; 11 | import { 12 | setUp, 13 | tearDown, 14 | logContainsSuccess, 15 | logContainsInfo, 16 | logContainsError, 17 | } from "./test-helpers.js"; 18 | 19 | describe("CLI", function () { 20 | // Mock the catalog validation schema 21 | beforeEach(function () { 22 | nock.disableNetConnect(); 23 | nock("https://json.schemastore.org") 24 | .persist() 25 | .get("/schema-catalog.json") 26 | .reply(200, { 27 | $schema: "http://json-schema.org/draft-07/schema#", 28 | type: "object", 29 | properties: { 30 | schemas: { 31 | type: "array", 32 | items: { 33 | type: "object", 34 | required: ["url"], 35 | properties: { 36 | url: { 37 | type: "string", 38 | format: "uri", 39 | pattern: "^https://", 40 | }, 41 | }, 42 | }, 43 | }, 44 | }, 45 | }); 46 | }); 47 | 48 | describe("success behaviour, single file with JSON schema", function () { 49 | const schema = { 50 | $schema: "http://json-schema.org/draft-07/schema#", 51 | type: "object", 52 | properties: { num: { type: "number" } }, 53 | }; 54 | 55 | beforeEach(function () { 56 | setUp(); 57 | }); 58 | 59 | afterEach(function () { 60 | tearDown(); 61 | nock.cleanAll(); 62 | }); 63 | 64 | it("should return 0 when file is valid (with user-supplied local schema)", function () { 65 | return cli({ 66 | patterns: ["testfiles/files/valid.json"], 67 | schema: "testfiles/schemas/schema.json", 68 | ignorePatternFiles: [], 69 | }).then((result) => { 70 | assert.equal(result, 0); 71 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 72 | }); 73 | }); 74 | 75 | it("should return 99 when file is invalid (with user-supplied local schema)", function () { 76 | return cli({ 77 | patterns: ["testfiles/files/invalid.json"], 78 | schema: "testfiles/schemas/schema.json", 79 | ignorePatternFiles: [], 80 | }).then((result) => { 81 | assert.equal(result, 99); 82 | assert(logContainsError("testfiles/files/invalid.json is invalid")); 83 | }); 84 | }); 85 | 86 | it("should return 0 when file is valid (with user-supplied remote schema)", function () { 87 | const mock = nock("https://example.com") 88 | .get("/schema.json") 89 | .reply(200, schema); 90 | 91 | return cli({ 92 | patterns: ["testfiles/files/valid.json"], 93 | schema: "https://example.com/schema.json", 94 | ignorePatternFiles: [], 95 | }).then((result) => { 96 | assert.equal(result, 0); 97 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 98 | mock.done(); 99 | }); 100 | }); 101 | 102 | it("should return 99 when file is invalid (with user-supplied remote schema)", function () { 103 | const mock = nock("https://example.com") 104 | .get("/schema.json") 105 | .reply(200, schema); 106 | 107 | return cli({ 108 | patterns: ["testfiles/files/invalid.json"], 109 | schema: "https://example.com/schema.json", 110 | ignorePatternFiles: [], 111 | }).then((result) => { 112 | assert.equal(result, 99); 113 | assert(logContainsError("testfiles/files/invalid.json is invalid")); 114 | mock.done(); 115 | }); 116 | }); 117 | 118 | it("should return 0 when file is valid (with auto-detected schema)", function () { 119 | const catalogMock = nock("https://www.schemastore.org") 120 | .get("/api/json/catalog.json") 121 | .reply(200, { 122 | schemas: [ 123 | { 124 | url: "https://example.com/schema.json", 125 | fileMatch: ["valid.json", "invalid.json"], 126 | }, 127 | ], 128 | }); 129 | const schemaMock = nock("https://example.com") 130 | .get("/schema.json") 131 | .reply(200, schema); 132 | 133 | return cli({ 134 | patterns: ["testfiles/files/valid.json"], 135 | ignorePatternFiles: [], 136 | }).then((result) => { 137 | assert.equal(result, 0); 138 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 139 | catalogMock.done(); 140 | schemaMock.done(); 141 | }); 142 | }); 143 | 144 | it("should return 99 when file is invalid (with auto-detected schema)", function () { 145 | const catalogMock = nock("https://www.schemastore.org") 146 | .get("/api/json/catalog.json") 147 | .reply(200, { 148 | schemas: [ 149 | { 150 | url: "https://example.com/schema.json", 151 | fileMatch: ["valid.json", "invalid.json"], 152 | }, 153 | ], 154 | }); 155 | const schemaMock = nock("https://example.com") 156 | .get("/schema.json") 157 | .reply(200, schema); 158 | 159 | return cli({ 160 | patterns: ["testfiles/files/invalid.json"], 161 | ignorePatternFiles: [], 162 | }).then((result) => { 163 | assert.equal(result, 99); 164 | assert(logContainsError("testfiles/files/invalid.json is invalid")); 165 | catalogMock.done(); 166 | schemaMock.done(); 167 | }); 168 | }); 169 | 170 | it("should use schema from custom local catalog if match found", function () { 171 | const catalogMock = nock("https://www.schemastore.org") 172 | .get("/api/json/catalog.json") 173 | .reply(200, { 174 | schemas: [ 175 | { 176 | url: "https://example.com/not-used-schema.json", 177 | fileMatch: ["valid.json", "invalid.json"], 178 | }, 179 | ], 180 | }); 181 | const schemaMock = nock("https://example.com") 182 | .get("/schema.json") 183 | .reply(200, schema); 184 | 185 | return cli({ 186 | patterns: ["testfiles/files/valid.json"], 187 | catalogs: ["testfiles/catalogs/catalog-url.json"], 188 | ignorePatternFiles: [], 189 | }).then((result) => { 190 | assert.equal(result, 0, logger.stderr); 191 | assert( 192 | logContainsInfo( 193 | "Found schema in testfiles/catalogs/catalog-url.json ...", 194 | ), 195 | ); 196 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 197 | assert.equal(catalogMock.isDone(), false); 198 | schemaMock.done(); 199 | }); 200 | }); 201 | 202 | it("should use schema from custom remote catalog if match found", function () { 203 | const storeCatalogMock = nock("https://www.schemastore.org") 204 | .get("/api/json/catalog.json") 205 | .reply(200, { 206 | schemas: [ 207 | { 208 | url: "https://example.com/not-used-schema.json", 209 | fileMatch: ["valid.json", "invalid.json"], 210 | }, 211 | ], 212 | }); 213 | const customCatalogMock = nock("https://my-catalog.com") 214 | .get("/catalog.json") 215 | .reply(200, { 216 | schemas: [ 217 | { 218 | url: "https://example.com/schema.json", 219 | fileMatch: ["valid.json", "invalid.json"], 220 | }, 221 | ], 222 | }); 223 | const customSchemaMock = nock("https://example.com") 224 | .get("/schema.json") 225 | .reply(200, schema); 226 | 227 | return cli({ 228 | patterns: ["testfiles/files/valid.json"], 229 | catalogs: ["https://my-catalog.com/catalog.json"], 230 | ignorePatternFiles: [], 231 | }).then((result) => { 232 | assert.equal(result, 0); 233 | assert( 234 | logContainsInfo( 235 | "Found schema in https://my-catalog.com/catalog.json ...", 236 | ), 237 | ); 238 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 239 | assert.equal(storeCatalogMock.isDone(), false); 240 | customCatalogMock.done(); 241 | customSchemaMock.done(); 242 | }); 243 | }); 244 | 245 | it("should fall back to next custom catalog if match not found in first", function () { 246 | const mock = nock("https://example.com") 247 | .get("/schema.json") 248 | .reply(200, schema); 249 | 250 | return cli({ 251 | patterns: ["testfiles/files/valid.json"], 252 | catalogs: [ 253 | "testfiles/catalogs/catalog-nomatch.json", 254 | "testfiles/catalogs/catalog-url.json", 255 | ], 256 | ignorePatternFiles: [], 257 | }).then((result) => { 258 | assert.equal(result, 0, logger.stderr); 259 | assert( 260 | logContainsInfo( 261 | "Found schema in testfiles/catalogs/catalog-url.json ...", 262 | ), 263 | ); 264 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 265 | mock.done(); 266 | }); 267 | }); 268 | 269 | it("should fall back to schemastore.org if match not found in custom catalogs", function () { 270 | const storeCatalogMock = nock("https://www.schemastore.org") 271 | .get("/api/json/catalog.json") 272 | .reply(200, { 273 | schemas: [ 274 | { 275 | url: "https://example.com/schema.json", 276 | fileMatch: ["valid.json", "invalid.json"], 277 | }, 278 | ], 279 | }); 280 | const storeSchemaMock = nock("https://example.com") 281 | .get("/schema.json") 282 | .reply(200, schema); 283 | 284 | return cli({ 285 | patterns: ["testfiles/files/valid.json"], 286 | catalogs: ["testfiles/catalogs/catalog-nomatch.json"], 287 | ignorePatternFiles: [], 288 | }).then((result) => { 289 | assert.equal(result, 0, logger.stderr); 290 | assert( 291 | logContainsInfo( 292 | "Found schema in https://www.schemastore.org/api/json/catalog.json ...", 293 | ), 294 | ); 295 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 296 | storeCatalogMock.done(); 297 | storeSchemaMock.done(); 298 | }); 299 | }); 300 | 301 | it("should use schema from config file if match found", function () { 302 | return cli({ 303 | patterns: ["testfiles/files/valid.json"], 304 | catalogs: ["testfiles/catalogs/catalog-url.json"], 305 | customCatalog: { 306 | schemas: [ 307 | { 308 | name: "custom schema", 309 | fileMatch: ["valid.json", "valid.yml"], 310 | location: "testfiles/schemas/schema.json", 311 | }, 312 | ], 313 | }, 314 | configFileRelativePath: "foobar.conf", 315 | ignorePatternFiles: [], 316 | }).then((result) => { 317 | assert.equal(result, 0, logger.stderr); 318 | assert(logContainsInfo("Found schema in foobar.conf ...")); 319 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 320 | }); 321 | }); 322 | 323 | it("should fall back to custom catalog if match not found in config file", function () { 324 | const mock = nock("https://example.com") 325 | .get("/schema.json") 326 | .reply(200, schema); 327 | 328 | return cli({ 329 | patterns: ["testfiles/files/valid.json"], 330 | catalogs: ["testfiles/catalogs/catalog-url.json"], 331 | customCatalog: { 332 | schemas: [ 333 | { 334 | name: "custom schema", 335 | fileMatch: ["does-not-match.json"], 336 | location: "testfiles/schemas/schema.json", 337 | }, 338 | ], 339 | }, 340 | configFileRelativePath: "foobar.conf", 341 | ignorePatternFiles: [], 342 | }).then((result) => { 343 | assert.equal(result, 0, logger.stderr); 344 | assert( 345 | logContainsInfo( 346 | "Found schema in testfiles/catalogs/catalog-url.json ...", 347 | ), 348 | ); 349 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 350 | mock.done(); 351 | }); 352 | }); 353 | 354 | it("should fall back to schemastore.org if match not found in config file or custom catalogs", function () { 355 | const storeCatalogMock = nock("https://www.schemastore.org") 356 | .get("/api/json/catalog.json") 357 | .reply(200, { 358 | schemas: [ 359 | { 360 | url: "https://example.com/schema.json", 361 | fileMatch: ["valid.json", "invalid.json"], 362 | }, 363 | ], 364 | }); 365 | const storeSchemaMock = nock("https://example.com") 366 | .get("/schema.json") 367 | .reply(200, schema); 368 | 369 | return cli({ 370 | patterns: ["testfiles/files/valid.json"], 371 | catalogs: ["testfiles/catalogs/catalog-nomatch.json"], 372 | customCatalog: { 373 | schemas: [ 374 | { 375 | name: "custom schema", 376 | fileMatch: ["does-not-match.json"], 377 | location: "testfiles/schemas/schema.json", 378 | }, 379 | ], 380 | }, 381 | configFileRelativePath: "foobar.conf", 382 | ignorePatternFiles: [], 383 | }).then((result) => { 384 | assert.equal(result, 0, logger.stderr); 385 | assert( 386 | logContainsInfo( 387 | "Found schema in https://www.schemastore.org/api/json/catalog.json ...", 388 | ), 389 | ); 390 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 391 | storeCatalogMock.done(); 392 | storeSchemaMock.done(); 393 | }); 394 | }); 395 | 396 | it("should find a schema using glob patterns", function () { 397 | const catalogMock = nock("https://www.schemastore.org") 398 | .get("/api/json/catalog.json") 399 | .reply(200, { 400 | schemas: [ 401 | { 402 | url: "https://example.com/schema.json", 403 | fileMatch: ["testfiles/files/*.json"], 404 | }, 405 | ], 406 | }); 407 | const schemaMock = nock("https://example.com") 408 | .get("/schema.json") 409 | .reply(200, schema); 410 | 411 | return cli({ 412 | patterns: ["testfiles/files/valid.json"], 413 | ignorePatternFiles: [], 414 | }).then((result) => { 415 | assert.equal(result, 0); 416 | catalogMock.done(); 417 | schemaMock.done(); 418 | }); 419 | }); 420 | 421 | it("should validate yaml files", function () { 422 | return cli({ 423 | patterns: ["testfiles/files/valid.yaml"], 424 | schema: "testfiles/schemas/schema.json", 425 | ignorePatternFiles: [], 426 | }).then((result) => { 427 | assert.equal(result, 0); 428 | assert(logContainsSuccess("testfiles/files/valid.yaml is valid")); 429 | }); 430 | }); 431 | 432 | it("should validate yaml files containing multiple documents", function () { 433 | return cli({ 434 | patterns: ["testfiles/files/multi-doc.yaml"], 435 | schema: "testfiles/schemas/schema.json", 436 | ignorePatternFiles: [], 437 | }).then((result) => { 438 | assert.equal(result, 99); 439 | assert( 440 | logContainsSuccess("testfiles/files/multi-doc.yaml[0] is valid"), 441 | ); 442 | assert( 443 | logContainsSuccess("testfiles/files/multi-doc.yaml[1] is valid"), 444 | ); 445 | assert( 446 | logContainsError("testfiles/files/multi-doc.yaml[2] is invalid"), 447 | ); 448 | }); 449 | }); 450 | 451 | it("should validate json5 files", function () { 452 | return cli({ 453 | patterns: ["testfiles/files/valid.json5"], 454 | schema: "testfiles/schemas/schema.json", 455 | ignorePatternFiles: [], 456 | }).then((result) => { 457 | assert.equal(result, 0); 458 | assert(logContainsSuccess("testfiles/files/valid.json5 is valid")); 459 | }); 460 | }); 461 | 462 | it("should validate toml files", function () { 463 | return cli({ 464 | patterns: ["testfiles/files/valid.toml"], 465 | schema: "testfiles/schemas/schema.json", 466 | ignorePatternFiles: [], 467 | }).then((result) => { 468 | assert.equal(result, 0); 469 | assert(logContainsSuccess("testfiles/files/valid.toml is valid")); 470 | }); 471 | }); 472 | 473 | it("should use custom parser in preference to file extension if specified", function () { 474 | return cli({ 475 | patterns: ["testfiles/files/with-comments.json"], 476 | customCatalog: { 477 | schemas: [ 478 | { 479 | name: "custom schema", 480 | fileMatch: ["with-comments.json"], 481 | location: "testfiles/schemas/schema.json", 482 | parser: "json5", 483 | }, 484 | ], 485 | }, 486 | configFileRelativePath: "foobar.conf", 487 | ignorePatternFiles: [], 488 | }).then((result) => { 489 | assert.equal(result, 0); 490 | assert( 491 | logContainsSuccess("testfiles/files/with-comments.json is valid"), 492 | ); 493 | }); 494 | }); 495 | }); 496 | 497 | describe("success behaviour, single file with YAML as JSON schema", function () { 498 | const schema = { 499 | $schema: "http://json-schema.org/draft-07/schema#", 500 | type: "object", 501 | properties: { num: { type: "number" } }, 502 | }; 503 | 504 | let yamlSchema; 505 | 506 | before(function () { 507 | yamlSchema = dumpToYaml(schema); 508 | }); 509 | 510 | beforeEach(function () { 511 | setUp(); 512 | }); 513 | 514 | afterEach(function () { 515 | tearDown(); 516 | nock.cleanAll(); 517 | }); 518 | 519 | it("should return 0 when file is valid (with user-supplied local schema)", function () { 520 | return cli({ 521 | patterns: ["testfiles/files/valid.json"], 522 | schema: "testfiles/schemas/schema.yaml", 523 | ignorePatternFiles: [], 524 | }).then((result) => { 525 | assert.equal(result, 0); 526 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 527 | }); 528 | }); 529 | 530 | it("should return 99 when file is invalid (with user-supplied local schema)", function () { 531 | return cli({ 532 | patterns: ["testfiles/files/invalid.json"], 533 | schema: "testfiles/schemas/schema.yaml", 534 | ignorePatternFiles: [], 535 | }).then((result) => { 536 | assert.equal(result, 99); 537 | assert(logContainsError("testfiles/files/invalid.json is invalid")); 538 | }); 539 | }); 540 | 541 | it("should return 0 when file is valid (with user-supplied remote schema)", function () { 542 | const mock = nock("https://example.com") 543 | .get("/schema.yaml") 544 | .reply(200, yamlSchema); 545 | 546 | return cli({ 547 | patterns: ["testfiles/files/valid.json"], 548 | schema: "https://example.com/schema.yaml", 549 | ignorePatternFiles: [], 550 | }).then((result) => { 551 | assert.equal(result, 0); 552 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 553 | mock.done(); 554 | }); 555 | }); 556 | 557 | it("should return 99 when file is invalid (with user-supplied remote schema)", function () { 558 | const mock = nock("https://example.com") 559 | .get("/schema.yaml") 560 | .reply(200, yamlSchema); 561 | 562 | return cli({ 563 | patterns: ["testfiles/files/invalid.json"], 564 | schema: "https://example.com/schema.yaml", 565 | ignorePatternFiles: [], 566 | }).then((result) => { 567 | assert.equal(result, 99); 568 | assert(logContainsError("testfiles/files/invalid.json is invalid")); 569 | mock.done(); 570 | }); 571 | }); 572 | 573 | it("should return 0 when file is valid (with auto-detected schema)", function () { 574 | const catalogMock = nock("https://www.schemastore.org") 575 | .get("/api/json/catalog.json") 576 | .reply(200, { 577 | schemas: [ 578 | { 579 | url: "https://example.com/schema.yaml", 580 | fileMatch: ["valid.json", "invalid.json"], 581 | }, 582 | ], 583 | }); 584 | const schemaMock = nock("https://example.com") 585 | .get("/schema.yaml") 586 | .reply(200, yamlSchema); 587 | 588 | return cli({ 589 | patterns: ["testfiles/files/valid.json"], 590 | ignorePatternFiles: [], 591 | }).then((result) => { 592 | assert.equal(result, 0); 593 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 594 | catalogMock.done(); 595 | schemaMock.done(); 596 | }); 597 | }); 598 | 599 | it("should return 99 when file is invalid (with auto-detected schema)", function () { 600 | const catalogMock = nock("https://www.schemastore.org") 601 | .get("/api/json/catalog.json") 602 | .reply(200, { 603 | schemas: [ 604 | { 605 | url: "https://example.com/schema.yaml", 606 | fileMatch: ["valid.json", "invalid.json"], 607 | }, 608 | ], 609 | }); 610 | const schemaMock = nock("https://example.com") 611 | .get("/schema.yaml") 612 | .reply(200, yamlSchema); 613 | 614 | return cli({ 615 | patterns: ["testfiles/files/invalid.json"], 616 | ignorePatternFiles: [], 617 | }).then((result) => { 618 | assert.equal(result, 99); 619 | assert(logContainsError("testfiles/files/invalid.json is invalid")); 620 | catalogMock.done(); 621 | schemaMock.done(); 622 | }); 623 | }); 624 | }); 625 | 626 | describe("error handling, single file", function () { 627 | beforeEach(function () { 628 | setUp(); 629 | }); 630 | 631 | afterEach(function () { 632 | tearDown(); 633 | }); 634 | 635 | it("should return 1 if invalid response from schemastore", async function () { 636 | const mock = nock("https://www.schemastore.org") 637 | .get("/api/json/catalog.json") 638 | .reply(404, {}); 639 | 640 | return cli({ 641 | patterns: ["testfiles/files/valid.json"], 642 | ignorePatternFiles: [], 643 | }).then((result) => { 644 | assert.equal(result, 1); 645 | assert( 646 | logContainsError( 647 | "Failed fetching https://www.schemastore.org/api/json/catalog.json", 648 | ), 649 | ); 650 | mock.done(); 651 | }); 652 | }); 653 | 654 | it("should return 1 if invalid response fetching auto-detected schema", async function () { 655 | const catalogMock = nock("https://www.schemastore.org") 656 | .get("/api/json/catalog.json") 657 | .reply(200, { 658 | schemas: [ 659 | { 660 | url: "https://example.com/schema.json", 661 | fileMatch: ["valid.json", "invalid.json"], 662 | }, 663 | ], 664 | }); 665 | const schemaMock = nock("https://example.com") 666 | .get("/schema.json") 667 | .reply(404, {}); 668 | 669 | return cli({ 670 | patterns: ["testfiles/files/valid.json"], 671 | ignorePatternFiles: [], 672 | }).then((result) => { 673 | assert.equal(result, 1); 674 | assert( 675 | logContainsError("Failed fetching https://example.com/schema.json"), 676 | ); 677 | catalogMock.done(); 678 | schemaMock.done(); 679 | }); 680 | }); 681 | 682 | it("should return 1 if we can't find a schema", async function () { 683 | const mock = nock("https://www.schemastore.org") 684 | .get("/api/json/catalog.json") 685 | .reply(200, { 686 | schemas: [ 687 | { 688 | url: "https://example.com/schema.json", 689 | fileMatch: ["not-a-match.json"], 690 | }, 691 | ], 692 | }); 693 | 694 | return cli({ 695 | patterns: ["testfiles/files/valid.json"], 696 | ignorePatternFiles: [], 697 | }).then((result) => { 698 | assert.equal(result, 1); 699 | assert( 700 | logContainsError( 701 | "Could not find a schema to validate testfiles/files/valid.json", 702 | ), 703 | ); 704 | mock.done(); 705 | }); 706 | }); 707 | 708 | it("should return 1 if multiple schemas are matched", async function () { 709 | const mock = nock("https://www.schemastore.org") 710 | .get("/api/json/catalog.json") 711 | .reply(200, { 712 | schemas: [ 713 | { 714 | url: "https://example.com/schema1.json", 715 | name: "example schema 1", 716 | fileMatch: ["valid.json"], 717 | }, 718 | { 719 | url: "https://example.com/schema2.json", 720 | name: "example schema 2", 721 | fileMatch: ["testfiles/files/valid*"], 722 | }, 723 | ], 724 | }); 725 | 726 | return cli({ 727 | patterns: ["testfiles/files/valid.json"], 728 | ignorePatternFiles: [], 729 | }).then((result) => { 730 | assert.equal(result, 1); 731 | assert( 732 | logContainsError( 733 | "Found multiple possible schemas to validate testfiles/files/valid.json", 734 | ), 735 | ); 736 | assert( 737 | logContainsInfo( 738 | "Found multiple possible matches for testfiles/files/valid.json. Possible matches:", 739 | ), 740 | ); 741 | assert( 742 | logContainsInfo( 743 | "example schema 1\n https://example.com/schema1.json", 744 | ), 745 | ); 746 | assert( 747 | logContainsInfo( 748 | "example schema 2\n https://example.com/schema2.json", 749 | ), 750 | ); 751 | mock.done(); 752 | }); 753 | }); 754 | 755 | it("should return 1 if invalid response fetching user-supplied schema", async function () { 756 | const mock = nock("https://example.com") 757 | .get("/schema.json") 758 | .reply(404, {}); 759 | 760 | return cli({ 761 | patterns: ["testfiles/files/valid.json"], 762 | schema: "https://example.com/schema.json", 763 | ignorePatternFiles: [], 764 | }).then((result) => { 765 | assert.equal(result, 1); 766 | assert( 767 | logContainsError("Failed fetching https://example.com/schema.json"), 768 | ); 769 | mock.done(); 770 | }); 771 | }); 772 | 773 | it("should return 97 if config file is found but invalid", async function () { 774 | const tempDir = path.join(os.tmpdir(), randomUUID()); 775 | const tempFile = path.join(tempDir, ".v8rrc"); 776 | fs.mkdirSync(tempDir, { recursive: true }); 777 | fs.writeFileSync(tempFile, '{"foo":"bar"}'); 778 | 779 | const mock = mockCwd(tempDir); 780 | return cli().then((result) => { 781 | mock.restore(); 782 | fs.rmSync(tempDir, { recursive: true, force: true }); 783 | assert.equal(result, 97); 784 | assert(logContainsError("Malformed config file")); 785 | }); 786 | }); 787 | 788 | it("should return 98 if any glob pattern matches no files", async function () { 789 | return cli({ 790 | patterns: [ 791 | "testfiles/files/valid.json", 792 | "testfiles/does-not-exist.json", 793 | ], 794 | }).then((result) => { 795 | assert.equal(result, 98); 796 | assert( 797 | logContainsError( 798 | "Pattern 'testfiles/does-not-exist.json' did not match any files", 799 | ), 800 | ); 801 | }); 802 | }); 803 | 804 | it("should return 98 if glob pattern is invalid", async function () { 805 | return cli({ patterns: [""] }).then((result) => { 806 | assert.equal(result, 98); 807 | assert(logContainsError("Pattern '' did not match any files")); 808 | }); 809 | }); 810 | 811 | it("should return 1 if target file type is not supported", async function () { 812 | return cli({ 813 | patterns: ["testfiles/files/not-supported.txt"], 814 | schema: "testfiles/schemas/schema.json", 815 | ignorePatternFiles: [], 816 | }).then((result) => { 817 | assert.equal(result, 1); 818 | assert(logContainsError("Unsupported format txt")); 819 | }); 820 | }); 821 | 822 | it("should return 1 if local schema file not found", async function () { 823 | return cli({ 824 | patterns: ["testfiles/files/valid.json"], 825 | schema: "testfiles/does-not-exist.json", 826 | ignorePatternFiles: [], 827 | }).then((result) => { 828 | assert.equal(result, 1); 829 | assert( 830 | logContainsError( 831 | "ENOENT: no such file or directory, open 'testfiles/does-not-exist.json'", 832 | ), 833 | ); 834 | }); 835 | }); 836 | 837 | it("should return 1 if local catalog file not found", async function () { 838 | return cli({ 839 | patterns: ["testfiles/files/valid.json"], 840 | catalogs: ["testfiles/does-not-exist.json"], 841 | ignorePatternFiles: [], 842 | }).then((result) => { 843 | assert.equal(result, 1); 844 | assert( 845 | logContainsError( 846 | "ENOENT: no such file or directory, open 'testfiles/does-not-exist.json'", 847 | ), 848 | ); 849 | }); 850 | }); 851 | 852 | it("should return 1 if invalid response fetching remote catalog", async function () { 853 | const mock = nock("https://example.com") 854 | .get("/catalog.json") 855 | .reply(404, {}); 856 | 857 | return cli({ 858 | patterns: ["testfiles/files/valid.json"], 859 | catalogs: ["https://example.com/catalog.json"], 860 | ignorePatternFiles: [], 861 | }).then((result) => { 862 | assert.equal(result, 1); 863 | assert( 864 | logContainsError("Failed fetching https://example.com/catalog.json"), 865 | ); 866 | mock.done(); 867 | }); 868 | }); 869 | 870 | it("should return 1 on malformed catalog (missing 'schemas')", async function () { 871 | const mock = nock("https://example.com") 872 | .get("/catalog.json") 873 | .reply(200, {}); 874 | 875 | return cli({ 876 | patterns: ["testfiles/files/valid.json"], 877 | catalogs: ["https://example.com/catalog.json"], 878 | ignorePatternFiles: [], 879 | }).then((result) => { 880 | assert.equal(result, 1); 881 | mock.done(); 882 | assert( 883 | logContainsError( 884 | "Malformed catalog at https://example.com/catalog.json", 885 | ), 886 | ); 887 | }); 888 | }); 889 | 890 | it("should return 1 on malformed catalog ('schemas' should be an array)", async function () { 891 | const mock = nock("https://example.com") 892 | .get("/catalog.json") 893 | .reply(200, { schemas: {} }); 894 | 895 | return cli({ 896 | patterns: ["testfiles/files/valid.json"], 897 | catalogs: ["https://example.com/catalog.json"], 898 | ignorePatternFiles: [], 899 | }).then((result) => { 900 | assert.equal(result, 1); 901 | mock.done(); 902 | assert( 903 | logContainsError( 904 | "Malformed catalog at https://example.com/catalog.json", 905 | ), 906 | ); 907 | }); 908 | }); 909 | 910 | it("should return 1 on malformed catalog ('schemas' elements should contains a valid url)", async function () { 911 | return cli({ 912 | patterns: ["testfiles/files/valid.json"], 913 | catalogs: ["testfiles/catalogs/catalog-malformed.json"], 914 | ignorePatternFiles: [], 915 | }).then((result) => { 916 | assert.equal(result, 1); 917 | assert( 918 | logContainsError( 919 | "Malformed catalog at testfiles/catalogs/catalog-malformed.json", 920 | ), 921 | ); 922 | }); 923 | }); 924 | 925 | it("should return 0 if ignore-errors flag is passed", async function () { 926 | return cli({ 927 | patterns: ["testfiles/files/not-supported.txt"], 928 | schema: "testfiles/schemas/schema.json", 929 | ignoreErrors: true, 930 | ignorePatternFiles: [], 931 | }).then((result) => { 932 | assert.equal(result, 0); 933 | assert(logContainsError("Unsupported format txt")); 934 | }); 935 | }); 936 | 937 | it("should return 1 on multi-document as schema", function () { 938 | // In principle, it is possible to serialize multiple yaml documents 939 | // into a single file, but js-yaml does not support this. 940 | return cli({ 941 | patterns: ["testfiles/files/valid.json"], 942 | schema: "testfiles/schemas/schema.multi-doc.yaml", 943 | ignorePatternFiles: [], 944 | }).then((result) => { 945 | assert.equal(result, 1); 946 | assert( 947 | logContainsError( 948 | "expected a single document in the stream, but found more", 949 | ), 950 | ); 951 | }); 952 | }); 953 | }); 954 | 955 | describe("multiple file processing", function () { 956 | beforeEach(function () { 957 | setUp(); 958 | }); 959 | 960 | afterEach(function () { 961 | tearDown(); 962 | }); 963 | 964 | it("should return 0 if all files are valid", async function () { 965 | return cli({ 966 | patterns: ["{testfiles/files/valid.json,testfiles/files/valid.yaml}"], 967 | schema: "testfiles/schemas/schema.json", 968 | ignorePatternFiles: [], 969 | }).then((result) => { 970 | assert.equal(result, 0); 971 | assert(logContainsSuccess("is valid", 2)); 972 | }); 973 | }); 974 | 975 | it("should accept multiple glob patterns", async function () { 976 | return cli({ 977 | patterns: ["testfiles/files/valid.json", "testfiles/files/valid.yaml"], 978 | schema: "testfiles/schemas/schema.json", 979 | ignorePatternFiles: [], 980 | }).then((result) => { 981 | assert.equal(result, 0); 982 | assert(logContainsSuccess("is valid", 2)); 983 | }); 984 | }); 985 | 986 | it("should return 99 if any file is invalid", async function () { 987 | return cli({ 988 | patterns: [ 989 | "{testfiles/files/valid.json,testfiles/files/invalid.json,testfiles/files/not-supported.txt}", 990 | ], 991 | schema: "testfiles/schemas/schema.json", 992 | ignorePatternFiles: [], 993 | }).then((result) => { 994 | assert.equal(result, 99); 995 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 996 | assert(logContainsError("testfiles/files/invalid.json is invalid")); 997 | assert(logContainsError("Unsupported format txt")); 998 | }); 999 | }); 1000 | 1001 | it("should return 1 if any file throws an error and no files are invalid", async function () { 1002 | return cli({ 1003 | patterns: [ 1004 | "{testfiles/files/valid.json,testfiles/files/not-supported.txt}", 1005 | ], 1006 | schema: "testfiles/schemas/schema.json", 1007 | ignorePatternFiles: [], 1008 | }).then((result) => { 1009 | assert.equal(result, 1); 1010 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 1011 | assert(logContainsError("Unsupported format txt")); 1012 | }); 1013 | }); 1014 | 1015 | it("should ignore errors when ignore-errors flag is passed", async function () { 1016 | return cli({ 1017 | patterns: [ 1018 | "{testfiles/files/valid.json,testfiles/files/not-supported.txt}", 1019 | ], 1020 | schema: "testfiles/schemas/schema.json", 1021 | ignoreErrors: true, 1022 | ignorePatternFiles: [], 1023 | }).then((result) => { 1024 | assert.equal(result, 0); 1025 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 1026 | assert(logContainsError("Unsupported format txt")); 1027 | }); 1028 | }); 1029 | 1030 | it("should de-duplicate and sort paths", async function () { 1031 | return cli({ 1032 | patterns: [ 1033 | "testfiles/files/valid.json", 1034 | "testfiles/files/valid.json", 1035 | "testfiles/files/invalid.json", 1036 | ], 1037 | schema: "testfiles/schemas/schema.json", 1038 | outputFormat: "json", 1039 | ignorePatternFiles: [], 1040 | }).then(() => { 1041 | const json = JSON.parse(logger.stdout[0]); 1042 | const results = json.results; 1043 | assert.equal(results.length, 2); 1044 | assert.equal(results[0].fileLocation, "testfiles/files/invalid.json"); 1045 | assert.equal(results[1].fileLocation, "testfiles/files/valid.json"); 1046 | }); 1047 | }); 1048 | }); 1049 | 1050 | describe("output formats", function () { 1051 | beforeEach(function () { 1052 | setUp(); 1053 | }); 1054 | 1055 | afterEach(function () { 1056 | tearDown(); 1057 | }); 1058 | 1059 | it("should log errors in text format when format is text (single doc)", async function () { 1060 | return cli({ 1061 | patterns: [ 1062 | "{testfiles/files/valid.json,testfiles/files/invalid.json,testfiles/files/not-supported.txt}", 1063 | ], 1064 | schema: "testfiles/schemas/schema.json", 1065 | outputFormat: "text", 1066 | ignorePatternFiles: [], 1067 | }).then(() => { 1068 | assert( 1069 | logger.stdout.includes( 1070 | "testfiles/files/invalid.json#/num must be number\n", 1071 | ), 1072 | ); 1073 | }); 1074 | }); 1075 | 1076 | it("should log errors in text format when format is text (multi doc)", async function () { 1077 | return cli({ 1078 | patterns: ["testfiles/files/multi-doc.yaml"], 1079 | schema: "testfiles/schemas/schema.json", 1080 | outputFormat: "text", 1081 | ignorePatternFiles: [], 1082 | }).then(() => { 1083 | assert( 1084 | logger.stdout.includes( 1085 | "testfiles/files/multi-doc.yaml[2]#/num must be number\n", 1086 | ), 1087 | ); 1088 | }); 1089 | }); 1090 | 1091 | it("should output json report when format is json (single doc)", async function () { 1092 | return cli({ 1093 | patterns: [ 1094 | "{testfiles/files/valid.json,testfiles/files/invalid.json,testfiles/files/not-supported.txt}", 1095 | ], 1096 | schema: "testfiles/schemas/schema.json", 1097 | outputFormat: "json", 1098 | ignorePatternFiles: [], 1099 | }).then(() => { 1100 | const expected = { 1101 | results: [ 1102 | { 1103 | code: 99, 1104 | errors: [ 1105 | { 1106 | instancePath: "/num", 1107 | keyword: "type", 1108 | message: "must be number", 1109 | params: { 1110 | type: "number", 1111 | }, 1112 | schemaPath: "#/properties/num/type", 1113 | }, 1114 | ], 1115 | fileLocation: "testfiles/files/invalid.json", 1116 | documentIndex: null, 1117 | schemaLocation: "testfiles/schemas/schema.json", 1118 | valid: false, 1119 | }, 1120 | { 1121 | code: 1, 1122 | errors: [], 1123 | fileLocation: "testfiles/files/not-supported.txt", 1124 | documentIndex: null, 1125 | schemaLocation: "testfiles/schemas/schema.json", 1126 | valid: null, 1127 | }, 1128 | { 1129 | code: 0, 1130 | errors: [], 1131 | fileLocation: "testfiles/files/valid.json", 1132 | documentIndex: null, 1133 | schemaLocation: "testfiles/schemas/schema.json", 1134 | valid: true, 1135 | }, 1136 | ], 1137 | }; 1138 | assert.deepStrictEqual(JSON.parse(logger.stdout[0]), expected); 1139 | }); 1140 | }); 1141 | 1142 | it("should output json report when format is json (multi doc)", async function () { 1143 | return cli({ 1144 | patterns: ["testfiles/files/multi-doc.yaml"], 1145 | schema: "testfiles/schemas/schema.json", 1146 | outputFormat: "json", 1147 | ignorePatternFiles: [], 1148 | }).then(() => { 1149 | const expected = { 1150 | results: [ 1151 | { 1152 | code: 0, 1153 | errors: [], 1154 | fileLocation: "testfiles/files/multi-doc.yaml", 1155 | documentIndex: 0, 1156 | schemaLocation: "testfiles/schemas/schema.json", 1157 | valid: true, 1158 | }, 1159 | { 1160 | code: 0, 1161 | errors: [], 1162 | fileLocation: "testfiles/files/multi-doc.yaml", 1163 | documentIndex: 1, 1164 | schemaLocation: "testfiles/schemas/schema.json", 1165 | valid: true, 1166 | }, 1167 | { 1168 | code: 99, 1169 | errors: [ 1170 | { 1171 | instancePath: "/num", 1172 | keyword: "type", 1173 | message: "must be number", 1174 | params: { 1175 | type: "number", 1176 | }, 1177 | schemaPath: "#/properties/num/type", 1178 | }, 1179 | ], 1180 | fileLocation: "testfiles/files/multi-doc.yaml", 1181 | documentIndex: 2, 1182 | schemaLocation: "testfiles/schemas/schema.json", 1183 | valid: false, 1184 | }, 1185 | ], 1186 | }; 1187 | assert.deepStrictEqual(JSON.parse(logger.stdout[0]), expected); 1188 | }); 1189 | }); 1190 | }); 1191 | 1192 | describe("external reference resolver", function () { 1193 | beforeEach(function () { 1194 | setUp(); 1195 | }); 1196 | 1197 | afterEach(function () { 1198 | tearDown(); 1199 | nock.cleanAll(); 1200 | }); 1201 | 1202 | it("resolves remote $refs", function () { 1203 | const fragment = { 1204 | type: "object", 1205 | properties: { 1206 | num: { 1207 | type: "number", 1208 | }, 1209 | }, 1210 | required: ["num"], 1211 | }; 1212 | const refMock = nock("https://example.com/foobar") 1213 | .get("/fragment.json") 1214 | .reply(200, fragment); 1215 | 1216 | return cli({ 1217 | patterns: ["testfiles/files/valid.json"], 1218 | schema: "testfiles/schemas/remote_external_ref.json", 1219 | ignorePatternFiles: [], 1220 | }).then((result) => { 1221 | assert.equal(result, 0); 1222 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 1223 | refMock.done(); 1224 | }); 1225 | }); 1226 | 1227 | it("resolves local $refs (with $id)", async function () { 1228 | return cli({ 1229 | patterns: ["testfiles/files/valid.json"], 1230 | schema: "testfiles/schemas/local_external_ref_with_id.json", 1231 | ignorePatternFiles: [], 1232 | }).then((result) => { 1233 | assert.equal(result, 0); 1234 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 1235 | }); 1236 | }); 1237 | 1238 | it("resolves local $refs (without $id)", async function () { 1239 | return cli({ 1240 | patterns: ["testfiles/files/valid.json"], 1241 | schema: "testfiles/schemas/local_external_ref_without_id.json", 1242 | ignorePatternFiles: [], 1243 | }).then((result) => { 1244 | assert.equal(result, 0); 1245 | assert(logContainsSuccess("testfiles/files/valid.json is valid")); 1246 | }); 1247 | }); 1248 | 1249 | it("fails on invalid $ref", async function () { 1250 | return cli({ 1251 | patterns: ["testfiles/files/valid.json"], 1252 | schema: "testfiles/schemas/invalid_external_ref.json", 1253 | ignorePatternFiles: [], 1254 | }).then((result) => { 1255 | assert.equal(result, 1); 1256 | assert( 1257 | logContainsError( 1258 | "no such file or directory, open 'testfiles/schemas/does-not-exist.json'", 1259 | ), 1260 | ); 1261 | }); 1262 | }); 1263 | }); 1264 | }); 1265 | -------------------------------------------------------------------------------- /src/config-validators.js: -------------------------------------------------------------------------------- 1 | import { createRequire } from "node:module"; 2 | // TODO: once JSON modules is stable these requires could become imports 3 | // https://nodejs.org/api/esm.html#esm_experimental_json_modules 4 | const require = createRequire(import.meta.url); 5 | 6 | import Ajv2019 from "ajv/dist/2019.js"; 7 | import logger from "./logger.js"; 8 | import { formatErrors } from "./output-formatters.js"; 9 | 10 | function validateConfigAgainstSchema(configFile) { 11 | const ajv = new Ajv2019({ allErrors: true, strict: false }); 12 | const schema = require("../config-schema.json"); 13 | const validateFn = ajv.compile(schema); 14 | const valid = validateFn(configFile.config); 15 | if (!valid) { 16 | logger.log( 17 | formatErrors( 18 | configFile.filepath ? configFile.filepath : "", 19 | validateFn.errors, 20 | ), 21 | ); 22 | throw new Error("Malformed config file"); 23 | } 24 | return true; 25 | } 26 | 27 | function validateConfigDocumentParsers(configFile, documentFormats) { 28 | for (const schema of configFile.config?.customCatalog?.schemas || []) { 29 | if (schema?.parser != null && !documentFormats.includes(schema?.parser)) { 30 | throw new Error( 31 | `Malformed config file: "${schema.parser}" not in ${JSON.stringify(documentFormats)}`, 32 | ); 33 | } 34 | } 35 | return true; 36 | } 37 | 38 | function validateConfigOutputFormats(configFile, outputFormats) { 39 | if ( 40 | configFile.config?.format != null && 41 | !outputFormats.includes(configFile.config?.format) 42 | ) { 43 | throw new Error( 44 | `Malformed config file: "${configFile.config.format}" not in ${JSON.stringify(outputFormats)}`, 45 | ); 46 | } 47 | return true; 48 | } 49 | 50 | export { 51 | validateConfigAgainstSchema, 52 | validateConfigDocumentParsers, 53 | validateConfigOutputFormats, 54 | }; 55 | -------------------------------------------------------------------------------- /src/config-validators.spec.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | 3 | import { getDocumentFormats, getOutputFormats } from "./bootstrap.js"; 4 | import { 5 | validateConfigAgainstSchema, 6 | validateConfigDocumentParsers, 7 | validateConfigOutputFormats, 8 | } from "./config-validators.js"; 9 | import { loadAllPlugins } from "./plugins.js"; 10 | import { setUp, tearDown } from "./test-helpers.js"; 11 | 12 | const validConfigs = [ 13 | { config: {} }, 14 | { 15 | config: { 16 | ignoreErrors: true, 17 | ignorePatternFiles: [".v8rignore", ".gitignore"], 18 | verbose: 0, 19 | patterns: ["foobar.js"], 20 | cacheTtl: 600, 21 | outputFormat: "json", 22 | plugins: [ 23 | "package:v8r-plugin-emoji-output", 24 | "file:./testfiles/plugins/valid.js", 25 | ], 26 | customCatalog: { 27 | schemas: [ 28 | { 29 | name: "Schema 1", 30 | fileMatch: ["file1.json"], 31 | location: "localschema.json", 32 | }, 33 | { 34 | name: "Schema 2", 35 | description: "Long Description", 36 | fileMatch: ["file2.json"], 37 | location: "https://example.com/remoteschema.json", 38 | parser: "json5", 39 | }, 40 | ], 41 | }, 42 | }, 43 | }, 44 | ]; 45 | 46 | const { allLoadedPlugins } = await loadAllPlugins([]); 47 | const documentFormats = getDocumentFormats(allLoadedPlugins); 48 | const outputFormats = getOutputFormats(allLoadedPlugins); 49 | 50 | describe("validateConfigAgainstSchema", function () { 51 | const messages = {}; 52 | 53 | beforeEach(function () { 54 | setUp(messages); 55 | }); 56 | 57 | afterEach(function () { 58 | tearDown(); 59 | }); 60 | 61 | it("should pass valid configs", function () { 62 | for (const config of validConfigs) { 63 | assert(validateConfigAgainstSchema(config)); 64 | } 65 | }); 66 | 67 | it("should reject invalid configs", function () { 68 | const invalidConfigs = [ 69 | { config: { ignoreErrors: "string" } }, 70 | { config: { foo: "bar" } }, 71 | { config: { verbose: "string" } }, 72 | { config: { verbose: -1 } }, 73 | { config: { patterns: "string" } }, 74 | { config: { patterns: [] } }, 75 | { config: { patterns: ["valid", "ok", false] } }, 76 | { config: { patterns: ["duplicate", "duplicate"] } }, 77 | { config: { cacheTtl: "string" } }, 78 | { config: { cacheTtl: -1 } }, 79 | { config: { plugins: ["invalid"] } }, 80 | { config: { plugins: ["./invalid.js"] } }, 81 | { config: { plugins: ["invalid-prefix:invalid"] } }, 82 | { config: { customCatalog: "string" } }, 83 | { config: { customCatalog: {} } }, 84 | { config: { customCatalog: { schemas: [{}] } } }, 85 | { 86 | config: { 87 | customCatalog: { 88 | schemas: [ 89 | { 90 | name: "Schema 1", 91 | fileMatch: ["file1.json"], 92 | location: "localschema.json", 93 | foo: "bar", 94 | }, 95 | ], 96 | }, 97 | }, 98 | }, 99 | { 100 | config: { 101 | customCatalog: { 102 | schemas: [ 103 | { 104 | name: "Schema 1", 105 | fileMatch: ["file1.json"], 106 | location: "localschema.json", 107 | url: "https://example.com/remoteschema.json", 108 | }, 109 | ], 110 | }, 111 | }, 112 | }, 113 | ]; 114 | for (const config of invalidConfigs) { 115 | assert.throws(() => validateConfigAgainstSchema(config), { 116 | name: "Error", 117 | message: "Malformed config file", 118 | }); 119 | } 120 | }); 121 | }); 122 | 123 | describe("validateConfigDocumentParsers", function () { 124 | it("should pass valid configs", function () { 125 | for (const config of validConfigs) { 126 | assert(validateConfigDocumentParsers(config, documentFormats)); 127 | } 128 | }); 129 | 130 | it("should reject invalid configs", function () { 131 | const invalidConfigs = [ 132 | { 133 | config: { 134 | customCatalog: { 135 | schemas: [ 136 | { 137 | name: "Schema 1", 138 | fileMatch: ["file1.json"], 139 | location: "localschema.json", 140 | parser: "invalid", 141 | }, 142 | ], 143 | }, 144 | }, 145 | }, 146 | ]; 147 | for (const config of invalidConfigs) { 148 | assert.throws( 149 | () => validateConfigDocumentParsers(config, documentFormats), 150 | { 151 | name: "Error", 152 | message: /^Malformed config file.*$/, 153 | }, 154 | ); 155 | } 156 | }); 157 | }); 158 | 159 | describe("validateConfigOutputFormats", function () { 160 | it("should pass valid configs", function () { 161 | for (const config of validConfigs) { 162 | assert(validateConfigOutputFormats(config, outputFormats)); 163 | } 164 | }); 165 | 166 | it("should reject invalid configs", function () { 167 | const invalidConfigs = [{ config: { format: "invalid" } }]; 168 | for (const config of invalidConfigs) { 169 | assert.throws(() => validateConfigOutputFormats(config, outputFormats), { 170 | name: "Error", 171 | message: /^Malformed config file.*$/, 172 | }); 173 | } 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /src/glob.js: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import { glob } from "glob"; 4 | import ignore from "ignore"; 5 | import logger from "./logger.js"; 6 | 7 | class NotFound extends Error {} 8 | 9 | async function getMatches(pattern) { 10 | try { 11 | return await glob(pattern, { dot: true }); 12 | } catch (e) { 13 | logger.error(e.message); 14 | return []; 15 | } 16 | } 17 | 18 | async function exists(path) { 19 | try { 20 | await fs.promises.access(path); 21 | return true; 22 | } catch { 23 | return false; 24 | } 25 | } 26 | 27 | async function filterIgnores(filenames, ignorePatterns) { 28 | const ig = ignore(); 29 | for (const patterns of ignorePatterns) { 30 | ig.add(patterns); 31 | } 32 | return ig.filter(filenames); 33 | } 34 | 35 | async function readIgnoreFiles(filenames) { 36 | let content = []; 37 | for (const filename of filenames) { 38 | const abspath = path.join(process.cwd(), filename); 39 | if (await exists(abspath)) { 40 | content.push(await fs.promises.readFile(abspath, "utf8")); 41 | } 42 | } 43 | return content; 44 | } 45 | 46 | async function getFiles(patterns, ignorePatternFiles) { 47 | let filenames = []; 48 | 49 | // find all the files matching input globs 50 | for (const pattern of patterns) { 51 | const matches = await getMatches(pattern); 52 | if (matches.length === 0) { 53 | throw new NotFound(`Pattern '${pattern}' did not match any files`); 54 | } 55 | filenames = filenames.concat(matches); 56 | } 57 | 58 | // de-dupe 59 | filenames = [...new Set(filenames)]; 60 | 61 | // process ignores 62 | const ignorePatterns = await readIgnoreFiles(ignorePatternFiles); 63 | let filteredFilenames = await filterIgnores(filenames, ignorePatterns); 64 | 65 | const diff = filenames.filter((x) => filteredFilenames.indexOf(x) < 0); 66 | if (diff.length > 0) { 67 | logger.debug( 68 | `Ignoring file(s):\n ${diff.join("\n ")}\nbased on ignore patterns in\n ${ignorePatternFiles.join("\n ")}`, 69 | ); 70 | } 71 | 72 | // finally, sort 73 | filteredFilenames.sort((a, b) => a.localeCompare(b)); 74 | 75 | if (filteredFilenames.length === 0) { 76 | throw new NotFound(`Could not find any files to validate`); 77 | } 78 | 79 | return filteredFilenames; 80 | } 81 | 82 | export { getFiles, NotFound }; 83 | -------------------------------------------------------------------------------- /src/glob.spec.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | 3 | import { getFiles } from "./glob.js"; 4 | import { setUp, tearDown } from "./test-helpers.js"; 5 | 6 | describe("getFiles", function () { 7 | beforeEach(function () { 8 | setUp(); 9 | }); 10 | 11 | afterEach(function () { 12 | tearDown(); 13 | }); 14 | 15 | it("matches single filename", async function () { 16 | const patterns = ["testfiles/files/valid.json"]; 17 | const ignorePatternFiles = []; 18 | const expected = ["testfiles/files/valid.json"]; 19 | assert.deepStrictEqual( 20 | await getFiles(patterns, ignorePatternFiles), 21 | expected, 22 | ); 23 | }); 24 | 25 | it("matches multiple filenames", async function () { 26 | const patterns = [ 27 | "testfiles/files/valid.json", 28 | "testfiles/files/valid.yaml", 29 | ]; 30 | const ignorePatternFiles = []; 31 | const expected = [ 32 | "testfiles/files/valid.json", 33 | "testfiles/files/valid.yaml", 34 | ]; 35 | assert.deepStrictEqual( 36 | await getFiles(patterns, ignorePatternFiles), 37 | expected, 38 | ); 39 | }); 40 | 41 | it("matches single glob", async function () { 42 | const patterns = ["testfiles/files/*.yaml"]; 43 | const ignorePatternFiles = []; 44 | const expected = [ 45 | "testfiles/files/multi-doc.yaml", 46 | "testfiles/files/valid.yaml", 47 | ]; 48 | assert.deepStrictEqual( 49 | await getFiles(patterns, ignorePatternFiles), 50 | expected, 51 | ); 52 | }); 53 | 54 | it("matches multiple globs", async function () { 55 | const patterns = ["testfiles/files/*.yaml", "testfiles/files/*.json"]; 56 | const ignorePatternFiles = []; 57 | const expected = [ 58 | "testfiles/files/invalid.json", 59 | "testfiles/files/multi-doc.yaml", 60 | "testfiles/files/valid.json", 61 | "testfiles/files/valid.yaml", 62 | "testfiles/files/with-comments.json", 63 | ]; 64 | assert.deepStrictEqual( 65 | await getFiles(patterns, ignorePatternFiles), 66 | expected, 67 | ); 68 | }); 69 | 70 | it("throws if filename not found", async function () { 71 | const patterns = ["testfiles/files/does-not-exist.png"]; 72 | const ignorePatternFiles = []; 73 | await assert.rejects(getFiles(patterns, ignorePatternFiles), { 74 | name: "Error", 75 | message: 76 | "Pattern 'testfiles/files/does-not-exist.png' did not match any files", 77 | }); 78 | }); 79 | 80 | it("throws if no matches found for pattern", async function () { 81 | const patterns = ["testfiles/files/*.png"]; 82 | const ignorePatternFiles = []; 83 | await assert.rejects(getFiles(patterns, ignorePatternFiles), { 84 | name: "Error", 85 | message: "Pattern 'testfiles/files/*.png' did not match any files", 86 | }); 87 | }); 88 | 89 | it("filters out patterns from single ignore file", async function () { 90 | const patterns = ["testfiles/files/*.json", "testfiles/files/*.yaml"]; 91 | const ignorePatternFiles = ["testfiles/ignorefiles/ignore-json"]; 92 | const expected = [ 93 | "testfiles/files/multi-doc.yaml", 94 | "testfiles/files/valid.yaml", 95 | ]; 96 | assert.deepStrictEqual( 97 | await getFiles(patterns, ignorePatternFiles), 98 | expected, 99 | ); 100 | }); 101 | 102 | it("filters out patterns from multiple ignore files", async function () { 103 | const patterns = [ 104 | "testfiles/files/*.json", 105 | "testfiles/files/*.yaml", 106 | "testfiles/files/*.toml", 107 | ]; 108 | const ignorePatternFiles = [ 109 | "testfiles/ignorefiles/ignore-json", 110 | "testfiles/ignorefiles/ignore-yaml", 111 | ]; 112 | const expected = ["testfiles/files/valid.toml"]; 113 | assert.deepStrictEqual( 114 | await getFiles(patterns, ignorePatternFiles), 115 | expected, 116 | ); 117 | }); 118 | 119 | it("throws if all matches filtered", async function () { 120 | const patterns = ["testfiles/files/*.json", "testfiles/files/*.yaml"]; 121 | const ignorePatternFiles = [ 122 | "testfiles/ignorefiles/ignore-json", 123 | "testfiles/ignorefiles/ignore-yaml", 124 | ]; 125 | await assert.rejects(getFiles(patterns, ignorePatternFiles), { 126 | name: "Error", 127 | message: "Could not find any files to validate", 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { cli } from "./cli.js"; 4 | 5 | import { bootstrap } from "global-agent"; 6 | bootstrap(); 7 | 8 | const exitCode = await cli(); 9 | process.exit(exitCode); 10 | -------------------------------------------------------------------------------- /src/io.js: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import isUrl from "is-url"; 4 | import { parseSchema } from "./parser.js"; 5 | 6 | async function getFromUrlOrFile(location, cache, base = null) { 7 | if (isUrl(location)) { 8 | return await cache.fetch(location); 9 | } else { 10 | if (base != null) { 11 | return parseSchema( 12 | await fs.promises.readFile(path.join(base, location), "utf8"), 13 | path.join(base, location), 14 | ); 15 | } 16 | } 17 | return parseSchema(await fs.promises.readFile(location, "utf8"), location); 18 | } 19 | 20 | export { getFromUrlOrFile }; 21 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | 3 | class Logger { 4 | constructor(verbosity = 0) { 5 | this.stderr = []; 6 | this.stdout = []; 7 | this.verbosity = verbosity; 8 | } 9 | 10 | setVerbosity(verbosity) { 11 | this.verbosity = verbosity; 12 | } 13 | 14 | writeOut(message) { 15 | process.stdout.write(message + "\n"); 16 | } 17 | 18 | writeErr(message) { 19 | process.stderr.write(message + "\n"); 20 | } 21 | 22 | resetStderr() { 23 | this.stderr = []; 24 | } 25 | 26 | resetStdout() { 27 | this.stdout = []; 28 | } 29 | 30 | log(message) { 31 | this.stdout.push(message); 32 | this.writeOut(message); 33 | } 34 | 35 | info(message) { 36 | const formatedMessage = chalk.blue.bold("ℹ ") + message; 37 | this.stderr.push(formatedMessage); 38 | this.writeErr(formatedMessage); 39 | } 40 | 41 | debug(message) { 42 | const formatedMessage = chalk.blue.bold("ℹ ") + message; 43 | this.stderr.push(formatedMessage); 44 | if (this.verbosity === 0) { 45 | return; 46 | } 47 | this.writeErr(formatedMessage); 48 | } 49 | 50 | warning(message) { 51 | const formatedMessage = chalk.yellow.bold("▲ ") + message; 52 | this.stderr.push(formatedMessage); 53 | this.writeErr(formatedMessage); 54 | } 55 | 56 | error(message) { 57 | const formatedMessage = chalk.red.bold("✖ ") + message; 58 | this.stderr.push(formatedMessage); 59 | this.writeErr(formatedMessage); 60 | } 61 | 62 | success(message) { 63 | const formatedMessage = chalk.green.bold("✔ ") + message; 64 | this.stderr.push(formatedMessage); 65 | this.writeErr(formatedMessage); 66 | } 67 | } 68 | 69 | const logger = new Logger(); 70 | export default logger; 71 | -------------------------------------------------------------------------------- /src/output-formatters.js: -------------------------------------------------------------------------------- 1 | import Ajv from "ajv"; 2 | 3 | function getDocumentLocation(result) { 4 | if (result.documentIndex == null) { 5 | return result.fileLocation; 6 | } 7 | return `${result.fileLocation}[${result.documentIndex}]`; 8 | } 9 | 10 | function formatErrors(location, errors) { 11 | const ajv = new Ajv(); 12 | let formattedErrors = []; 13 | 14 | if (errors) { 15 | formattedErrors = errors.map(function (error) { 16 | if ( 17 | error.keyword === "additionalProperties" && 18 | typeof error.params.additionalProperty === "string" 19 | ) { 20 | return { 21 | ...error, 22 | message: `${error.message}, found additional property '${error.params.additionalProperty}'`, 23 | }; 24 | } 25 | return error; 26 | }); 27 | } 28 | 29 | return ( 30 | ajv.errorsText(formattedErrors, { 31 | separator: "\n", 32 | dataVar: location + "#", 33 | }) + "\n" 34 | ); 35 | } 36 | 37 | export { formatErrors, getDocumentLocation }; 38 | -------------------------------------------------------------------------------- /src/output-formatters.spec.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | 3 | import { formatErrors } from "./output-formatters.js"; 4 | 5 | describe("formatErrors", function () { 6 | it("includes the name of additional properties in the log message", async function () { 7 | const errors = [ 8 | { 9 | instancePath: "", 10 | schemaPath: "#/additionalProperties", 11 | keyword: "additionalProperties", 12 | params: { additionalProperty: "foo" }, 13 | message: "must NOT have additional properties", 14 | }, 15 | ]; 16 | assert.equal( 17 | formatErrors("file.json", errors), 18 | "file.json# must NOT have additional properties, found additional property 'foo'\n", 19 | ); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/parser.js: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import yaml from "js-yaml"; 3 | import { Document } from "./plugins.js"; 4 | 5 | function parseFile(plugins, contents, filename, parser) { 6 | for (const plugin of plugins) { 7 | const parsedFile = plugin.parseInputFile(contents, filename, parser); 8 | 9 | if (parsedFile != null) { 10 | const maybeDocuments = Array.isArray(parsedFile) 11 | ? parsedFile 12 | : [parsedFile]; 13 | for (const doc of maybeDocuments) { 14 | if (!(doc instanceof Document)) { 15 | throw new Error( 16 | `Plugin ${plugin.constructor.name} returned an unexpected type from parseInputFile hook. Expected Document, got ${typeof doc}`, 17 | ); 18 | } 19 | } 20 | return maybeDocuments.map((md) => md.document); 21 | } 22 | } 23 | 24 | const errorMessage = parser 25 | ? `Unsupported format ${parser}` 26 | : `Unsupported format ${path.extname(filename).slice(1)}`; 27 | throw new Error(errorMessage); 28 | } 29 | 30 | function parseSchema(contents, location) { 31 | if (location.endsWith(".yml") || location.endsWith(".yaml")) { 32 | return yaml.load(contents); 33 | } 34 | /* 35 | Always fall back and assume json even if no .json extension 36 | Sometimes a JSON schema is served from a URL like 37 | https://json-stat.org/format/schema/2.0/ 38 | */ 39 | return JSON.parse(contents); 40 | } 41 | 42 | export { parseFile, parseSchema }; 43 | -------------------------------------------------------------------------------- /src/plugins.js: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | /** 4 | * Base class for all v8r plugins. 5 | * 6 | * @abstract 7 | */ 8 | class BasePlugin { 9 | /** 10 | * Name of the plugin. All plugins must declare a name starting with 11 | * `v8r-plugin-`. 12 | * 13 | * @type {string} 14 | * @static 15 | */ 16 | static name = "untitled plugin"; 17 | 18 | /** 19 | * Use the `registerInputFileParsers` hook to tell v8r about additional file 20 | * formats that can be parsed. Any parsers registered with this hook become 21 | * valid values for the `parser` property in custom schemas. 22 | * 23 | * @returns {string[]} File parsers to register 24 | */ 25 | registerInputFileParsers() { 26 | return []; 27 | } 28 | 29 | /** 30 | * Use the `parseInputFile` hook to tell v8r how to parse files. 31 | * 32 | * If `parseInputFile` returns anything other than undefined, that return 33 | * value will be used and no further plugins will be invoked. If 34 | * `parseInputFile` returns undefined, v8r will move on to the next plugin in 35 | * the stack. The result of successfully parsing a file can either be a single 36 | * Document object or an array of Document objects. 37 | * 38 | * @param {string} contents - The unparsed file content. 39 | * @param {string} fileLocation - The file path. 40 | * @param {string | undefined} parser - If the user has specified a parser to 41 | * use for this file in a custom schema, this will be passed to 42 | * `parseInputFile` in the `parser` param. 43 | * @returns {Document | Document[] | undefined} Parsed file contents 44 | */ 45 | // eslint-disable-next-line no-unused-vars 46 | parseInputFile(contents, fileLocation, parser) { 47 | return undefined; 48 | } 49 | 50 | /** 51 | * Use the `registerOutputFormats` hook to tell v8r about additional output 52 | * formats that can be generated. Any formats registered with this hook become 53 | * valid values for the `outputFormat` property in the config file and the 54 | * `--output-format` command line argument. 55 | * 56 | * @returns {string[]} Output formats to register 57 | */ 58 | registerOutputFormats() { 59 | return []; 60 | } 61 | 62 | /** 63 | * Use the `getSingleResultLogMessage` hook to provide a log message for v8r 64 | * to output after processing a single file. 65 | * 66 | * If `getSingleResultLogMessage` returns anything other than undefined, that 67 | * return value will be used and no further plugins will be invoked. If 68 | * `getSingleResultLogMessage` returns undefined, v8r will move on to the next 69 | * plugin in the stack. 70 | * 71 | * Any message returned from this function will be written to stdout. 72 | * 73 | * @param {ValidationResult} result - Result of attempting to validate this 74 | * document. 75 | * @param {string} format - The user's requested output format as specified in 76 | * the config file or via the `--output-format` command line argument. 77 | * @returns {string | undefined} Log message 78 | */ 79 | // eslint-disable-next-line no-unused-vars 80 | getSingleResultLogMessage(result, format) { 81 | return undefined; 82 | } 83 | 84 | /** 85 | * Use the `getAllResultsLogMessage` hook to provide a log message for v8r to 86 | * output after processing all files. 87 | * 88 | * If `getAllResultsLogMessage` returns anything other than undefined, that 89 | * return value will be used and no further plugins will be invoked. If 90 | * `getAllResultsLogMessage` returns undefined, v8r will move on to the next 91 | * plugin in the stack. 92 | * 93 | * Any message returned from this function will be written to stdout. 94 | * 95 | * @param {ValidationResult[]} results - Results of attempting to validate 96 | * these documents. 97 | * @param {string} format - The user's requested output format as specified in 98 | * the config file or via the `--output-format` command line argument. 99 | * @returns {string | undefined} Log message 100 | */ 101 | // eslint-disable-next-line no-unused-vars 102 | getAllResultsLogMessage(results, format) { 103 | return undefined; 104 | } 105 | } 106 | 107 | class Document { 108 | /** 109 | * Document is a thin wrapper class for a document we want to validate after 110 | * parsing a file 111 | * 112 | * @param {any} document - The object to be wrapped 113 | */ 114 | constructor(document) { 115 | this.document = document; 116 | } 117 | } 118 | 119 | function validatePlugin(plugin) { 120 | if ( 121 | typeof plugin.name !== "string" || 122 | !plugin.name.startsWith("v8r-plugin-") 123 | ) { 124 | throw new Error(`Plugin ${plugin.name} does not declare a valid name`); 125 | } 126 | 127 | if (!(plugin.prototype instanceof BasePlugin)) { 128 | throw new Error(`Plugin ${plugin.name} does not extend BasePlugin`); 129 | } 130 | 131 | for (const prop of Object.getOwnPropertyNames(BasePlugin.prototype)) { 132 | const method = plugin.prototype[prop]; 133 | const argCount = plugin.prototype[prop].length; 134 | if (typeof method !== "function") { 135 | throw new Error( 136 | `Error loading plugin ${plugin.name}: must have a method called ${method}`, 137 | ); 138 | } 139 | const expectedArgs = BasePlugin.prototype[prop].length; 140 | if (expectedArgs !== argCount) { 141 | throw new Error( 142 | `Error loading plugin ${plugin.name}: ${prop} must take exactly ${expectedArgs} arguments`, 143 | ); 144 | } 145 | } 146 | } 147 | 148 | function resolveUserPlugins(userPlugins) { 149 | let plugins = []; 150 | for (let plugin of userPlugins) { 151 | if (plugin.startsWith("package:")) { 152 | plugins.push(plugin.slice(8)); 153 | } 154 | if (plugin.startsWith("file:")) { 155 | plugins.push(path.resolve(process.cwd(), plugin.slice(5))); 156 | } 157 | } 158 | return plugins; 159 | } 160 | 161 | async function loadPlugins(plugins) { 162 | let loadedPlugins = []; 163 | for (const plugin of plugins) { 164 | loadedPlugins.push(await import(plugin)); 165 | } 166 | loadedPlugins = loadedPlugins.map((plugin) => plugin.default); 167 | loadedPlugins.forEach((plugin) => validatePlugin(plugin)); 168 | loadedPlugins = loadedPlugins.map((plugin) => new plugin()); 169 | return loadedPlugins; 170 | } 171 | 172 | async function loadAllPlugins(userPlugins) { 173 | const loadedUserPlugins = await loadPlugins(userPlugins); 174 | 175 | const corePlugins = [ 176 | "./plugins/parser-json.js", 177 | "./plugins/parser-json5.js", 178 | "./plugins/parser-toml.js", 179 | "./plugins/parser-yaml.js", 180 | "./plugins/output-text.js", 181 | "./plugins/output-json.js", 182 | ]; 183 | const loadedCorePlugins = await loadPlugins(corePlugins); 184 | 185 | return { 186 | allLoadedPlugins: loadedUserPlugins.concat(loadedCorePlugins), 187 | loadedCorePlugins, 188 | loadedUserPlugins, 189 | }; 190 | } 191 | 192 | /** 193 | * @typedef {object} ValidationResult 194 | * @property {string} fileLocation - Path of the document that was validated. 195 | * @property {number | null} documentIndex - Some file formats allow multiple 196 | * documents to be embedded in one file (e.g: 197 | * [yaml](https://www.yaml.info/learn/document.html)). In these cases, 198 | * `documentIndex` identifies is used to identify the sub document within the 199 | * file. `documentIndex` will be `null` when there is a one-to-one 200 | * relationship between file and document. 201 | * @property {string | null} schemaLocation - Location of the schema used to 202 | * validate this file if one could be found. `null` if no schema was found. 203 | * @property {boolean | null} valid - Result of the validation (true/false) if a 204 | * schema was found. `null` if no schema was found and no validation could be 205 | * performed. 206 | * @property {ErrorObject[]} errors - An array of [AJV Error 207 | * Objects](https://ajv.js.org/api.html#error-objects) describing any errors 208 | * encountered when validating this document. 209 | */ 210 | 211 | /** 212 | * @external ErrorObject 213 | * @see https://ajv.js.org/api.html#error-objects 214 | */ 215 | 216 | export { BasePlugin, Document, loadAllPlugins, resolveUserPlugins }; 217 | -------------------------------------------------------------------------------- /src/plugins.spec.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import path from "node:path"; 3 | 4 | import { loadAllPlugins, resolveUserPlugins } from "./plugins.js"; 5 | import { parseFile } from "./parser.js"; 6 | 7 | describe("loadAllPlugins", function () { 8 | it("should load the core plugins", async function () { 9 | const plugins = await loadAllPlugins([]); 10 | assert.equal(plugins.allLoadedPlugins.length, 6); 11 | assert.equal(plugins.loadedCorePlugins.length, 6); 12 | assert.equal(plugins.loadedUserPlugins.length, 0); 13 | }); 14 | 15 | it("should load valid user plugins", async function () { 16 | const plugins = await loadAllPlugins(["../testfiles/plugins/valid.js"]); 17 | assert.equal(plugins.allLoadedPlugins.length, 7); 18 | assert.equal(plugins.loadedCorePlugins.length, 6); 19 | assert.equal(plugins.loadedUserPlugins.length, 1); 20 | }); 21 | 22 | it("should fail loading local plugin that does not exist", async function () { 23 | await assert.rejects( 24 | loadAllPlugins(["../testfiles/plugins/does-not-exist.js"]), 25 | { 26 | name: "Error", 27 | message: /^Cannot find module.*$/, 28 | }, 29 | ); 30 | }); 31 | 32 | it("should fail loading package plugin that does not exist", async function () { 33 | await assert.rejects(loadAllPlugins(["does-not-exist"]), { 34 | name: "Error", 35 | message: /^Cannot find package.*$/, 36 | }); 37 | }); 38 | 39 | it("should reject plugin with invalid name", async function () { 40 | await assert.rejects( 41 | loadAllPlugins(["../testfiles/plugins/invalid-name.js"]), 42 | { 43 | name: "Error", 44 | message: "Plugin bad-plugin-name does not declare a valid name", 45 | }, 46 | ); 47 | }); 48 | 49 | it("should reject plugin that does not extend BasePlugin", async function () { 50 | await assert.rejects( 51 | loadAllPlugins(["../testfiles/plugins/invalid-base.js"]), 52 | { 53 | name: "Error", 54 | message: 55 | "Plugin v8r-plugin-test-invalid-base does not extend BasePlugin", 56 | }, 57 | ); 58 | }); 59 | 60 | it("should reject plugin with invalid parameters", async function () { 61 | await assert.rejects( 62 | loadAllPlugins(["../testfiles/plugins/invalid-params.js"]), 63 | { 64 | name: "Error", 65 | message: 66 | "Error loading plugin v8r-plugin-test-invalid-params: registerInputFileParsers must take exactly 0 arguments", 67 | }, 68 | ); 69 | }); 70 | }); 71 | 72 | describe("resolveUserPlugins", function () { 73 | it("should resolve both file: and package: plugins", async function () { 74 | const resolvedPlugins = resolveUserPlugins([ 75 | "package:v8r-plugin-emoji-output", 76 | "file:./testfiles/plugins/valid.js", 77 | ]); 78 | 79 | assert.equal(resolvedPlugins.length, 2); 80 | 81 | assert.equal(resolvedPlugins[0], "v8r-plugin-emoji-output"); 82 | 83 | assert(path.isAbsolute(resolvedPlugins[1])); 84 | assert(resolvedPlugins[1].endsWith("/testfiles/plugins/valid.js")); 85 | }); 86 | }); 87 | 88 | describe("parseInputFile", function () { 89 | it("throws when parseInputFile returns unexpected object", async function () { 90 | const plugins = await loadAllPlugins([ 91 | "../testfiles/plugins/bad-parse-method1.js", 92 | ]); 93 | assert.throws( 94 | () => parseFile(plugins.allLoadedPlugins, "{}", "foo.json", null), 95 | { 96 | name: "Error", 97 | message: 98 | "Plugin v8r-plugin-test-bad-parse-method returned an unexpected type from parseInputFile hook. Expected Document, got object", 99 | }, 100 | ); 101 | }); 102 | 103 | it("throws when parseInputFile returns unexpected array", async function () { 104 | const plugins = await loadAllPlugins([ 105 | "../testfiles/plugins/bad-parse-method2.js", 106 | ]); 107 | assert.throws( 108 | () => parseFile(plugins.allLoadedPlugins, "{}", "foo.json", null), 109 | { 110 | name: "Error", 111 | message: 112 | "Plugin v8r-plugin-test-bad-parse-method returned an unexpected type from parseInputFile hook. Expected Document, got string", 113 | }, 114 | ); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/plugins/output-json.js: -------------------------------------------------------------------------------- 1 | import { BasePlugin } from "../plugins.js"; 2 | 3 | class JsonOutput extends BasePlugin { 4 | static name = "v8r-plugin-json-output"; 5 | 6 | registerOutputFormats() { 7 | return ["json"]; 8 | } 9 | 10 | getAllResultsLogMessage(results, format) { 11 | if (format === "json") { 12 | return JSON.stringify({ results }, null, 2); 13 | } 14 | } 15 | } 16 | 17 | export default JsonOutput; 18 | -------------------------------------------------------------------------------- /src/plugins/output-text.js: -------------------------------------------------------------------------------- 1 | import { BasePlugin } from "../plugins.js"; 2 | import { formatErrors, getDocumentLocation } from "../output-formatters.js"; 3 | 4 | class TextOutput extends BasePlugin { 5 | static name = "v8r-plugin-text-output"; 6 | 7 | registerOutputFormats() { 8 | return ["text"]; 9 | } 10 | 11 | getSingleResultLogMessage(result, format) { 12 | if (result.valid === false && format === "text") { 13 | return formatErrors(getDocumentLocation(result), result.errors); 14 | } 15 | } 16 | } 17 | 18 | export default TextOutput; 19 | -------------------------------------------------------------------------------- /src/plugins/parser-json.js: -------------------------------------------------------------------------------- 1 | import { BasePlugin, Document } from "../plugins.js"; 2 | 3 | class JsonParser extends BasePlugin { 4 | static name = "v8r-plugin-json-parser"; 5 | 6 | registerInputFileParsers() { 7 | return ["json"]; 8 | } 9 | 10 | parseInputFile(contents, fileLocation, parser) { 11 | if (parser === "json") { 12 | return new Document(JSON.parse(contents)); 13 | } else if (parser == null) { 14 | if ( 15 | fileLocation.endsWith(".json") || 16 | fileLocation.endsWith(".geojson") || 17 | fileLocation.endsWith(".jsonld") 18 | ) { 19 | return new Document(JSON.parse(contents)); 20 | } 21 | } 22 | } 23 | } 24 | 25 | export default JsonParser; 26 | -------------------------------------------------------------------------------- /src/plugins/parser-json5.js: -------------------------------------------------------------------------------- 1 | import JSON5 from "json5"; 2 | import { BasePlugin, Document } from "../plugins.js"; 3 | 4 | class Json5Parser extends BasePlugin { 5 | static name = "v8r-plugin-json5-parser"; 6 | 7 | registerInputFileParsers() { 8 | return ["json5"]; 9 | } 10 | 11 | parseInputFile(contents, fileLocation, parser) { 12 | if (parser === "json5") { 13 | return new Document(JSON5.parse(contents)); 14 | } else if (parser == null) { 15 | if (fileLocation.endsWith(".json5") || fileLocation.endsWith(".jsonc")) { 16 | return new Document(JSON5.parse(contents)); 17 | } 18 | } 19 | } 20 | } 21 | 22 | export default Json5Parser; 23 | -------------------------------------------------------------------------------- /src/plugins/parser-toml.js: -------------------------------------------------------------------------------- 1 | import { parse } from "smol-toml"; 2 | import { BasePlugin, Document } from "../plugins.js"; 3 | 4 | class TomlParser extends BasePlugin { 5 | static name = "v8r-plugin-toml-parser"; 6 | 7 | registerInputFileParsers() { 8 | return ["toml"]; 9 | } 10 | 11 | parseInputFile(contents, fileLocation, parser) { 12 | if (parser === "toml") { 13 | return new Document(parse(contents)); 14 | } else if (parser == null) { 15 | if (fileLocation.endsWith(".toml")) { 16 | return new Document(parse(contents)); 17 | } 18 | } 19 | } 20 | } 21 | 22 | export default TomlParser; 23 | -------------------------------------------------------------------------------- /src/plugins/parser-yaml.js: -------------------------------------------------------------------------------- 1 | import yaml from "js-yaml"; 2 | import { BasePlugin, Document } from "../plugins.js"; 3 | 4 | class YamlParser extends BasePlugin { 5 | static name = "v8r-plugin-yaml-parser"; 6 | 7 | registerInputFileParsers() { 8 | return ["yaml"]; 9 | } 10 | 11 | parseInputFile(contents, fileLocation, parser) { 12 | if (parser === "yaml") { 13 | return yaml.loadAll(contents).map((doc) => new Document(doc)); 14 | } else if (parser == null) { 15 | if (fileLocation.endsWith(".yaml") || fileLocation.endsWith(".yml")) { 16 | return yaml.loadAll(contents).map((doc) => new Document(doc)); 17 | } 18 | } 19 | } 20 | } 21 | 22 | export default YamlParser; 23 | -------------------------------------------------------------------------------- /src/public.js: -------------------------------------------------------------------------------- 1 | import { BasePlugin, Document } from "./plugins.js"; 2 | 3 | export { BasePlugin, Document }; 4 | -------------------------------------------------------------------------------- /src/test-helpers.js: -------------------------------------------------------------------------------- 1 | import { clearCacheById } from "flat-cache"; 2 | import logger from "./logger.js"; 3 | 4 | const origWriteOut = logger.writeOut; 5 | const origWriteErr = logger.writeErr; 6 | const testCacheName = process.env.V8R_CACHE_NAME; 7 | const env = process.env; 8 | 9 | function setUp() { 10 | clearCacheById(testCacheName); 11 | logger.resetStdout(); 12 | logger.resetStderr(); 13 | logger.writeOut = function () {}; 14 | logger.writeErr = function () {}; 15 | process.env = { ...env }; 16 | } 17 | 18 | function tearDown() { 19 | clearCacheById(testCacheName); 20 | logger.resetStdout(); 21 | logger.resetStderr(); 22 | logger.writeOut = origWriteOut; 23 | logger.writeErr = origWriteErr; 24 | process.env = env; 25 | } 26 | 27 | function isString(el) { 28 | return typeof el === "string" || el instanceof String; 29 | } 30 | 31 | function logContainsSuccess(expectedString, expectedCount = 1) { 32 | const counter = (count, el) => 33 | count + (isString(el) && el.includes("✔") && el.includes(expectedString)); 34 | return logger.stderr.reduce(counter, 0) === expectedCount; 35 | } 36 | 37 | function logContainsInfo(expectedString, expectedCount = 1) { 38 | const counter = (count, el) => 39 | count + (isString(el) && el.includes("ℹ") && el.includes(expectedString)); 40 | return logger.stderr.reduce(counter, 0) === expectedCount; 41 | } 42 | 43 | function logContainsError(expectedString, expectedCount = 1) { 44 | const counter = (count, el) => 45 | count + (isString(el) && el.includes("✖") && el.includes(expectedString)); 46 | return logger.stderr.reduce(counter, 0) === expectedCount; 47 | } 48 | 49 | export { 50 | testCacheName, 51 | setUp, 52 | tearDown, 53 | logContainsSuccess, 54 | logContainsInfo, 55 | logContainsError, 56 | }; 57 | -------------------------------------------------------------------------------- /testfiles/catalogs/catalog-malformed.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/schema-catalog.json", 3 | "version": 1.0, 4 | "schemas": [ 5 | { 6 | "name": "Valid custom schema", 7 | "description": "", 8 | "fileMatch": [ 9 | "valid.json", 10 | "valid.yml" 11 | ], 12 | "url": "testfiles/schemas/schema.json" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /testfiles/catalogs/catalog-nomatch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1.0, 3 | "schemas": [] 4 | } 5 | -------------------------------------------------------------------------------- /testfiles/catalogs/catalog-url-with-yaml-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/schema-catalog.json", 3 | "version": 1.0, 4 | "schemas": [ 5 | { 6 | "name": "Valid custom schema", 7 | "description": "", 8 | "fileMatch": [ 9 | "valid.json", 10 | "valid.yml" 11 | ], 12 | "url": "https://example.com/schema.yaml" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /testfiles/catalogs/catalog-url.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/schema-catalog.json", 3 | "version": 1.0, 4 | "schemas": [ 5 | { 6 | "name": "Valid custom schema", 7 | "description": "", 8 | "fileMatch": [ 9 | "valid.json", 10 | "valid.yml" 11 | ], 12 | "url": "https://example.com/schema.json" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /testfiles/configs/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "cacheTtl": 300, 3 | "customCatalog": { 4 | "schemas": [ 5 | { 6 | "name": "custom schema", 7 | "fileMatch": ["valid.json", "invalid.json"], 8 | "location": "./testfiles/schemas/schema.json", 9 | "parser": "json5" 10 | } 11 | ] 12 | }, 13 | "ignoreErrors": true, 14 | "ignorePatternFiles": [".v8rignore"], 15 | "patterns": ["./foobar/*.json"], 16 | "verbose": 1 17 | } 18 | -------------------------------------------------------------------------------- /testfiles/files/invalid.json: -------------------------------------------------------------------------------- 1 | {"num": "foo"} 2 | 3 | -------------------------------------------------------------------------------- /testfiles/files/multi-doc.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | num: 4 3 | --- 4 | num: 200 5 | --- 6 | num: foo 7 | 8 | # this yaml file contains 3 documents 9 | # the first two are valid, the third is not 10 | -------------------------------------------------------------------------------- /testfiles/files/not-supported.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chris48s/v8r/ead986997948d0520b0e4ab06d05880f9256cd35/testfiles/files/not-supported.txt -------------------------------------------------------------------------------- /testfiles/files/valid.json: -------------------------------------------------------------------------------- 1 | {"num": 4} 2 | 3 | -------------------------------------------------------------------------------- /testfiles/files/valid.json5: -------------------------------------------------------------------------------- 1 | { 2 | // not valid json, but valid json5 3 | "num": 4, 4 | } 5 | -------------------------------------------------------------------------------- /testfiles/files/valid.toml: -------------------------------------------------------------------------------- 1 | num = 4 -------------------------------------------------------------------------------- /testfiles/files/valid.yaml: -------------------------------------------------------------------------------- 1 | num: 4 2 | -------------------------------------------------------------------------------- /testfiles/files/with-comments.json: -------------------------------------------------------------------------------- 1 | { 2 | // this file has a .json extension 3 | // but it has comments in it 4 | "num": 4 5 | } 6 | 7 | -------------------------------------------------------------------------------- /testfiles/ignorefiles/ignore-json: -------------------------------------------------------------------------------- 1 | *.json 2 | -------------------------------------------------------------------------------- /testfiles/ignorefiles/ignore-yaml: -------------------------------------------------------------------------------- 1 | *.yml 2 | *.yaml 3 | -------------------------------------------------------------------------------- /testfiles/plugins/bad-parse-method1.js: -------------------------------------------------------------------------------- 1 | import { BasePlugin } from "v8r"; 2 | 3 | export default class BadParseMethod1TestPlugin extends BasePlugin { 4 | static name = "v8r-plugin-test-bad-parse-method"; 5 | 6 | // eslint-disable-next-line no-unused-vars 7 | parseInputFile(contents, fileLocation, parser) { 8 | // this method returns something other than a Document object, 9 | // which should cause a failure 10 | return { foo: "bar" }; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /testfiles/plugins/bad-parse-method2.js: -------------------------------------------------------------------------------- 1 | import { BasePlugin, Document } from "v8r"; 2 | 3 | export default class BadParseMethod2TestPlugin extends BasePlugin { 4 | static name = "v8r-plugin-test-bad-parse-method"; 5 | 6 | // eslint-disable-next-line no-unused-vars 7 | parseInputFile(contents, fileLocation, parser) { 8 | // if we are returning an array 9 | // all objects in the array should be a Document 10 | return [new Document({}), "foobar"]; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /testfiles/plugins/invalid-base.js: -------------------------------------------------------------------------------- 1 | export default class InvalidBaseTestPlugin { 2 | static name = "v8r-plugin-test-invalid-base"; 3 | } 4 | -------------------------------------------------------------------------------- /testfiles/plugins/invalid-name.js: -------------------------------------------------------------------------------- 1 | import { BasePlugin } from "v8r"; 2 | 3 | export default class InvalidNameTestPlugin extends BasePlugin { 4 | static name = "bad-plugin-name"; 5 | } 6 | -------------------------------------------------------------------------------- /testfiles/plugins/invalid-params.js: -------------------------------------------------------------------------------- 1 | import { BasePlugin } from "v8r"; 2 | 3 | export default class InvalidParamsTestPlugin extends BasePlugin { 4 | static name = "v8r-plugin-test-invalid-params"; 5 | 6 | // eslint-disable-next-line no-unused-vars 7 | registerInputFileParsers(foo) { 8 | return []; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /testfiles/plugins/valid.js: -------------------------------------------------------------------------------- 1 | import { BasePlugin } from "v8r"; 2 | 3 | export default class ValidTestPlugin extends BasePlugin { 4 | static name = "v8r-plugin-test-valid"; 5 | } 6 | -------------------------------------------------------------------------------- /testfiles/schemas/fragment.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "num": { 5 | "type": "number" 6 | } 7 | }, 8 | "required": ["num"], 9 | "additionalProperties": false 10 | } 11 | -------------------------------------------------------------------------------- /testfiles/schemas/invalid_external_ref.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$ref": "./does-not-exist.json" 4 | } 5 | -------------------------------------------------------------------------------- /testfiles/schemas/local_external_ref_with_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "local_external_refs_with_id.json", 4 | "$ref": "./fragment.json" 5 | } 6 | -------------------------------------------------------------------------------- /testfiles/schemas/local_external_ref_without_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$ref": "./fragment.json" 4 | } 5 | -------------------------------------------------------------------------------- /testfiles/schemas/remote_external_ref.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "https://example.com/foobar/remote_external_refs.json", 4 | "$ref": "./fragment.json" 5 | } 6 | -------------------------------------------------------------------------------- /testfiles/schemas/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "properties": { 5 | "num": { 6 | "type": "number" 7 | } 8 | }, 9 | "required": ["num"], 10 | "additionalProperties": false 11 | } 12 | -------------------------------------------------------------------------------- /testfiles/schemas/schema.multi-doc.yaml: -------------------------------------------------------------------------------- 1 | $schema: http://json-schema.org/draft-07/schema# 2 | type: object 3 | properties: 4 | num: 5 | type: number 6 | required: 7 | - num 8 | additionalProperties: false 9 | ... 10 | --- 11 | $schema: http://json-schema.org/draft-07/schema# 12 | type: object 13 | properties: 14 | num: 15 | type: number 16 | required: 17 | - num 18 | additionalProperties: false 19 | -------------------------------------------------------------------------------- /testfiles/schemas/schema.yaml: -------------------------------------------------------------------------------- 1 | $schema: http://json-schema.org/draft-07/schema# 2 | type: object 3 | properties: 4 | num: 5 | type: number 6 | required: 7 | - num 8 | additionalProperties: false 9 | --------------------------------------------------------------------------------