Skip to content

Commit 7611465

Browse files
miteshasharclaude
andauthored
feat: add opt-in test coverage analysis tool (SchemaStore#5383)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 68b3844 commit 7611465

6 files changed

Lines changed: 1021 additions & 0 deletions

File tree

.github/workflows/validate.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ jobs:
1818
- run: 'npm run typecheck'
1919
- run: 'npm run eslint'
2020
- run: 'node ./cli.js check'
21+
- run: 'node ./cli.js coverage'

CONTRIBUTING.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
- [How to add a `$ref` to a JSON Schema that's hosted in this repository](#how-to-add-a-ref-to-a-json-schema-thats-hosted-in-this-repository)
4141
- [How to add a `$ref` to a JSON Schema that's self-hosted](#how-to-add-a-ref-to-a-json-schema-thats-self-hosted)
4242
- [How to validate a JSON Schema](#how-to-validate-a-json-schema)
43+
- [How to check test coverage for a JSON Schema](#how-to-check-test-coverage-for-a-json-schema)
4344
- [How to ignore validation errors in a JSON Schema](#how-to-ignore-validation-errors-in-a-json-schema)
4445
- [How to name schemas that are subschemas (`partial-`)](#how-to-name-schemas-that-are-subschemas-partial-)
4546
- [Older Links](#older-links)
@@ -670,6 +671,39 @@ For example, to validate the [`ava.json`](https://github.com/SchemaStore/schemas
670671

671672
Note that `<schemaName.json>` refers to the _filename_ that the schema has under `src/schemas/json`.
672673

674+
### How to check test coverage for a JSON Schema
675+
676+
The coverage tool analyzes how thoroughly your schema's test files exercise its constraints. It runs 8 checks:
677+
678+
1. **Unused `$defs`** — flags `$defs`/`definitions` entries not referenced by any `$ref`
679+
2. **Description coverage** — flags properties missing a `description`
680+
3. **Test completeness** — checks that every top-level schema property appears in at least one positive test
681+
4. **Enum coverage** — checks that each enum value has positive test coverage and at least one invalid value in negative tests
682+
5. **Pattern coverage** — checks that each `pattern` constraint has a matching and a violating test value
683+
6. **Required field coverage** — checks that negative tests omit required fields
684+
7. **Default value coverage** — checks that positive tests include non-default values
685+
8. **Negative test isolation** — flags negative test files that test multiple unrelated violation types
686+
687+
**Opting in:** Add your schema to the `coverage` array in `src/schema-validation.jsonc`:
688+
689+
```jsonc
690+
"coverage": [
691+
{ "schema": "my-schema.json" },
692+
{ "schema": "my-strict-schema.json", "strict": true }
693+
]
694+
```
695+
696+
- `strict` (default: `false`) — when `true`, coverage failures cause a non-zero exit code, enforced in CI.
697+
- Without `strict: true`, the tool reports findings but does not fail CI.
698+
699+
**Running locally:**
700+
701+
```console
702+
node ./cli.js coverage --schema-name=my-schema.json
703+
```
704+
705+
Coverage is opt-in and runs in CI. Schemas with `strict: true` will block PRs on coverage failures. Schemas without `strict` get an advisory report only.
706+
673707
### How to ignore validation errors in a JSON Schema
674708

675709
> **Note**

cli.js

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ import jsonlint from '@prantlf/jsonlint'
1919
import * as jsoncParser from 'jsonc-parser'
2020
import ora from 'ora'
2121
import chalk from 'chalk'
22+
import {
23+
checkUnusedDefs,
24+
checkDescriptionCoverage,
25+
checkTestCompleteness,
26+
checkEnumCoverage,
27+
checkPatternCoverage,
28+
checkRequiredCoverage,
29+
checkDefaultCoverage,
30+
checkNegativeIsolation,
31+
printCoverageReport,
32+
} from './src/helpers/coverage.js'
2233
import minimist from 'minimist'
2334
import fetch, { FetchError } from 'node-fetch'
2435
import { execFile } from 'node:child_process'
@@ -144,6 +155,7 @@ if (argv.SchemaName) {
144155
* @property {string[]} highSchemaVersion
145156
* @property {string[]} missingCatalogUrl
146157
* @property {string[]} skiptest
158+
* @property {{schema: string, strict?: boolean}[]} coverage
147159
* @property {string[]} catalogEntryNoLintNameOrDescription
148160
* @property {Record<string, SchemaValidationJsonOption>} options
149161
*/
@@ -1481,6 +1493,10 @@ async function assertSchemaValidationJsonReferencesNoNonexistentFiles() {
14811493
schemaNamesMustExist(SchemaValidation.skiptest, 'skiptest')
14821494
schemaNamesMustExist(SchemaValidation.missingCatalogUrl, 'missingCatalogUrl')
14831495
schemaNamesMustExist(SchemaValidation.highSchemaVersion, 'highSchemaVersion')
1496+
schemaNamesMustExist(
1497+
(SchemaValidation.coverage ?? []).map((c) => c.schema),
1498+
'coverage',
1499+
)
14841500
for (const schemaName in SchemaValidation.options) {
14851501
if (!SchemasToBeTested.includes(schemaName)) {
14861502
printErrorAndExit(new Error(), [
@@ -2060,6 +2076,7 @@ TASKS:
20602076
check-remote: Run all build checks for remote schemas
20612077
maintenance: Run maintenance checks
20622078
build-xregistry: Build the xRegistry from the catalog.json
2079+
coverage: Run test coverage analysis on opted-in schemas
20632080
20642081
EXAMPLES:
20652082
node ./cli.js check
@@ -2132,6 +2149,113 @@ EXAMPLES:
21322149
}
21332150
}
21342151

2152+
// ---------------------------------------------------------------------------
2153+
// Coverage task
2154+
// ---------------------------------------------------------------------------
2155+
2156+
async function taskCoverage() {
2157+
const coverageSchemas = SchemaValidation.coverage ?? []
2158+
if (coverageSchemas.length === 0) {
2159+
console.info(
2160+
'No schemas opted into coverage. Add schemas to "coverage" in schema-validation.jsonc',
2161+
)
2162+
return
2163+
}
2164+
2165+
const spinner = ora()
2166+
spinner.start()
2167+
let hasFailure = false
2168+
let hasMatch = false
2169+
2170+
for (const entry of coverageSchemas) {
2171+
const schemaName = entry.schema
2172+
const strict = entry.strict ?? false
2173+
if (argv['schema-name'] && argv['schema-name'] !== schemaName) {
2174+
continue
2175+
}
2176+
hasMatch = true
2177+
2178+
const schemaId = schemaName.replace('.json', '')
2179+
spinner.text = `Running coverage checks on "${schemaName}"${strict ? ' (strict)' : ''}`
2180+
2181+
// Load schema
2182+
const schemaFile = await toFile(path.join(SchemaDir, schemaName))
2183+
const schema = /** @type {Record<string, unknown>} */ (schemaFile.json)
2184+
2185+
// Load positive test files
2186+
const positiveTests = new Map()
2187+
const posDir = path.join(TestPositiveDir, schemaId)
2188+
for (const testfile of await fs.readdir(posDir).catch(() => [])) {
2189+
if (isIgnoredFile(testfile)) continue
2190+
const file = await toFile(path.join(posDir, testfile))
2191+
positiveTests.set(testfile, file.json)
2192+
}
2193+
2194+
// Load negative test files
2195+
const negativeTests = new Map()
2196+
const negDir = path.join(TestNegativeDir, schemaId)
2197+
for (const testfile of await fs.readdir(negDir).catch(() => [])) {
2198+
if (isIgnoredFile(testfile)) continue
2199+
const file = await toFile(path.join(negDir, testfile))
2200+
negativeTests.set(testfile, file.json)
2201+
}
2202+
2203+
// Run all 8 checks
2204+
const results = [
2205+
{ name: '1. Unused $defs', result: checkUnusedDefs(schema) },
2206+
{
2207+
name: '2. Description Coverage',
2208+
result: checkDescriptionCoverage(schema),
2209+
},
2210+
{
2211+
name: '3. Test Completeness',
2212+
result: checkTestCompleteness(schema, positiveTests),
2213+
},
2214+
{
2215+
name: '4. Enum Coverage',
2216+
result: checkEnumCoverage(schema, positiveTests, negativeTests),
2217+
},
2218+
{
2219+
name: '5. Pattern Coverage',
2220+
result: checkPatternCoverage(schema, positiveTests, negativeTests),
2221+
},
2222+
{
2223+
name: '6. Required Field Coverage',
2224+
result: checkRequiredCoverage(schema, negativeTests),
2225+
},
2226+
{
2227+
name: '7. Default Value Coverage',
2228+
result: checkDefaultCoverage(schema, positiveTests),
2229+
},
2230+
{
2231+
name: '8. Negative Test Isolation',
2232+
result: checkNegativeIsolation(schema, negativeTests),
2233+
},
2234+
]
2235+
2236+
spinner.stop()
2237+
printCoverageReport(schemaName, results)
2238+
if (strict && results.some((r) => r.result.status === 'fail'))
2239+
hasFailure = true
2240+
2241+
// Restart spinner for next schema
2242+
if (coverageSchemas.indexOf(entry) < coverageSchemas.length - 1) {
2243+
spinner.start()
2244+
}
2245+
}
2246+
2247+
if (!hasMatch) {
2248+
spinner.stop()
2249+
printErrorAndExit(null, [
2250+
`Schema "${argv['schema-name']}" is not in the coverage list in "${SchemaValidationFile}"`,
2251+
])
2252+
}
2253+
2254+
if (hasFailure) {
2255+
process.exit(1)
2256+
}
2257+
}
2258+
21352259
/** @type {Record<string, () => Promise<unknown>>} */
21362260
const taskMap = {
21372261
'new-schema': taskNewSchema,
@@ -2143,6 +2267,7 @@ EXAMPLES:
21432267
maintenance: taskMaintenance,
21442268
'build-website': taskBuildWebsite,
21452269
'build-xregistry': taskBuildXRegistry,
2270+
coverage: taskCoverage,
21462271
build: taskCheck, // Undocumented alias.
21472272
}
21482273
const taskOrFn = argv._[0]

0 commit comments

Comments
 (0)