├── .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 | [](https://github.com/chris48s/v8r/actions/workflows/build.yml?query=branch%3Amain)
4 | [](https://app.codecov.io/gh/chris48s/v8r)
5 | [](https://www.npmjs.com/package/v8r)
6 | [](https://www.npmjs.com/package/v8r)
7 | [](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 |
--------------------------------------------------------------------------------
/docs/static/img/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chris48s/v8r/ead986997948d0520b0e4ab06d05880f9256cd35/docs/static/img/logo.png
--------------------------------------------------------------------------------
/docs/static/img/npm.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------